Import TexasResellers into Gitea

This commit is contained in:
Claude
2026-07-30 18:39:06 -05:00
commit bc1b610332
24 changed files with 8910 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
node_modules/
dist/
.astro/
__pycache__/
*.pyc
.venv/
+40
View File
@@ -0,0 +1,40 @@
# Texas Resellers — Inventory Frontend
Client-branded inventory management web app built for Texas Resellers. Single-page
React application (no backend included in this repo) for tracking inventory items,
generating/scanning QR labels, and managing per-item detail records.
## Tech Stack
- React 18 + React Router 6
- Vite (build tool / dev server)
- Tailwind CSS (dark theme by default, see `frontend/index.html`)
- `qrcode.react` + `jsqr` for QR code generation and camera-based scanning
- `lucide-react` for icons
## Key Files / Entry Points
- `frontend/index.html` — app shell, mounts React at `#root`
- `frontend/src/main.jsx` — React entry point
- `frontend/src/App.jsx` — route definitions
- `frontend/src/context/DataContext.jsx` — shared app/inventory state
- `frontend/src/pages/` — screens: `Dashboard`, `Inventory`, `ItemDetail`, `ItemForm`,
`LabelDesigner` (QR label creation), `Scanner` (camera QR scanning), `Settings`
- `frontend/src/components/` — shared UI: `Layout`, `Sidebar`, `StatusBadge`, `CategoryBadge`
## Running Locally
```bash
cd frontend
npm install
npm run dev # Vite dev server
npm run build # production build
npm run lint
```
## Status
Working frontend prototype. No backend/API or persistence layer is present in this
repo as of import — `DataContext` appears to hold state client-side. Client-branded
project (Texas Resellers is a specific MSP client); this is the client's own
application code, not a copy of client personal/business data.
+23
View File
@@ -0,0 +1,23 @@
<!DOCTYPE html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Texas Resellers</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&display=swap" rel="stylesheet" />
<style>
@media print {
body * { visibility: hidden; }
#print-area, #print-area * { visibility: visible; }
#print-area { position: absolute; left: 0; top: 0; }
}
</style>
</head>
<body class="bg-gray-900 text-gray-100">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+5863
View File
File diff suppressed because it is too large Load Diff
+32
View File
@@ -0,0 +1,32 @@
{
"name": "texas-resellers",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.22.0",
"lucide-react": "^0.344.0",
"qrcode.react": "^3.1.0",
"jsqr": "^1.4.0"
},
"devDependencies": {
"@types/react": "^18.2.55",
"@types/react-dom": "^18.2.19",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.17",
"postcss": "^8.4.35",
"tailwindcss": "^3.4.1",
"eslint": "^8.56.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="8" fill="#f59e0b"/>
<text x="16" y="22" text-anchor="middle" font-size="18" font-family="sans-serif" font-weight="bold" fill="#1c1917">TR</text>
</svg>

After

Width:  |  Height:  |  Size: 250 B

+36
View File
@@ -0,0 +1,36 @@
import React from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { DataProvider } from './context/DataContext'
import Layout from './components/Layout'
import Dashboard from './pages/Dashboard'
import Inventory from './pages/Inventory'
import ItemForm from './pages/ItemForm'
import ItemDetail from './pages/ItemDetail'
import LabelDesigner from './pages/LabelDesigner'
import Scanner from './pages/Scanner'
import Settings from './pages/Settings'
export default function App() {
return (
<DataProvider>
<BrowserRouter>
<Routes>
<Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route path="inventory">
<Route index element={<Inventory />} />
<Route path="new" element={<ItemForm />} />
<Route path=":id" element={<ItemDetail />} />
<Route path=":id/edit" element={<ItemForm />} />
</Route>
<Route path="labels" element={<LabelDesigner />} />
<Route path="scanner" element={<Scanner />} />
<Route path="settings" element={<Settings />} />
{/* Catch-all */}
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
</DataProvider>
)
}
+30
View File
@@ -0,0 +1,30 @@
import React from 'react'
import { Shirt, Cpu, Armchair } from 'lucide-react'
export default function CategoryBadge({ category }) {
switch (category) {
case 'Clothing':
return (
<span className="badge-purple inline-flex items-center gap-1">
<Shirt className="w-3 h-3" />
{category}
</span>
)
case 'Electronics':
return (
<span className="badge-blue inline-flex items-center gap-1">
<Cpu className="w-3 h-3" />
{category}
</span>
)
case 'Furniture':
return (
<span className="badge-orange inline-flex items-center gap-1">
<Armchair className="w-3 h-3" />
{category}
</span>
)
default:
return <span className="badge-gray">{category}</span>
}
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
import Sidebar from './Sidebar'
import { Outlet } from 'react-router-dom'
export default function Layout() {
return (
<div className="flex min-h-screen bg-gray-900">
<Sidebar />
<main className="flex-1 overflow-auto">
<Outlet />
</main>
</div>
)
}
+100
View File
@@ -0,0 +1,100 @@
import React, { useState } from 'react'
import { NavLink, useLocation } from 'react-router-dom'
import {
LayoutDashboard,
Package,
Tag,
ScanLine,
Settings,
ChevronLeft,
ChevronRight,
Star,
TrendingUp,
} from 'lucide-react'
const NAV_ITEMS = [
{ to: '/', label: 'Dashboard', icon: LayoutDashboard, exact: true },
{ to: '/inventory', label: 'Inventory', icon: Package },
{ to: '/labels', label: 'Labels', icon: Tag },
{ to: '/scanner', label: 'Scanner', icon: ScanLine },
{ to: '/settings', label: 'Settings', icon: Settings },
]
export default function Sidebar() {
const [collapsed, setCollapsed] = useState(false)
const location = useLocation()
return (
<aside
className={`
relative flex flex-col bg-gray-900 border-r border-gray-700 transition-all duration-300 ease-in-out
${collapsed ? 'w-16' : 'w-60'}
min-h-screen shrink-0
`}
>
{/* Logo */}
<div className={`flex items-center gap-3 px-4 py-5 border-b border-gray-700 ${collapsed ? 'justify-center' : ''}`}>
<div className="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500 shrink-0">
<Star className="w-5 h-5 text-gray-900" fill="currentColor" />
</div>
{!collapsed && (
<div className="overflow-hidden">
<p className="text-sm font-bold text-amber-400 leading-tight whitespace-nowrap">Texas Resellers</p>
<p className="text-xs text-gray-500 whitespace-nowrap">Inventory Manager</p>
</div>
)}
</div>
{/* Nav */}
<nav className="flex-1 py-4 space-y-1 px-2">
{NAV_ITEMS.map(({ to, label, icon: Icon, exact }) => {
const isActive = exact ? location.pathname === to : location.pathname.startsWith(to)
return (
<NavLink
key={to}
to={to}
className={() => `
flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150
${collapsed ? 'justify-center' : ''}
${isActive
? 'bg-amber-500 text-gray-900'
: 'text-gray-400 hover:bg-gray-800 hover:text-gray-100'
}
`}
title={collapsed ? label : undefined}
>
<Icon className="w-5 h-5 shrink-0" />
{!collapsed && <span>{label}</span>}
</NavLink>
)
})}
</nav>
{/* Bottom stats teaser */}
{!collapsed && (
<div className="px-3 pb-4">
<div className="bg-gray-800 rounded-lg p-3 border border-gray-700">
<div className="flex items-center gap-2 text-xs text-gray-400 mb-1">
<TrendingUp className="w-3.5 h-3.5 text-green-400" />
<span className="font-medium text-gray-300">This Month</span>
</div>
<p className="text-lg font-bold text-green-400">+$1,128</p>
<p className="text-xs text-gray-500">in profits</p>
</div>
</div>
)}
{/* Collapse toggle */}
<button
onClick={() => setCollapsed(c => !c)}
className="absolute -right-3 top-20 w-6 h-6 bg-gray-700 hover:bg-gray-600 border border-gray-600 rounded-full flex items-center justify-center text-gray-400 hover:text-gray-100 transition-colors z-10"
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed
? <ChevronRight className="w-3.5 h-3.5" />
: <ChevronLeft className="w-3.5 h-3.5" />
}
</button>
</aside>
)
}
+14
View File
@@ -0,0 +1,14 @@
import React from 'react'
export default function StatusBadge({ status }) {
switch (status) {
case 'In Stock':
return <span className="badge-blue">{status}</span>
case 'Listed':
return <span className="badge-yellow">{status}</span>
case 'Sold':
return <span className="badge-green">{status}</span>
default:
return <span className="badge-gray">{status}</span>
}
}
+467
View File
@@ -0,0 +1,467 @@
import React, { createContext, useContext, useState, useCallback } from 'react'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function generateAssetTag() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let tag = 'TR-'
for (let i = 0; i < 6; i++) {
tag += chars.charAt(Math.floor(Math.random() * chars.length))
}
return tag
}
function generateId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2)
}
// ─── Initial Mock Data ────────────────────────────────────────────────────────
const INITIAL_ITEMS = [
{
id: 'item001',
assetTag: 'TR-A1B2C3',
name: 'Apple iPhone 13 Pro 128GB',
category: 'Electronics',
condition: 'Good',
description: 'Minor scratches on back. Screen is perfect. Unlocked.',
purchasePrice: 180.00,
listPrice: 380.00,
salePrice: 360.00,
platform: 'eBay',
source: 'Facebook Marketplace',
status: 'Sold',
datePurchased: '2024-01-05',
dateSold: '2024-01-18',
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-01-05', note: 'Purchased from Facebook' },
{ status: 'Listed', date: '2024-01-06', note: 'Listed on eBay at $380' },
{ status: 'Sold', date: '2024-01-18', note: 'Sold for $360 on eBay' },
]
},
{
id: 'item002',
assetTag: 'TR-D4E5F6',
name: 'Samsung 65" 4K Smart TV',
category: 'Electronics',
condition: 'Like New',
description: 'Model UN65TU8000. Remote included. No dead pixels.',
purchasePrice: 120.00,
listPrice: 350.00,
salePrice: 325.00,
platform: 'Facebook Marketplace',
source: 'Estate Sale',
status: 'Sold',
datePurchased: '2024-01-10',
dateSold: '2024-01-20',
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-01-10', note: 'Purchased from estate sale' },
{ status: 'Listed', date: '2024-01-11', note: 'Listed on Facebook at $350' },
{ status: 'Sold', date: '2024-01-20', note: 'Sold for $325 on Facebook' },
]
},
{
id: 'item003',
assetTag: 'TR-G7H8I9',
name: 'Levi\'s 501 Jeans - Size 34x32',
category: 'Clothing',
condition: 'Good',
description: 'Classic straight leg. Minor fade, no rips or stains.',
purchasePrice: 4.00,
listPrice: 35.00,
salePrice: null,
platform: 'eBay',
source: 'Goodwill',
status: 'Listed',
datePurchased: '2024-01-22',
dateSold: null,
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-01-22', note: 'Purchased at Goodwill' },
{ status: 'Listed', date: '2024-01-23', note: 'Listed on eBay at $35' },
]
},
{
id: 'item004',
assetTag: 'TR-J1K2L3',
name: 'Mid-Century Modern Coffee Table',
category: 'Furniture',
condition: 'Fair',
description: 'Solid walnut. Some scratches on top. Legs are solid.',
purchasePrice: 25.00,
listPrice: 150.00,
salePrice: null,
platform: 'Facebook Marketplace',
source: 'Garage Sale',
status: 'Listed',
datePurchased: '2024-01-28',
dateSold: null,
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-01-28', note: 'Purchased at garage sale' },
{ status: 'Listed', date: '2024-01-29', note: 'Listed on Facebook at $150' },
]
},
{
id: 'item005',
assetTag: 'TR-M4N5O6',
name: 'Nike Air Jordan 1 Retro High OG - Size 11',
category: 'Clothing',
condition: 'Like New',
description: 'Worn once. Original box and laces included. Chicago colorway.',
purchasePrice: 85.00,
listPrice: 280.00,
salePrice: 265.00,
platform: 'eBay',
source: 'ThredUp',
status: 'Sold',
datePurchased: '2024-02-01',
dateSold: '2024-02-10',
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-02-01', note: 'Purchased from ThredUp' },
{ status: 'Listed', date: '2024-02-02', note: 'Listed on eBay at $280' },
{ status: 'Sold', date: '2024-02-10', note: 'Sold for $265 on eBay' },
]
},
{
id: 'item006',
assetTag: 'TR-P7Q8R9',
name: 'Dell XPS 15 Laptop (2021)',
category: 'Electronics',
condition: 'Good',
description: 'i7-11800H, 16GB RAM, 512GB SSD. Charger included. Battery at 87% health.',
purchasePrice: 350.00,
listPrice: 750.00,
salePrice: null,
platform: 'eBay',
source: 'Craigslist',
status: 'Listed',
datePurchased: '2024-02-05',
dateSold: null,
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-02-05', note: 'Purchased from Craigslist' },
{ status: 'Listed', date: '2024-02-06', note: 'Listed on eBay at $750' },
]
},
{
id: 'item007',
assetTag: 'TR-S1T2U3',
name: 'Vintage Wooden Dresser 6-Drawer',
category: 'Furniture',
condition: 'Fair',
description: '1960s style. Dovetail joints. Needs refinishing but structurally sound.',
purchasePrice: 30.00,
listPrice: 200.00,
salePrice: 185.00,
platform: 'Facebook Marketplace',
source: 'Estate Sale',
status: 'Sold',
datePurchased: '2024-02-08',
dateSold: '2024-02-22',
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-02-08', note: 'Purchased from estate sale' },
{ status: 'Listed', date: '2024-02-09', note: 'Listed on Facebook at $200' },
{ status: 'Sold', date: '2024-02-22', note: 'Sold for $185 on Facebook' },
]
},
{
id: 'item008',
assetTag: 'TR-V4W5X6',
name: 'Ralph Lauren Polo Shirt - Size L',
category: 'Clothing',
condition: 'Like New',
description: 'Navy blue. No stains or pilling. From non-smoking home.',
purchasePrice: 3.50,
listPrice: 28.00,
salePrice: null,
platform: 'eBay',
source: 'Goodwill',
status: 'In Stock',
datePurchased: '2024-02-14',
dateSold: null,
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-02-14', note: 'Purchased at Goodwill' },
]
},
{
id: 'item009',
assetTag: 'TR-Y7Z8A9',
name: 'Sony WH-1000XM5 Headphones',
category: 'Electronics',
condition: 'Good',
description: 'All buttons work. ANC works perfectly. Case and cable included.',
purchasePrice: 95.00,
listPrice: 220.00,
salePrice: null,
platform: 'Both',
source: 'Facebook Marketplace',
status: 'Listed',
datePurchased: '2024-02-18',
dateSold: null,
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-02-18', note: 'Purchased from Facebook' },
{ status: 'Listed', date: '2024-02-19', note: 'Listed on eBay and Facebook at $220' },
]
},
{
id: 'item010',
assetTag: 'TR-B1C2D3',
name: 'IKEA Kallax Shelf Unit 4x4',
category: 'Furniture',
condition: 'Good',
description: 'White. All 16 cubbies intact. Some minor edge chips.',
purchasePrice: 20.00,
listPrice: 95.00,
salePrice: null,
platform: 'Facebook Marketplace',
source: 'Craigslist',
status: 'In Stock',
datePurchased: '2024-02-20',
dateSold: null,
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-02-20', note: 'Purchased from Craigslist' },
]
},
{
id: 'item011',
assetTag: 'TR-E4F5G6',
name: 'Patagonia Down Jacket - Size M',
category: 'Clothing',
condition: 'Good',
description: 'Black. Small repair on left sleeve (not visible). Very warm.',
purchasePrice: 18.00,
listPrice: 120.00,
salePrice: 110.00,
platform: 'eBay',
source: 'Goodwill',
status: 'Sold',
datePurchased: '2024-02-25',
dateSold: '2024-03-05',
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-02-25', note: 'Purchased at Goodwill' },
{ status: 'Listed', date: '2024-02-26', note: 'Listed on eBay at $120' },
{ status: 'Sold', date: '2024-03-05', note: 'Sold for $110 on eBay' },
]
},
{
id: 'item012',
assetTag: 'TR-H7I8J9',
name: 'PlayStation 5 Console (Disc Edition)',
category: 'Electronics',
condition: 'Like New',
description: 'Barely used. One controller, all original cables, box included.',
purchasePrice: 320.00,
listPrice: 480.00,
salePrice: null,
platform: 'Both',
source: 'Facebook Marketplace',
status: 'Listed',
datePurchased: '2024-03-01',
dateSold: null,
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-03-01', note: 'Purchased from Facebook' },
{ status: 'Listed', date: '2024-03-02', note: 'Listed on eBay and Facebook at $480' },
]
},
{
id: 'item013',
assetTag: 'TR-K1L2M3',
name: 'Leather Sectional Sofa (3-piece)',
category: 'Furniture',
condition: 'Good',
description: 'Genuine brown leather. Minor wear on armrests. Seats are firm.',
purchasePrice: 75.00,
listPrice: 450.00,
salePrice: null,
platform: 'Facebook Marketplace',
source: 'Estate Sale',
status: 'In Stock',
datePurchased: '2024-03-05',
dateSold: null,
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-03-05', note: 'Purchased from estate sale' },
]
},
{
id: 'item014',
assetTag: 'TR-N4O5P6',
name: 'Vintage Levi\'s Denim Jacket - Size S',
category: 'Clothing',
condition: 'Good',
description: 'Orange tab. 1980s. Classic wash. Some fading adds character.',
purchasePrice: 12.00,
listPrice: 95.00,
salePrice: 88.00,
platform: 'eBay',
source: 'Thrift Store',
status: 'Sold',
datePurchased: '2024-03-08',
dateSold: '2024-03-12',
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-03-08', note: 'Purchased from thrift store' },
{ status: 'Listed', date: '2024-03-09', note: 'Listed on eBay at $95' },
{ status: 'Sold', date: '2024-03-12', note: 'Sold for $88 on eBay' },
]
},
{
id: 'item015',
assetTag: 'TR-Q7R8S9',
name: 'iPad Air 4th Gen 64GB WiFi',
category: 'Electronics',
condition: 'Good',
description: 'Space gray. Screen protector on. No cracked glass. Apple Pencil NOT included.',
purchasePrice: 160.00,
listPrice: 320.00,
salePrice: null,
platform: 'eBay',
source: 'Facebook Marketplace',
status: 'In Stock',
datePurchased: '2024-03-10',
dateSold: null,
photo: null,
statusHistory: [
{ status: 'In Stock', date: '2024-03-10', note: 'Purchased from Facebook' },
]
},
]
// ─── Context ──────────────────────────────────────────────────────────────────
const DataContext = createContext(null)
export function DataProvider({ children }) {
const [items, setItems] = useState(INITIAL_ITEMS)
const [settings, setSettings] = useState({
businessName: 'Texas Resellers',
defaultTemplate: '4x6',
currencySymbol: '$',
username: 'admin',
password: 'reseller123',
})
// ── CRUD ──────────────────────────────────────────────────────────────────
const addItem = useCallback((itemData) => {
const newItem = {
...itemData,
id: generateId(),
assetTag: generateAssetTag(),
photo: null,
statusHistory: [
{
status: itemData.status || 'In Stock',
date: itemData.datePurchased || new Date().toISOString().split('T')[0],
note: `Added to inventory. Source: ${itemData.source || 'Unknown'}`,
}
]
}
setItems(prev => [newItem, ...prev])
return newItem
}, [])
const updateItem = useCallback((id, updates) => {
setItems(prev => prev.map(item => {
if (item.id !== id) return item
const updated = { ...item, ...updates }
// If status changed, append to history
if (updates.status && updates.status !== item.status) {
updated.statusHistory = [
...(item.statusHistory || []),
{
status: updates.status,
date: new Date().toISOString().split('T')[0],
note: `Status changed to ${updates.status}`,
}
]
}
return updated
}))
}, [])
const deleteItem = useCallback((id) => {
setItems(prev => prev.filter(item => item.id !== id))
}, [])
const getItem = useCallback((id) => {
return items.find(item => item.id === id) || null
}, [items])
const getItemByTag = useCallback((tag) => {
const normalized = tag.trim().toUpperCase()
return items.find(item =>
item.assetTag.toUpperCase() === normalized ||
item.assetTag.toUpperCase().replace('TR-', '') === normalized
) || null
}, [items])
// ── Derived stats ─────────────────────────────────────────────────────────
const stats = {
totalItems: items.length,
totalValue: items.reduce((sum, item) => sum + (item.purchasePrice || 0), 0),
totalListValue: items.reduce((sum, item) => sum + (item.listPrice || 0), 0),
totalProfit: items
.filter(i => i.status === 'Sold')
.reduce((sum, item) => sum + ((item.salePrice || 0) - (item.purchasePrice || 0)), 0),
soldThisMonth: (() => {
const now = new Date()
const monthStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
return items.filter(i => i.status === 'Sold' && i.dateSold && i.dateSold.startsWith(monthStr)).length
})(),
byCategory: {
Clothing: items.filter(i => i.category === 'Clothing').length,
Electronics: items.filter(i => i.category === 'Electronics').length,
Furniture: items.filter(i => i.category === 'Furniture').length,
},
byStatus: {
'In Stock': items.filter(i => i.status === 'In Stock').length,
Listed: items.filter(i => i.status === 'Listed').length,
Sold: items.filter(i => i.status === 'Sold').length,
},
byPlatform: {
eBay: items.filter(i => i.status === 'Sold' && i.platform === 'eBay').length,
'Facebook Marketplace': items.filter(i => i.status === 'Sold' && i.platform === 'Facebook Marketplace').length,
Both: items.filter(i => i.status === 'Sold' && i.platform === 'Both').length,
Other: items.filter(i => i.status === 'Sold' && i.platform === 'Other').length,
},
}
const recentItems = [...items]
.sort((a, b) => new Date(b.datePurchased) - new Date(a.datePurchased))
.slice(0, 5)
return (
<DataContext.Provider value={{
items,
stats,
recentItems,
settings,
setSettings,
addItem,
updateItem,
deleteItem,
getItem,
getItemByTag,
}}>
{children}
</DataContext.Provider>
)
}
export function useData() {
const ctx = useContext(DataContext)
if (!ctx) throw new Error('useData must be used within DataProvider')
return ctx
}
+67
View File
@@ -0,0 +1,67 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
html {
font-family: 'Inter', system-ui, sans-serif;
}
body {
@apply bg-gray-900 text-gray-100;
}
* {
@apply border-gray-700;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
@apply bg-gray-800;
}
::-webkit-scrollbar-thumb {
@apply bg-gray-600 rounded-full;
}
::-webkit-scrollbar-thumb:hover {
@apply bg-gray-500;
}
}
@layer components {
.card {
@apply bg-gray-800 rounded-xl border border-gray-700 p-6;
}
.btn-primary {
@apply bg-amber-500 hover:bg-amber-400 text-gray-900 font-semibold px-4 py-2 rounded-lg transition-colors duration-150 flex items-center gap-2 text-sm;
}
.btn-secondary {
@apply bg-gray-700 hover:bg-gray-600 text-gray-100 font-medium px-4 py-2 rounded-lg transition-colors duration-150 flex items-center gap-2 text-sm;
}
.btn-danger {
@apply bg-red-600 hover:bg-red-500 text-white font-medium px-4 py-2 rounded-lg transition-colors duration-150 flex items-center gap-2 text-sm;
}
.input {
@apply bg-gray-700 border border-gray-600 rounded-lg px-3 py-2 text-gray-100 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent w-full text-sm;
}
.label {
@apply block text-sm font-medium text-gray-300 mb-1;
}
.badge-blue {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-900 text-blue-300 border border-blue-700;
}
.badge-yellow {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-900 text-yellow-300 border border-yellow-700;
}
.badge-green {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-900 text-green-300 border border-green-700;
}
.badge-gray {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-700 text-gray-300 border border-gray-600;
}
.badge-purple {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-900 text-purple-300 border border-purple-700;
}
.badge-orange {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-900 text-orange-300 border border-orange-700;
}
}
+10
View File
@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)
+209
View File
@@ -0,0 +1,209 @@
import React from 'react'
import { Link } from 'react-router-dom'
import {
Package, DollarSign, TrendingUp, ShoppingCart,
Shirt, Cpu, Armchair, ArrowRight, BarChart3
} from 'lucide-react'
import { useData } from '../context/DataContext'
import StatusBadge from '../components/StatusBadge'
import CategoryBadge from '../components/CategoryBadge'
function StatCard({ icon: Icon, label, value, sub, color = 'amber' }) {
const colorMap = {
amber: 'bg-amber-500/10 text-amber-400',
green: 'bg-green-500/10 text-green-400',
blue: 'bg-blue-500/10 text-blue-400',
purple: 'bg-purple-500/10 text-purple-400',
}
return (
<div className="card flex items-start gap-4">
<div className={`p-3 rounded-xl ${colorMap[color]}`}>
<Icon className="w-6 h-6" />
</div>
<div>
<p className="text-sm text-gray-400">{label}</p>
<p className="text-2xl font-bold text-gray-100 mt-0.5">{value}</p>
{sub && <p className="text-xs text-gray-500 mt-0.5">{sub}</p>}
</div>
</div>
)
}
function SimpleBar({ label, value, max, color }) {
const pct = max ? Math.round((value / max) * 100) : 0
return (
<div>
<div className="flex justify-between items-center mb-1">
<span className="text-sm text-gray-300">{label}</span>
<span className="text-sm font-semibold text-gray-100">{value}</span>
</div>
<div className="h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${color}`}
style={{ width: `${pct}%` }}
/>
</div>
</div>
)
}
export default function Dashboard() {
const { stats, recentItems, settings } = useData()
const sym = settings.currencySymbol
const fmt = (n) => `${sym}${n.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
const maxCat = Math.max(...Object.values(stats.byCategory))
const maxStat = Math.max(...Object.values(stats.byStatus))
const totalSold = Object.values(stats.byPlatform).reduce((a, b) => a + b, 0)
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-100">Dashboard</h1>
<p className="text-sm text-gray-400 mt-1">Welcome back here's what's happening.</p>
</div>
<Link to="/inventory/new" className="btn-primary">
<Package className="w-4 h-4" />
Add Item
</Link>
</div>
{/* Stat cards */}
<div className="grid grid-cols-2 xl:grid-cols-4 gap-4">
<StatCard
icon={Package}
label="Total Items"
value={stats.totalItems}
sub="in inventory"
color="blue"
/>
<StatCard
icon={DollarSign}
label="Total Inventory Value"
value={fmt(stats.totalListValue)}
sub="at list price"
color="amber"
/>
<StatCard
icon={TrendingUp}
label="Total Profit"
value={fmt(stats.totalProfit)}
sub="from sold items"
color="green"
/>
<StatCard
icon={ShoppingCart}
label="Sold This Month"
value={stats.soldThisMonth}
sub="items sold"
color="purple"
/>
</div>
{/* Main grid */}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
{/* Recent items */}
<div className="xl:col-span-2 card">
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-gray-100 flex items-center gap-2">
<Package className="w-4 h-4 text-amber-400" />
Recently Added
</h2>
<Link to="/inventory" className="text-xs text-amber-400 hover:text-amber-300 flex items-center gap-1">
View all <ArrowRight className="w-3.5 h-3.5" />
</Link>
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-gray-500 text-xs uppercase tracking-wider border-b border-gray-700">
<th className="pb-2 pr-4">Asset Tag</th>
<th className="pb-2 pr-4">Name</th>
<th className="pb-2 pr-4">Category</th>
<th className="pb-2 pr-4">List Price</th>
<th className="pb-2">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{recentItems.map(item => (
<tr key={item.id} className="hover:bg-gray-700/30 transition-colors">
<td className="py-2.5 pr-4">
<Link to={`/inventory/${item.id}`} className="font-mono text-xs text-amber-400 hover:text-amber-300">
{item.assetTag}
</Link>
</td>
<td className="py-2.5 pr-4">
<Link to={`/inventory/${item.id}`} className="text-gray-200 hover:text-white font-medium truncate max-w-[180px] block">
{item.name}
</Link>
</td>
<td className="py-2.5 pr-4"><CategoryBadge category={item.category} /></td>
<td className="py-2.5 pr-4 text-gray-300">{fmt(item.listPrice)}</td>
<td className="py-2.5"><StatusBadge status={item.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Right column */}
<div className="space-y-4">
{/* Category breakdown */}
<div className="card">
<h2 className="text-base font-semibold text-gray-100 flex items-center gap-2 mb-4">
<BarChart3 className="w-4 h-4 text-amber-400" />
By Category
</h2>
<div className="space-y-3">
<SimpleBar label={<span className="flex items-center gap-1.5"><Shirt className="w-3.5 h-3.5 text-purple-400"/>Clothing</span>} value={stats.byCategory.Clothing} max={maxCat} color="bg-purple-500" />
<SimpleBar label={<span className="flex items-center gap-1.5"><Cpu className="w-3.5 h-3.5 text-blue-400"/>Electronics</span>} value={stats.byCategory.Electronics} max={maxCat} color="bg-blue-500" />
<SimpleBar label={<span className="flex items-center gap-1.5"><Armchair className="w-3.5 h-3.5 text-orange-400"/>Furniture</span>} value={stats.byCategory.Furniture} max={maxCat} color="bg-orange-500" />
</div>
</div>
{/* Status breakdown */}
<div className="card">
<h2 className="text-base font-semibold text-gray-100 mb-4">By Status</h2>
<div className="space-y-3">
<SimpleBar label="In Stock" value={stats.byStatus['In Stock']} max={maxStat} color="bg-blue-500" />
<SimpleBar label="Listed" value={stats.byStatus.Listed} max={maxStat} color="bg-yellow-500" />
<SimpleBar label="Sold" value={stats.byStatus.Sold} max={maxStat} color="bg-green-500" />
</div>
</div>
{/* Platform sales */}
<div className="card">
<h2 className="text-base font-semibold text-gray-100 mb-4">Sales by Platform</h2>
{totalSold === 0 ? (
<p className="text-sm text-gray-500">No sold items yet.</p>
) : (
<div className="space-y-3">
{Object.entries(stats.byPlatform).filter(([, v]) => v > 0).map(([platform, count]) => (
<div key={platform} className="flex items-center justify-between">
<span className="text-sm text-gray-300">{platform}</span>
<div className="flex items-center gap-2">
<div className="w-24 h-2 bg-gray-700 rounded-full overflow-hidden">
<div
className="h-full rounded-full bg-amber-500"
style={{ width: `${Math.round((count / totalSold) * 100)}%` }}
/>
</div>
<span className="text-sm font-semibold text-gray-100 w-4 text-right">{count}</span>
</div>
</div>
))}
</div>
)}
</div>
</div>
</div>
</div>
)
}
+259
View File
@@ -0,0 +1,259 @@
import React, { useState, useMemo } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import {
Plus, Search, Filter, Eye, Pencil, Trash2,
Image, ChevronUp, ChevronDown, ChevronsUpDown
} from 'lucide-react'
import { useData } from '../context/DataContext'
import StatusBadge from '../components/StatusBadge'
import CategoryBadge from '../components/CategoryBadge'
const CATEGORIES = ['All', 'Clothing', 'Electronics', 'Furniture']
const STATUSES = ['All', 'In Stock', 'Listed', 'Sold']
const PLATFORMS = ['All', 'eBay', 'Facebook Marketplace', 'Both', 'Other']
function SortIcon({ field, sortField, sortDir }) {
if (sortField !== field) return <ChevronsUpDown className="w-3.5 h-3.5 text-gray-600" />
return sortDir === 'asc'
? <ChevronUp className="w-3.5 h-3.5 text-amber-400" />
: <ChevronDown className="w-3.5 h-3.5 text-amber-400" />
}
export default function Inventory() {
const { items, deleteItem, settings } = useData()
const navigate = useNavigate()
const sym = settings.currencySymbol
const [search, setSearch] = useState('')
const [category, setCategory] = useState('All')
const [status, setStatus] = useState('All')
const [platform, setPlatform] = useState('All')
const [sortField, setSortField] = useState('datePurchased')
const [sortDir, setSortDir] = useState('desc')
const [deleteConfirm, setDeleteConfirm] = useState(null)
const fmt = (n) => n != null
? `${sym}${Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: '—'
const profit = (item) =>
item.status === 'Sold' && item.salePrice != null
? item.salePrice - item.purchasePrice
: null
function toggleSort(field) {
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc')
else { setSortField(field); setSortDir('asc') }
}
const filtered = useMemo(() => {
let list = [...items]
if (search) {
const q = search.toLowerCase()
list = list.filter(i =>
i.name.toLowerCase().includes(q) ||
i.assetTag.toLowerCase().includes(q) ||
(i.description || '').toLowerCase().includes(q)
)
}
if (category !== 'All') list = list.filter(i => i.category === category)
if (status !== 'All') list = list.filter(i => i.status === status)
if (platform !== 'All') list = list.filter(i => i.platform === platform)
list.sort((a, b) => {
let va = a[sortField], vb = b[sortField]
if (va == null) va = sortDir === 'asc' ? Infinity : -Infinity
if (vb == null) vb = sortDir === 'asc' ? Infinity : -Infinity
if (typeof va === 'string') va = va.toLowerCase()
if (typeof vb === 'string') vb = vb.toLowerCase()
if (va < vb) return sortDir === 'asc' ? -1 : 1
if (va > vb) return sortDir === 'asc' ? 1 : -1
return 0
})
return list
}, [items, search, category, status, platform, sortField, sortDir])
function confirmDelete(id) {
deleteItem(id)
setDeleteConfirm(null)
}
const Th = ({ field, label, className = '' }) => (
<th
className={`pb-3 pr-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider cursor-pointer hover:text-gray-300 select-none ${className}`}
onClick={() => field && toggleSort(field)}
>
<span className="inline-flex items-center gap-1">
{label}
{field && <SortIcon field={field} sortField={sortField} sortDir={sortDir} />}
</span>
</th>
)
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-100">Inventory</h1>
<p className="text-sm text-gray-400 mt-1">{filtered.length} of {items.length} items</p>
</div>
<Link to="/inventory/new" className="btn-primary">
<Plus className="w-4 h-4" />
Add Item
</Link>
</div>
{/* Filters */}
<div className="card p-4">
<div className="flex flex-wrap gap-3">
{/* Search */}
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
className="input pl-9"
placeholder="Search name, asset tag, description…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
</div>
{/* Category */}
<div className="flex items-center gap-2">
<Filter className="w-4 h-4 text-gray-400 shrink-0" />
<select className="input w-auto" value={category} onChange={e => setCategory(e.target.value)}>
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
</select>
</div>
{/* Status */}
<select className="input w-auto" value={status} onChange={e => setStatus(e.target.value)}>
{STATUSES.map(s => <option key={s}>{s}</option>)}
</select>
{/* Platform */}
<select className="input w-auto" value={platform} onChange={e => setPlatform(e.target.value)}>
{PLATFORMS.map(p => <option key={p}>{p}</option>)}
</select>
{/* Clear */}
{(search || category !== 'All' || status !== 'All' || platform !== 'All') && (
<button
className="btn-secondary"
onClick={() => { setSearch(''); setCategory('All'); setStatus('All'); setPlatform('All') }}
>
Clear
</button>
)}
</div>
</div>
{/* Table */}
<div className="card p-0 overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="border-b border-gray-700 bg-gray-800/80">
<tr className="px-6">
<th className="pl-6 pb-3 pt-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-8">#</th>
<Th field="assetTag" label="Asset Tag" className="pl-3" />
<th className="pb-3 pr-4 pt-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Photo</th>
<Th field="name" label="Name" />
<Th field="category" label="Category" />
<Th field="condition" label="Condition" />
<Th field="purchasePrice" label="Cost" />
<Th field="listPrice" label="List" />
<Th field="salePrice" label="Sale" />
<th className="pb-3 pr-4 pt-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Profit</th>
<Th field="platform" label="Platform" />
<Th field="status" label="Status" />
<th className="pb-3 pr-6 pt-4 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-700/50">
{filtered.length === 0 && (
<tr>
<td colSpan={13} className="py-12 text-center text-gray-500">
No items match your filters.
</td>
</tr>
)}
{filtered.map((item, idx) => {
const p = profit(item)
return (
<tr key={item.id} className="hover:bg-gray-700/20 transition-colors group">
<td className="pl-6 py-3 text-gray-600 text-xs">{idx + 1}</td>
<td className="py-3 pr-4 pl-3">
<Link to={`/inventory/${item.id}`} className="font-mono text-xs text-amber-400 hover:text-amber-300">
{item.assetTag}
</Link>
</td>
<td className="py-3 pr-4">
<div className="w-10 h-10 rounded-lg bg-gray-700 border border-gray-600 flex items-center justify-center">
<Image className="w-4 h-4 text-gray-500" />
</div>
</td>
<td className="py-3 pr-4">
<Link to={`/inventory/${item.id}`} className="text-gray-100 hover:text-white font-medium max-w-[180px] block truncate">
{item.name}
</Link>
{item.source && <p className="text-xs text-gray-500 truncate max-w-[180px]">from {item.source}</p>}
</td>
<td className="py-3 pr-4"><CategoryBadge category={item.category} /></td>
<td className="py-3 pr-4 text-gray-400 text-xs">{item.condition}</td>
<td className="py-3 pr-4 text-gray-300 tabular-nums">{fmt(item.purchasePrice)}</td>
<td className="py-3 pr-4 text-gray-300 tabular-nums">{fmt(item.listPrice)}</td>
<td className="py-3 pr-4 text-gray-300 tabular-nums">{fmt(item.salePrice)}</td>
<td className="py-3 pr-4 tabular-nums">
{p != null ? (
<span className={p >= 0 ? 'text-green-400 font-semibold' : 'text-red-400 font-semibold'}>
{p >= 0 ? '+' : ''}{fmt(p)}
</span>
) : (
<span className="text-gray-600"></span>
)}
</td>
<td className="py-3 pr-4">
<span className="badge-gray text-xs">{item.platform}</span>
</td>
<td className="py-3 pr-4"><StatusBadge status={item.status} /></td>
<td className="py-3 pr-6">
<div className="flex items-center gap-1 justify-end opacity-0 group-hover:opacity-100 transition-opacity">
<Link to={`/inventory/${item.id}`} className="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-100" title="View">
<Eye className="w-4 h-4" />
</Link>
<Link to={`/inventory/${item.id}/edit`} className="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-100" title="Edit">
<Pencil className="w-4 h-4" />
</Link>
<button
onClick={() => setDeleteConfirm(item.id)}
className="p-1.5 rounded hover:bg-red-900/40 text-gray-400 hover:text-red-400"
title="Delete"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
{/* Delete confirm modal */}
{deleteConfirm && (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50">
<div className="bg-gray-800 border border-gray-700 rounded-xl p-6 max-w-sm w-full mx-4 shadow-2xl">
<h3 className="text-lg font-semibold text-gray-100 mb-2">Delete Item?</h3>
<p className="text-sm text-gray-400 mb-5">This action cannot be undone.</p>
<div className="flex gap-3">
<button onClick={() => confirmDelete(deleteConfirm)} className="btn-danger flex-1 justify-center">
<Trash2 className="w-4 h-4" /> Delete
</button>
<button onClick={() => setDeleteConfirm(null)} className="btn-secondary flex-1 justify-center">
Cancel
</button>
</div>
</div>
</div>
)}
</div>
)
}
+307
View File
@@ -0,0 +1,307 @@
import React, { useRef } from 'react'
import { useParams, Link, useNavigate } from 'react-router-dom'
import { QRCodeSVG } from 'qrcode.react'
import {
ArrowLeft, Pencil, Printer, Trash2, Tag,
TrendingUp, Calendar, MapPin, ShoppingBag,
CheckCircle2, Clock, Package
} from 'lucide-react'
import { useData } from '../context/DataContext'
import StatusBadge from '../components/StatusBadge'
import CategoryBadge from '../components/CategoryBadge'
// Simple SVG barcode renderer (Code 39 style visual, not a real scanner-readable barcode)
function BarcodeDisplay({ value }) {
const bars = []
let x = 0
const H = 50
// Encode each char as a pseudo-random bar pattern based on char code
for (let i = 0; i < value.length; i++) {
const code = value.charCodeAt(i)
// 5 bars per character
for (let b = 0; b < 5; b++) {
const bit = (code >> b) & 1
const width = bit ? 3 : 1.5
bars.push({ x, width, black: (b + i) % 3 !== 1 })
x += width + 1
}
x += 3 // inter-char gap
}
const totalWidth = x
return (
<div className="bg-white rounded-lg p-3 inline-block">
<svg width={Math.min(totalWidth, 220)} height={H + 20} viewBox={`0 0 ${totalWidth} ${H + 20}`}>
{bars.map((bar, i) => (
<rect
key={i}
x={bar.x}
y={0}
width={bar.width}
height={H}
fill={bar.black ? '#111' : '#fff'}
/>
))}
<text x={totalWidth / 2} y={H + 14} textAnchor="middle" fontSize="9" fill="#333" fontFamily="monospace">
{value}
</text>
</svg>
</div>
)
}
function InfoRow({ label, value, mono = false }) {
if (value == null || value === '') return null
return (
<div className="flex flex-col gap-0.5">
<dt className="text-xs text-gray-500 uppercase tracking-wider">{label}</dt>
<dd className={`text-sm text-gray-200 ${mono ? 'font-mono' : ''}`}>{value}</dd>
</div>
)
}
const STATUS_ICONS = {
'In Stock': <Package className="w-4 h-4 text-blue-400" />,
'Listed': <Clock className="w-4 h-4 text-yellow-400" />,
'Sold': <CheckCircle2 className="w-4 h-4 text-green-400" />,
}
export default function ItemDetail() {
const { id } = useParams()
const { getItem, deleteItem, settings } = useData()
const navigate = useNavigate()
const printRef = useRef(null)
const sym = settings.currencySymbol
const item = getItem(id)
if (!item) {
return (
<div className="p-6 text-center py-24">
<Package className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<h2 className="text-xl font-semibold text-gray-300">Item Not Found</h2>
<p className="text-gray-500 mt-2 mb-6">This item may have been deleted.</p>
<Link to="/inventory" className="btn-primary inline-flex">Back to Inventory</Link>
</div>
)
}
const fmt = (n) => n != null
? `${sym}${Number(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
: '—'
const profit = item.status === 'Sold' && item.salePrice != null
? item.salePrice - item.purchasePrice
: item.listPrice != null
? item.listPrice - item.purchasePrice
: null
const profitLabel = item.status === 'Sold' ? 'Actual Profit' : 'Potential Profit'
function handleDelete() {
if (window.confirm(`Delete ${item.assetTag} - ${item.name}?`)) {
deleteItem(id)
navigate('/inventory')
}
}
function handlePrint() {
window.print()
}
return (
<div className="p-6 max-w-5xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-4">
<Link to="/inventory" className="p-2 rounded-lg hover:bg-gray-700 text-gray-400 hover:text-gray-100 transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<div className="flex items-center gap-3 flex-wrap">
<h1 className="text-xl font-bold text-gray-100 leading-tight">{item.name}</h1>
<StatusBadge status={item.status} />
<CategoryBadge category={item.category} />
</div>
<p className="text-sm font-mono text-amber-400 mt-1">{item.assetTag}</p>
</div>
</div>
<div className="flex items-center gap-2">
<button onClick={handlePrint} className="btn-secondary">
<Printer className="w-4 h-4" />
Print Label
</button>
<Link to={`/inventory/${id}/edit`} className="btn-primary">
<Pencil className="w-4 h-4" />
Edit
</Link>
<button onClick={handleDelete} className="btn-danger">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left: Main info */}
<div className="lg:col-span-2 space-y-5">
{/* Details */}
<div className="card">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4">Item Details</h2>
<dl className="grid grid-cols-2 sm:grid-cols-3 gap-x-6 gap-y-4">
<InfoRow label="Condition" value={item.condition} />
<InfoRow label="Platform" value={item.platform} />
<InfoRow label="Source" value={item.source} />
<InfoRow label="Purchased" value={item.datePurchased} />
{item.dateSold && <InfoRow label="Sold On" value={item.dateSold} />}
</dl>
{item.description && (
<div className="mt-4 pt-4 border-t border-gray-700">
<p className="text-xs text-gray-500 uppercase tracking-wider mb-1">Description</p>
<p className="text-sm text-gray-300 leading-relaxed">{item.description}</p>
</div>
)}
</div>
{/* Pricing */}
<div className="card">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4 flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-amber-400" />
Pricing & Profit
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div className="bg-gray-700/50 rounded-lg p-3">
<p className="text-xs text-gray-500 mb-1">Purchase Cost</p>
<p className="text-lg font-bold text-gray-100">{fmt(item.purchasePrice)}</p>
</div>
<div className="bg-gray-700/50 rounded-lg p-3">
<p className="text-xs text-gray-500 mb-1">List Price</p>
<p className="text-lg font-bold text-gray-100">{fmt(item.listPrice)}</p>
</div>
{item.status === 'Sold' && (
<div className="bg-gray-700/50 rounded-lg p-3">
<p className="text-xs text-gray-500 mb-1">Sale Price</p>
<p className="text-lg font-bold text-gray-100">{fmt(item.salePrice)}</p>
</div>
)}
{profit != null && (
<div className={`rounded-lg p-3 ${profit >= 0 ? 'bg-green-900/30 border border-green-800/50' : 'bg-red-900/30 border border-red-800/50'}`}>
<p className="text-xs text-gray-500 mb-1">{profitLabel}</p>
<p className={`text-lg font-bold ${profit >= 0 ? 'text-green-400' : 'text-red-400'}`}>
{profit >= 0 ? '+' : ''}{fmt(profit)}
</p>
{item.purchasePrice && profit != null && (
<p className="text-xs text-gray-500 mt-0.5">
{((profit / item.purchasePrice) * 100).toFixed(0)}% ROI
</p>
)}
</div>
)}
</div>
</div>
{/* Status history */}
<div className="card">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4 flex items-center gap-2">
<Clock className="w-4 h-4 text-amber-400" />
Status History
</h2>
<div className="relative">
<div className="absolute left-4 top-0 bottom-0 w-px bg-gray-700" />
<div className="space-y-4">
{(item.statusHistory || []).map((entry, i) => (
<div key={i} className="flex gap-4 pl-2">
<div className="w-6 h-6 rounded-full bg-gray-800 border-2 border-gray-600 flex items-center justify-center shrink-0 relative z-10">
{STATUS_ICONS[entry.status] || <div className="w-2 h-2 rounded-full bg-gray-500" />}
</div>
<div className="pb-1 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<StatusBadge status={entry.status} />
<span className="text-xs text-gray-500">{entry.date}</span>
</div>
{entry.note && <p className="text-xs text-gray-400 mt-0.5">{entry.note}</p>}
</div>
</div>
))}
</div>
</div>
</div>
</div>
{/* Right: QR + Barcode */}
<div className="space-y-5">
{/* Label preview (printable) */}
<div className="card" id="print-area">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-4 flex items-center gap-2">
<Tag className="w-4 h-4 text-amber-400" />
Label / QR Code
</h2>
<div className="bg-white rounded-xl p-4 text-gray-900 space-y-3" ref={printRef}>
{/* Business name */}
<p className="text-center text-xs font-bold text-gray-600 tracking-widest uppercase">
{settings.businessName}
</p>
{/* Item name */}
<p className="text-center text-xs font-semibold leading-tight text-gray-800 line-clamp-2">
{item.name}
</p>
{/* Asset tag */}
<p className="text-center font-mono text-sm font-bold text-gray-900 tracking-wider">
{item.assetTag}
</p>
{/* QR Code */}
<div className="flex justify-center">
<QRCodeSVG
value={`${settings.businessName}|${item.assetTag}|${item.name}`}
size={120}
bgColor="#ffffff"
fgColor="#111111"
level="M"
/>
</div>
{/* Barcode */}
<div className="flex justify-center">
<BarcodeDisplay value={item.assetTag} />
</div>
{/* Prices */}
<div className="flex justify-between text-xs text-gray-600 border-t border-gray-200 pt-2">
<span>Cost: {fmt(item.purchasePrice)}</span>
<span className="font-bold text-gray-900">List: {fmt(item.listPrice)}</span>
</div>
</div>
<button onClick={handlePrint} className="btn-secondary w-full justify-center mt-4">
<Printer className="w-4 h-4" />
Print Label
</button>
</div>
{/* Quick info */}
<div className="card space-y-3">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">Quick Info</h2>
<div className="space-y-2.5 text-sm">
<div className="flex items-center gap-2 text-gray-400">
<Tag className="w-4 h-4 shrink-0" />
<span className="font-mono text-amber-400">{item.assetTag}</span>
</div>
<div className="flex items-center gap-2 text-gray-400">
<ShoppingBag className="w-4 h-4 shrink-0" />
<span className="text-gray-300">{item.platform}</span>
</div>
<div className="flex items-center gap-2 text-gray-400">
<Calendar className="w-4 h-4 shrink-0" />
<span className="text-gray-300">Purchased {item.datePurchased}</span>
</div>
{item.source && (
<div className="flex items-center gap-2 text-gray-400">
<MapPin className="w-4 h-4 shrink-0" />
<span className="text-gray-300">{item.source}</span>
</div>
)}
</div>
</div>
</div>
</div>
</div>
)
}
+315
View File
@@ -0,0 +1,315 @@
import React, { useState, useEffect } from 'react'
import { useNavigate, useParams, Link } from 'react-router-dom'
import { Save, ArrowLeft, Image, RefreshCw } from 'lucide-react'
import { useData } from '../context/DataContext'
const CATEGORIES = ['Clothing', 'Electronics', 'Furniture']
const CONDITIONS = ['New', 'Like New', 'Good', 'Fair', 'Poor']
const PLATFORMS = ['eBay', 'Facebook Marketplace', 'Both', 'Other']
const STATUSES = ['In Stock', 'Listed', 'Sold']
const EMPTY = {
name: '',
category: 'Electronics',
condition: 'Good',
description: '',
purchasePrice: '',
listPrice: '',
salePrice: '',
platform: 'eBay',
source: '',
status: 'In Stock',
datePurchased: new Date().toISOString().split('T')[0],
dateSold: '',
}
export default function ItemForm() {
const { id } = useParams()
const isEdit = Boolean(id)
const { addItem, updateItem, getItem } = useData()
const navigate = useNavigate()
const [form, setForm] = useState(EMPTY)
const [errors, setErrors] = useState({})
const [saving, setSaving] = useState(false)
const [previewTag, setPreviewTag] = useState('TR-XXXXXX')
useEffect(() => {
if (isEdit) {
const item = getItem(id)
if (item) {
setForm({
name: item.name || '',
category: item.category || 'Electronics',
condition: item.condition || 'Good',
description: item.description || '',
purchasePrice: item.purchasePrice != null ? String(item.purchasePrice) : '',
listPrice: item.listPrice != null ? String(item.listPrice) : '',
salePrice: item.salePrice != null ? String(item.salePrice) : '',
platform: item.platform || 'eBay',
source: item.source || '',
status: item.status || 'In Stock',
datePurchased: item.datePurchased || '',
dateSold: item.dateSold || '',
})
setPreviewTag(item.assetTag)
}
} else {
generateTag()
}
}, [id])
function generateTag() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
let tag = 'TR-'
for (let i = 0; i < 6; i++) tag += chars.charAt(Math.floor(Math.random() * chars.length))
setPreviewTag(tag)
}
function set(field, val) {
setForm(f => ({ ...f, [field]: val }))
if (errors[field]) setErrors(e => ({ ...e, [field]: null }))
}
function validate() {
const errs = {}
if (!form.name.trim()) errs.name = 'Name is required'
if (!form.purchasePrice) errs.purchasePrice = 'Purchase price is required'
if (isNaN(Number(form.purchasePrice))) errs.purchasePrice = 'Must be a number'
if (form.listPrice && isNaN(Number(form.listPrice))) errs.listPrice = 'Must be a number'
if (form.status === 'Sold' && !form.salePrice) errs.salePrice = 'Sale price required for sold items'
if (form.status === 'Sold' && form.salePrice && isNaN(Number(form.salePrice))) errs.salePrice = 'Must be a number'
if (form.status === 'Sold' && !form.dateSold) errs.dateSold = 'Date sold required'
return errs
}
function handleSubmit(e) {
e.preventDefault()
const errs = validate()
if (Object.keys(errs).length) { setErrors(errs); return }
setSaving(true)
const payload = {
...form,
purchasePrice: form.purchasePrice ? Number(form.purchasePrice) : null,
listPrice: form.listPrice ? Number(form.listPrice) : null,
salePrice: form.salePrice ? Number(form.salePrice) : null,
dateSold: form.status === 'Sold' ? form.dateSold : null,
}
setTimeout(() => {
if (isEdit) {
updateItem(id, payload)
navigate(`/inventory/${id}`)
} else {
const newItem = addItem(payload)
navigate(`/inventory/${newItem.id}`)
}
setSaving(false)
}, 300)
}
const Field = ({ label, error, required, children }) => (
<div>
<label className="label">
{label}
{required && <span className="text-red-400 ml-0.5">*</span>}
</label>
{children}
{error && <p className="text-xs text-red-400 mt-1">{error}</p>}
</div>
)
return (
<div className="p-6 max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center gap-4">
<Link to="/inventory" className="p-2 rounded-lg hover:bg-gray-700 text-gray-400 hover:text-gray-100 transition-colors">
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-100">{isEdit ? 'Edit Item' : 'Add New Item'}</h1>
<p className="text-sm text-gray-400 mt-0.5">{isEdit ? `Editing ${previewTag}` : 'Fill in the item details below'}</p>
</div>
</div>
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
{/* Main details */}
<div className="lg:col-span-2 space-y-5">
{/* Asset tag */}
<div className="card">
<h2 className="text-sm font-semibold text-gray-300 mb-4 uppercase tracking-wider">Asset Tag</h2>
<div className="flex items-center gap-3">
<div className="bg-gray-900 border border-gray-600 rounded-lg px-4 py-3 font-mono text-xl font-bold text-amber-400 tracking-widest flex-1">
{previewTag}
</div>
{!isEdit && (
<button type="button" onClick={generateTag} className="btn-secondary" title="Regenerate tag">
<RefreshCw className="w-4 h-4" />
Regenerate
</button>
)}
</div>
<p className="text-xs text-gray-500 mt-2">Auto-generated unique identifier for this item.</p>
</div>
{/* Item info */}
<div className="card space-y-4">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">Item Information</h2>
<Field label="Item Name" error={errors.name} required>
<input className="input" placeholder="e.g. Apple iPhone 13 Pro 128GB" value={form.name} onChange={e => set('name', e.target.value)} />
</Field>
<div className="grid grid-cols-2 gap-4">
<Field label="Category">
<select className="input" value={form.category} onChange={e => set('category', e.target.value)}>
{CATEGORIES.map(c => <option key={c}>{c}</option>)}
</select>
</Field>
<Field label="Condition">
<select className="input" value={form.condition} onChange={e => set('condition', e.target.value)}>
{CONDITIONS.map(c => <option key={c}>{c}</option>)}
</select>
</Field>
</div>
<Field label="Description / Notes">
<textarea
className="input resize-none"
rows={3}
placeholder="Describe the item, note any defects, accessories included, etc."
value={form.description}
onChange={e => set('description', e.target.value)}
/>
</Field>
</div>
{/* Pricing */}
<div className="card space-y-4">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">Pricing</h2>
<div className="grid grid-cols-2 gap-4">
<Field label="Purchase Price" error={errors.purchasePrice} required>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
<input className="input pl-6" type="number" step="0.01" min="0" placeholder="0.00" value={form.purchasePrice} onChange={e => set('purchasePrice', e.target.value)} />
</div>
</Field>
<Field label="Listing Price" error={errors.listPrice}>
<div className="relative">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
<input className="input pl-6" type="number" step="0.01" min="0" placeholder="0.00" value={form.listPrice} onChange={e => set('listPrice', e.target.value)} />
</div>
</Field>
</div>
{form.status === 'Sold' && (
<Field label="Sale Price (Actual)" error={errors.salePrice} required>
<div className="relative max-w-xs">
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 text-sm">$</span>
<input className="input pl-6" type="number" step="0.01" min="0" placeholder="0.00" value={form.salePrice} onChange={e => set('salePrice', e.target.value)} />
</div>
{form.salePrice && form.purchasePrice && (
<p className="text-xs mt-1">
Profit: {' '}
<span className={Number(form.salePrice) - Number(form.purchasePrice) >= 0 ? 'text-green-400 font-semibold' : 'text-red-400 font-semibold'}>
${(Number(form.salePrice) - Number(form.purchasePrice)).toFixed(2)}
</span>
</p>
)}
</Field>
)}
</div>
{/* Sale details */}
<div className="card space-y-4">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider">Listing Details</h2>
<div className="grid grid-cols-2 gap-4">
<Field label="Platform">
<select className="input" value={form.platform} onChange={e => set('platform', e.target.value)}>
{PLATFORMS.map(p => <option key={p}>{p}</option>)}
</select>
</Field>
<Field label="Source / Where Purchased">
<input className="input" placeholder="e.g. Goodwill, Garage Sale…" value={form.source} onChange={e => set('source', e.target.value)} />
</Field>
</div>
<div className="grid grid-cols-2 gap-4">
<Field label="Status">
<select className="input" value={form.status} onChange={e => set('status', e.target.value)}>
{STATUSES.map(s => <option key={s}>{s}</option>)}
</select>
</Field>
<Field label="Date Purchased">
<input className="input" type="date" value={form.datePurchased} onChange={e => set('datePurchased', e.target.value)} />
</Field>
</div>
{form.status === 'Sold' && (
<Field label="Date Sold" error={errors.dateSold} required>
<input className="input max-w-xs" type="date" value={form.dateSold} onChange={e => set('dateSold', e.target.value)} />
</Field>
)}
</div>
</div>
{/* Right column */}
<div className="space-y-5">
{/* Photo upload placeholder */}
<div className="card">
<h2 className="text-sm font-semibold text-gray-300 mb-4 uppercase tracking-wider">Photo</h2>
<div className="border-2 border-dashed border-gray-600 rounded-xl p-8 text-center hover:border-amber-500/50 transition-colors cursor-pointer group">
<div className="flex justify-center mb-3">
<div className="w-12 h-12 bg-gray-700 rounded-xl flex items-center justify-center group-hover:bg-gray-600 transition-colors">
<Image className="w-6 h-6 text-gray-400" />
</div>
</div>
<p className="text-sm text-gray-400">Click to upload photo</p>
<p className="text-xs text-gray-600 mt-1">PNG, JPG up to 10MB</p>
<p className="text-xs text-amber-600 mt-3">(Photo upload coming soon)</p>
</div>
</div>
{/* Quick summary */}
{(form.purchasePrice || form.listPrice) && (
<div className="card">
<h2 className="text-sm font-semibold text-gray-300 mb-3 uppercase tracking-wider">Summary</h2>
<div className="space-y-2 text-sm">
{form.purchasePrice && (
<div className="flex justify-between">
<span className="text-gray-400">Cost</span>
<span className="text-gray-200 font-medium">${Number(form.purchasePrice).toFixed(2)}</span>
</div>
)}
{form.listPrice && (
<div className="flex justify-between">
<span className="text-gray-400">List Price</span>
<span className="text-gray-200 font-medium">${Number(form.listPrice).toFixed(2)}</span>
</div>
)}
{form.purchasePrice && form.listPrice && (
<div className="flex justify-between pt-2 border-t border-gray-700">
<span className="text-gray-400">Potential Profit</span>
<span className={`font-bold ${Number(form.listPrice) - Number(form.purchasePrice) >= 0 ? 'text-green-400' : 'text-red-400'}`}>
${(Number(form.listPrice) - Number(form.purchasePrice)).toFixed(2)}
</span>
</div>
)}
</div>
</div>
)}
</div>
</div>
{/* Actions */}
<div className="flex gap-3 pt-2">
<button type="submit" className="btn-primary" disabled={saving}>
<Save className="w-4 h-4" />
{saving ? 'Saving…' : isEdit ? 'Save Changes' : 'Add Item'}
</button>
<Link to="/inventory" className="btn-secondary">
Cancel
</Link>
</div>
</form>
</div>
)
}
+514
View File
@@ -0,0 +1,514 @@
import React, { useState, useRef, useCallback, useEffect } from 'react'
import { QRCodeSVG } from 'qrcode.react'
import {
Printer, Save, Plus, Trash2, Bold, Eye,
AlignLeft, AlignCenter, AlignRight, GripVertical,
ChevronDown, ChevronUp, RotateCcw
} from 'lucide-react'
import { useData } from '../context/DataContext'
// ─── Label size definitions (in px at 96dpi, approximated) ──────────────────
const LABEL_SIZES = {
'4x6': { label: '4" × 6" (Shipping)', w: 384, h: 576 },
'2x4': { label: '2" × 4" (Standard)', w: 192, h: 384 },
'2x2': { label: '2" × 2" (Small)', w: 192, h: 192 },
}
// ─── Available field types ────────────────────────────────────────────────────
const FIELD_TYPES = [
{ type: 'assetTag', label: 'Asset Tag', defaultText: 'TR-XXXXXX' },
{ type: 'itemName', label: 'Item Name', defaultText: 'Item Name Here' },
{ type: 'category', label: 'Category', defaultText: 'Electronics' },
{ type: 'purchasePrice', label: 'Purchase Price', defaultText: '$0.00' },
{ type: 'listPrice', label: 'List Price', defaultText: '$0.00' },
{ type: 'qrCode', label: 'QR Code', defaultText: '' },
{ type: 'businessName', label: 'Business Name', defaultText: 'Texas Resellers' },
{ type: 'customText', label: 'Custom Text', defaultText: 'Custom Text' },
]
function newField(type, x = 20, y = 20) {
const def = FIELD_TYPES.find(f => f.type === type)
return {
id: `${type}_${Date.now()}`,
type,
label: def?.label || type,
text: def?.defaultText || '',
x,
y,
fontSize: 14,
bold: false,
align: 'left',
border: false,
width: 140,
height: type === 'qrCode' ? 80 : 30,
}
}
// ─── A single draggable field on the canvas ──────────────────────────────────
function CanvasField({ field, selected, onSelect, onMove, scale, previewData, settings }) {
const dragStart = useRef(null)
function getValue() {
if (!previewData) return field.type === 'businessName' ? settings.businessName : field.text
switch (field.type) {
case 'assetTag': return previewData.assetTag
case 'itemName': return previewData.name
case 'category': return previewData.category
case 'purchasePrice': return `Cost: $${previewData.purchasePrice?.toFixed(2) ?? '0.00'}`
case 'listPrice': return `List: $${previewData.listPrice?.toFixed(2) ?? '0.00'}`
case 'businessName': return settings.businessName
case 'customText': return field.text
default: return field.text
}
}
function onMouseDown(e) {
if (e.button !== 0) return
e.stopPropagation()
onSelect(field.id)
dragStart.current = {
startX: field.x,
startY: field.y,
mouseX: e.clientX / scale,
mouseY: e.clientY / scale,
}
function handleMouseMove(ev) {
const dx = ev.clientX / scale - dragStart.current.mouseX
const dy = ev.clientY / scale - dragStart.current.mouseY
onMove(field.id, dragStart.current.startX + dx, dragStart.current.startY + dy)
}
function handleMouseUp() {
window.removeEventListener('mousemove', handleMouseMove)
window.removeEventListener('mouseup', handleMouseUp)
}
window.addEventListener('mousemove', handleMouseMove)
window.addEventListener('mouseup', handleMouseUp)
}
const style = {
position: 'absolute',
left: field.x,
top: field.y,
width: field.width,
height: field.type === 'qrCode' ? field.height : 'auto',
cursor: 'grab',
userSelect: 'none',
border: selected ? '1.5px solid #f59e0b' : field.border ? '1px solid #333' : '1px solid transparent',
borderRadius: 3,
padding: 2,
boxSizing: 'border-box',
}
if (field.type === 'qrCode') {
const qrVal = previewData
? `${settings.businessName}|${previewData.assetTag}|${previewData.name}`
: `${settings.businessName}|TR-XXXXXX|Sample Item`
return (
<div style={style} onMouseDown={onMouseDown}>
<QRCodeSVG value={qrVal} size={field.height - 4} bgColor="#fff" fgColor="#111" level="M" />
</div>
)
}
return (
<div
style={{
...style,
fontSize: field.fontSize,
fontWeight: field.bold ? 700 : 400,
textAlign: field.align,
lineHeight: '1.3',
color: '#111',
fontFamily: 'sans-serif',
overflow: 'hidden',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
}}
onMouseDown={onMouseDown}
>
{getValue()}
</div>
)
}
// ─── Main component ───────────────────────────────────────────────────────────
export default function LabelDesigner() {
const { items, settings, setSettings } = useData()
const [size, setSize] = useState('4x6')
const [fields, setFields] = useState([
{ ...newField('businessName', 20, 15), fontSize: 13, bold: true, align: 'center', width: 340 },
{ ...newField('itemName', 20, 42), fontSize: 15, bold: true, align: 'center', width: 340 },
{ ...newField('assetTag', 20, 75), fontSize: 13, align: 'center', width: 340 },
{ ...newField('qrCode', 130, 110), height: 100, width: 100 },
{ ...newField('listPrice', 20, 230), fontSize: 16, bold: true },
{ ...newField('purchasePrice', 200, 230), fontSize: 12 },
])
const [selectedId, setSelectedId] = useState(null)
const [previewItem, setPreviewItem] = useState(items[0] || null)
const [showPreview, setShowPreview] = useState(true)
const [savedMsg, setSavedMsg] = useState(false)
const canvasRef = useRef(null)
const dim = LABEL_SIZES[size]
// Scale canvas to fit container (max ~500px wide)
const DISPLAY_W = 420
const scale = DISPLAY_W / dim.w
const displayH = dim.h * scale
const selectedField = fields.find(f => f.id === selectedId) || null
function addField(type) {
setFields(prev => {
const f = newField(type, 20, 20)
setSelectedId(f.id)
return [...prev, f]
})
}
function moveField(id, x, y) {
setFields(prev => prev.map(f =>
f.id === id
? { ...f, x: Math.max(0, Math.min(dim.w - f.width, x)), y: Math.max(0, Math.min(dim.h - 20, y)) }
: f
))
}
function updateField(id, updates) {
setFields(prev => prev.map(f => f.id === id ? { ...f, ...updates } : f))
}
function deleteField(id) {
setFields(prev => prev.filter(f => f.id !== id))
if (selectedId === id) setSelectedId(null)
}
function resetTemplate() {
setFields([
{ ...newField('businessName', 20, 15), fontSize: 13, bold: true, align: 'center', width: dim.w - 40 },
{ ...newField('itemName', 20, 42), fontSize: 15, bold: true, align: 'center', width: dim.w - 40 },
{ ...newField('assetTag', 20, 75), fontSize: 13, align: 'center', width: dim.w - 40 },
{ ...newField('qrCode', (dim.w - 100) / 2, 110), height: 100, width: 100 },
{ ...newField('listPrice', 20, 230), fontSize: 16, bold: true },
{ ...newField('purchasePrice', 200, 230), fontSize: 12 },
])
setSelectedId(null)
}
function handleSaveTemplate() {
setSettings(s => ({ ...s, defaultTemplate: size }))
setSavedMsg(true)
setTimeout(() => setSavedMsg(false), 2000)
}
function handlePrint() { window.print() }
return (
<div className="p-6 space-y-5">
{/* Header */}
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold text-gray-100">Label Designer</h1>
<p className="text-sm text-gray-400 mt-1">Drag fields to position them. Rollo-compatible sizes.</p>
</div>
<div className="flex items-center gap-2">
<button onClick={handleSaveTemplate} className="btn-secondary">
<Save className="w-4 h-4" />
{savedMsg ? 'Saved!' : 'Save Template'}
</button>
<button onClick={handlePrint} className="btn-primary">
<Printer className="w-4 h-4" />
Print
</button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6">
{/* Left: Controls */}
<div className="xl:col-span-1 space-y-4">
{/* Size selector */}
<div className="card">
<h3 className="text-sm font-semibold text-gray-300 mb-3">Label Size</h3>
<div className="space-y-2">
{Object.entries(LABEL_SIZES).map(([key, val]) => (
<label key={key} className="flex items-center gap-3 p-2.5 rounded-lg cursor-pointer hover:bg-gray-700 transition-colors">
<input
type="radio"
name="size"
value={key}
checked={size === key}
onChange={() => { setSize(key); resetTemplate() }}
className="text-amber-500"
/>
<div>
<p className="text-sm font-medium text-gray-200">{key}"</p>
<p className="text-xs text-gray-500">{val.label}</p>
</div>
</label>
))}
</div>
</div>
{/* Add field */}
<div className="card">
<h3 className="text-sm font-semibold text-gray-300 mb-3">Add Field</h3>
<div className="space-y-1">
{FIELD_TYPES.map(ft => (
<button
key={ft.type}
onClick={() => addField(ft.type)}
className="w-full text-left px-3 py-2 rounded-lg text-sm text-gray-300 hover:bg-gray-700 hover:text-gray-100 transition-colors flex items-center gap-2"
>
<Plus className="w-3.5 h-3.5 text-amber-400 shrink-0" />
{ft.label}
</button>
))}
</div>
</div>
{/* Preview item selector */}
<div className="card">
<h3 className="text-sm font-semibold text-gray-300 mb-3 flex items-center gap-2">
<Eye className="w-4 h-4 text-amber-400" />
Preview With
</h3>
<select
className="input"
value={previewItem?.id || ''}
onChange={e => setPreviewItem(items.find(i => i.id === e.target.value) || null)}
>
<option value="">— Placeholder text —</option>
{items.map(i => (
<option key={i.id} value={i.id}>{i.assetTag} {i.name.slice(0, 30)}</option>
))}
</select>
</div>
{/* Reset */}
<button onClick={resetTemplate} className="btn-secondary w-full justify-center">
<RotateCcw className="w-4 h-4" />
Reset Layout
</button>
</div>
{/* Center: Canvas */}
<div className="xl:col-span-2 space-y-3">
<h3 className="text-sm font-semibold text-gray-300">
Canvas — {size}" ({dim.w}×{dim.h}px)
</h3>
<div
className="relative bg-white rounded-xl shadow-2xl overflow-hidden cursor-default"
style={{ width: DISPLAY_W, height: displayH }}
onClick={() => setSelectedId(null)}
>
{/* Scale wrapper */}
<div
style={{
transform: `scale(${scale})`,
transformOrigin: 'top left',
width: dim.w,
height: dim.h,
position: 'relative',
}}
ref={canvasRef}
>
{fields.map(field => (
<CanvasField
key={field.id}
field={field}
selected={selectedId === field.id}
onSelect={setSelectedId}
onMove={moveField}
scale={scale}
previewData={previewItem}
settings={settings}
/>
))}
</div>
</div>
<p className="text-xs text-gray-500">Click a field to select it. Drag to reposition.</p>
</div>
{/* Right: Field properties */}
<div className="xl:col-span-1">
{selectedField ? (
<div className="card space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-300">
Field: {selectedField.label}
</h3>
<button
onClick={() => deleteField(selectedField.id)}
className="p-1.5 rounded hover:bg-red-900/40 text-gray-400 hover:text-red-400"
title="Delete field"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
{/* Custom text override */}
{(selectedField.type === 'customText' || selectedField.type === 'assetTag' || selectedField.type === 'businessName') && (
<div>
<label className="label">Text Content</label>
<input
className="input"
value={selectedField.text}
onChange={e => updateField(selectedField.id, { text: e.target.value })}
/>
</div>
)}
{/* Font size */}
{selectedField.type !== 'qrCode' && (
<div>
<label className="label">Font Size: {selectedField.fontSize}px</label>
<div className="flex items-center gap-2">
<input
type="range"
min="8" max="36" step="1"
value={selectedField.fontSize}
onChange={e => updateField(selectedField.id, { fontSize: Number(e.target.value) })}
className="flex-1 accent-amber-500"
/>
<span className="text-xs text-gray-400 w-8 text-right">{selectedField.fontSize}</span>
</div>
</div>
)}
{/* QR size */}
{selectedField.type === 'qrCode' && (
<div>
<label className="label">QR Size: {selectedField.height}px</label>
<input
type="range"
min="40" max="160" step="4"
value={selectedField.height}
onChange={e => updateField(selectedField.id, { height: Number(e.target.value), width: Number(e.target.value) })}
className="w-full accent-amber-500"
/>
</div>
)}
{/* Width */}
<div>
<label className="label">Width: {selectedField.width}px</label>
<input
type="range"
min="40" max={dim.w - 10} step="4"
value={selectedField.width}
onChange={e => updateField(selectedField.id, { width: Number(e.target.value) })}
className="w-full accent-amber-500"
/>
</div>
{/* Bold */}
{selectedField.type !== 'qrCode' && (
<div className="flex items-center gap-3">
<button
onClick={() => updateField(selectedField.id, { bold: !selectedField.bold })}
className={`p-2 rounded-lg transition-colors ${selectedField.bold ? 'bg-amber-500 text-gray-900' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
title="Bold"
>
<Bold className="w-4 h-4" />
</button>
<div className="flex rounded-lg overflow-hidden border border-gray-600">
{['left', 'center', 'right'].map(a => {
const Icon = a === 'left' ? AlignLeft : a === 'center' ? AlignCenter : AlignRight
return (
<button
key={a}
onClick={() => updateField(selectedField.id, { align: a })}
className={`p-2 transition-colors ${selectedField.align === a ? 'bg-amber-500 text-gray-900' : 'bg-gray-700 text-gray-300 hover:bg-gray-600'}`}
title={`Align ${a}`}
>
<Icon className="w-4 h-4" />
</button>
)
})}
</div>
</div>
)}
{/* Border toggle */}
<label className="flex items-center gap-3 cursor-pointer">
<div
className={`w-10 h-5 rounded-full transition-colors relative ${selectedField.border ? 'bg-amber-500' : 'bg-gray-600'}`}
onClick={() => updateField(selectedField.id, { border: !selectedField.border })}
>
<div className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${selectedField.border ? 'translate-x-5' : 'translate-x-0.5'}`} />
</div>
<span className="text-sm text-gray-300">Show border</span>
</label>
{/* Position */}
<div className="grid grid-cols-2 gap-2">
<div>
<label className="label">X</label>
<input
type="number"
className="input"
value={Math.round(selectedField.x)}
onChange={e => updateField(selectedField.id, { x: Number(e.target.value) })}
/>
</div>
<div>
<label className="label">Y</label>
<input
type="number"
className="input"
value={Math.round(selectedField.y)}
onChange={e => updateField(selectedField.id, { y: Number(e.target.value) })}
/>
</div>
</div>
</div>
) : (
<div className="card text-center py-10">
<GripVertical className="w-8 h-8 text-gray-600 mx-auto mb-3" />
<p className="text-sm text-gray-500">Click a field on the canvas to edit its properties.</p>
</div>
)}
{/* Fields list */}
<div className="card mt-4">
<h3 className="text-sm font-semibold text-gray-300 mb-3">All Fields ({fields.length})</h3>
<div className="space-y-1 max-h-60 overflow-y-auto">
{fields.map(f => (
<button
key={f.id}
onClick={() => setSelectedId(f.id)}
className={`w-full text-left px-3 py-2 rounded-lg text-xs transition-colors flex items-center justify-between ${
selectedId === f.id ? 'bg-amber-500/20 text-amber-300 border border-amber-500/30' : 'hover:bg-gray-700 text-gray-400'
}`}
>
<span className="truncate">{f.label}</span>
<span className="text-gray-600 ml-2 shrink-0">{Math.round(f.x)},{Math.round(f.y)}</span>
</button>
))}
</div>
</div>
</div>
</div>
{/* Print styles — hidden div for actual printing */}
<div id="print-area" className="hidden print:block">
<div style={{ width: dim.w, height: dim.h, position: 'relative', background: '#fff' }}>
{fields.map(field => (
<CanvasField
key={field.id + '_print'}
field={field}
selected={false}
onSelect={() => {}}
onMove={() => {}}
scale={1}
previewData={previewItem}
settings={settings}
/>
))}
</div>
</div>
</div>
)
}
+363
View File
@@ -0,0 +1,363 @@
import React, { useState, useRef, useEffect, useCallback } from 'react'
import { Link, useNavigate } from 'react-router-dom'
import {
ScanLine, Camera, Keyboard, CheckCircle2, XCircle,
Eye, Pencil, ShoppingCart, RotateCcw, Zap
} from 'lucide-react'
import { useData } from '../context/DataContext'
import StatusBadge from '../components/StatusBadge'
import CategoryBadge from '../components/CategoryBadge'
// ─── Bluetooth / text-input scanner mode ─────────────────────────────────────
function BluetoothScanner({ onScan }) {
const [value, setValue] = useState('')
const inputRef = useRef(null)
useEffect(() => { inputRef.current?.focus() }, [])
function handleKeyDown(e) {
if (e.key === 'Enter') {
const trimmed = value.trim()
if (trimmed) { onScan(trimmed); setValue('') }
}
}
return (
<div className="space-y-5">
<div className="flex items-center gap-3 p-4 bg-blue-900/20 border border-blue-700/40 rounded-xl">
<Zap className="w-5 h-5 text-blue-400 shrink-0" />
<div>
<p className="text-sm font-medium text-blue-300">Bluetooth Scanner Mode</p>
<p className="text-xs text-blue-400/70 mt-0.5">
Connect your Bluetooth barcode scanner, click the field, and scan any item label.
The scanner will auto-submit when it sends Enter.
</p>
</div>
</div>
<div className="relative">
<ScanLine className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-amber-400" />
<input
ref={inputRef}
className="input text-xl font-mono tracking-widest pl-12 py-4 text-center"
placeholder="Scan barcode or type asset tag…"
value={value}
onChange={e => setValue(e.target.value)}
onKeyDown={handleKeyDown}
autoFocus
autoComplete="off"
spellCheck={false}
/>
</div>
<p className="text-center text-sm text-gray-500">
Or type an asset tag (e.g. <span className="font-mono text-amber-400">TR-A1B2C3</span>) and press Enter
</p>
</div>
)
}
// ─── Camera scanner mode ──────────────────────────────────────────────────────
function CameraScanner({ onScan }) {
const videoRef = useRef(null)
const canvasRef = useRef(null)
const frameRef = useRef(null)
const [status, setStatus] = useState('idle') // idle | starting | running | error
const [errorMsg, setErrorMsg] = useState('')
const [lastScan, setLastScan] = useState(null)
async function startCamera() {
setStatus('starting')
try {
const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
videoRef.current.srcObject = stream
await videoRef.current.play()
setStatus('running')
scanLoop()
} catch (err) {
setStatus('error')
setErrorMsg(err.message || 'Camera access denied')
}
}
function stopCamera() {
cancelAnimationFrame(frameRef.current)
const stream = videoRef.current?.srcObject
stream?.getTracks().forEach(t => t.stop())
if (videoRef.current) videoRef.current.srcObject = null
setStatus('idle')
}
useEffect(() => () => stopCamera(), [])
async function scanLoop() {
if (!videoRef.current || !canvasRef.current) return
const video = videoRef.current
const canvas = canvasRef.current
const ctx = canvas.getContext('2d')
async function tick() {
if (video.readyState === video.HAVE_ENOUGH_DATA) {
canvas.width = video.videoWidth
canvas.height = video.videoHeight
ctx.drawImage(video, 0, 0)
const imgData = ctx.getImageData(0, 0, canvas.width, canvas.height)
try {
// Dynamic import jsQR
const jsQR = (await import('jsqr')).default
const code = jsQR(imgData.data, imgData.width, imgData.height)
if (code && code.data !== lastScan) {
setLastScan(code.data)
onScan(code.data)
}
} catch (_) {}
}
frameRef.current = requestAnimationFrame(tick)
}
frameRef.current = requestAnimationFrame(tick)
}
return (
<div className="space-y-5">
<div className="flex items-center gap-3 p-4 bg-purple-900/20 border border-purple-700/40 rounded-xl">
<Camera className="w-5 h-5 text-purple-400 shrink-0" />
<div>
<p className="text-sm font-medium text-purple-300">Camera Scanner Mode</p>
<p className="text-xs text-purple-400/70 mt-0.5">
Uses your device camera to scan QR codes from printed labels.
Requires camera permission.
</p>
</div>
</div>
{status === 'idle' && (
<div className="text-center py-10">
<Camera className="w-12 h-12 text-gray-600 mx-auto mb-4" />
<p className="text-gray-400 mb-4">Camera is not active.</p>
<button onClick={startCamera} className="btn-primary mx-auto">
<Camera className="w-4 h-4" />
Start Camera
</button>
</div>
)}
{status === 'starting' && (
<div className="text-center py-10">
<div className="w-8 h-8 border-2 border-amber-500 border-t-transparent rounded-full animate-spin mx-auto mb-3" />
<p className="text-gray-400">Starting camera</p>
</div>
)}
{status === 'running' && (
<div className="space-y-3">
<div className="relative rounded-xl overflow-hidden bg-black">
<video ref={videoRef} className="w-full rounded-xl" playsInline muted />
{/* Scan overlay */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="w-48 h-48 border-2 border-amber-400 rounded-xl opacity-70">
<div className="absolute top-0 left-0 w-6 h-6 border-t-2 border-l-2 border-amber-400 rounded-tl-xl" />
<div className="absolute top-0 right-0 w-6 h-6 border-t-2 border-r-2 border-amber-400 rounded-tr-xl" />
<div className="absolute bottom-0 left-0 w-6 h-6 border-b-2 border-l-2 border-amber-400 rounded-bl-xl" />
<div className="absolute bottom-0 right-0 w-6 h-6 border-b-2 border-r-2 border-amber-400 rounded-br-xl" />
</div>
</div>
</div>
<canvas ref={canvasRef} className="hidden" />
<button onClick={stopCamera} className="btn-secondary w-full justify-center">
Stop Camera
</button>
</div>
)}
{status === 'error' && (
<div className="text-center py-10">
<XCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-red-400 mb-1">Camera Error</p>
<p className="text-sm text-gray-500 mb-4">{errorMsg}</p>
<button onClick={() => setStatus('idle')} className="btn-secondary mx-auto">
<RotateCcw className="w-4 h-4" /> Retry
</button>
</div>
)}
</div>
)
}
// ─── Scan result card ─────────────────────────────────────────────────────────
function ScanResult({ item, onMarkSold, onClear, settings }) {
const sym = settings.currencySymbol
const fmt = n => n != null ? `${sym}${Number(n).toFixed(2)}` : '—'
if (!item) return null
return (
<div className="card border-amber-500/30 bg-amber-500/5">
<div className="flex items-start justify-between gap-3 mb-4">
<div>
<div className="flex items-center gap-2 flex-wrap">
<CheckCircle2 className="w-5 h-5 text-green-400" />
<h3 className="text-base font-semibold text-gray-100">Item Found</h3>
<StatusBadge status={item.status} />
</div>
<p className="font-mono text-sm text-amber-400 mt-1">{item.assetTag}</p>
</div>
<button onClick={onClear} className="p-1.5 rounded hover:bg-gray-700 text-gray-400 hover:text-gray-100" title="Clear result">
<XCircle className="w-4 h-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-3 mb-5">
<div>
<p className="text-lg font-bold text-gray-100 leading-tight">{item.name}</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<CategoryBadge category={item.category} />
<span className="text-xs text-gray-400">{item.condition}</span>
</div>
</div>
<div className="text-right space-y-1">
<p className="text-xs text-gray-500">List Price</p>
<p className="text-xl font-bold text-amber-400">{fmt(item.listPrice)}</p>
<p className="text-xs text-gray-500">Cost: {fmt(item.purchasePrice)}</p>
</div>
</div>
{item.description && (
<p className="text-sm text-gray-400 mb-4 bg-gray-700/40 rounded-lg px-3 py-2">{item.description}</p>
)}
{/* Quick actions */}
<div className="flex flex-wrap gap-2">
<Link to={`/inventory/${item.id}`} className="btn-primary flex-1 justify-center">
<Eye className="w-4 h-4" />
View Full Item
</Link>
<Link to={`/inventory/${item.id}/edit`} className="btn-secondary flex-1 justify-center">
<Pencil className="w-4 h-4" />
Edit
</Link>
{item.status !== 'Sold' && (
<button onClick={onMarkSold} className="btn-secondary flex-1 justify-center text-green-400 border-green-800 hover:bg-green-900/30">
<ShoppingCart className="w-4 h-4" />
Mark as Sold
</button>
)}
</div>
</div>
)
}
// ─── Scan not found ───────────────────────────────────────────────────────────
function ScanNotFound({ query, onClear }) {
return (
<div className="card border-red-500/30 bg-red-500/5 text-center py-8">
<XCircle className="w-10 h-10 text-red-400 mx-auto mb-3" />
<p className="text-red-300 font-medium">No item found for:</p>
<p className="font-mono text-amber-400 text-lg mt-1">{query}</p>
<p className="text-sm text-gray-500 mt-2 mb-5">Check the asset tag and try again.</p>
<button onClick={onClear} className="btn-secondary mx-auto">
<RotateCcw className="w-4 h-4" /> Try Again
</button>
</div>
)
}
// ─── Main page ────────────────────────────────────────────────────────────────
export default function Scanner() {
const { getItemByTag, updateItem, settings } = useData()
const [mode, setMode] = useState('bluetooth') // bluetooth | camera
const [result, setResult] = useState(null) // { item, query } | null
const [soldMsg, setSoldMsg] = useState(false)
const handleScan = useCallback((raw) => {
// Raw might be full QR value like "Texas Resellers|TR-A1B2C3|Name"
// or just the asset tag like "TR-A1B2C3"
let query = raw.trim()
if (query.includes('|')) {
const parts = query.split('|')
query = parts[1] || query
}
const item = getItemByTag(query)
setResult({ item: item || null, query })
setSoldMsg(false)
}, [getItemByTag])
function handleMarkSold() {
if (!result?.item) return
updateItem(result.item.id, {
status: 'Sold',
dateSold: new Date().toISOString().split('T')[0],
})
setSoldMsg(true)
// Refresh result with updated item
setTimeout(() => {
setResult(prev => ({
...prev,
item: { ...prev.item, status: 'Sold', dateSold: new Date().toISOString().split('T')[0] }
}))
}, 100)
}
function handleClear() {
setResult(null)
setSoldMsg(false)
}
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-100">Scanner</h1>
<p className="text-sm text-gray-400 mt-1">Scan a barcode or QR code from a printed label.</p>
</div>
{/* Mode tabs */}
<div className="flex gap-1 p-1 bg-gray-800 rounded-xl">
<button
onClick={() => { setMode('bluetooth'); handleClear() }}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-colors ${
mode === 'bluetooth' ? 'bg-amber-500 text-gray-900' : 'text-gray-400 hover:text-gray-100'
}`}
>
<Keyboard className="w-4 h-4" />
Bluetooth Scanner
</button>
<button
onClick={() => { setMode('camera'); handleClear() }}
className={`flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg text-sm font-medium transition-colors ${
mode === 'camera' ? 'bg-amber-500 text-gray-900' : 'text-gray-400 hover:text-gray-100'
}`}
>
<Camera className="w-4 h-4" />
Camera
</button>
</div>
{/* Scanner UI */}
{mode === 'bluetooth' && <BluetoothScanner onScan={handleScan} />}
{mode === 'camera' && <CameraScanner onScan={handleScan} />}
{/* Result */}
{result && (
<div className="space-y-3">
{soldMsg && (
<div className="flex items-center gap-2 p-3 bg-green-900/30 border border-green-700/40 rounded-xl text-green-300 text-sm">
<CheckCircle2 className="w-4 h-4 shrink-0" />
Item marked as sold!
</div>
)}
{result.item
? <ScanResult item={result.item} onMarkSold={handleMarkSold} onClear={handleClear} settings={settings} />
: <ScanNotFound query={result.query} onClear={handleClear} />
}
</div>
)}
{/* Recent scans hint */}
{!result && (
<div className="text-center py-6 text-gray-600 text-sm">
<ScanLine className="w-8 h-8 mx-auto mb-2 text-gray-700" />
Scan result will appear here
</div>
)}
</div>
)
}
+192
View File
@@ -0,0 +1,192 @@
import React, { useState } from 'react'
import { Save, Eye, EyeOff, User, Building2, DollarSign, Tag, Shield } from 'lucide-react'
import { useData } from '../context/DataContext'
const LABEL_SIZES = ['4x6', '2x4', '2x2']
const CURRENCIES = [
{ symbol: '$', label: 'USD — US Dollar ($)' },
{ symbol: '€', label: 'EUR — Euro (€)' },
{ symbol: '£', label: 'GBP — British Pound (£)' },
{ symbol: 'CA$', label: 'CAD — Canadian Dollar (CA$)' },
]
function SectionTitle({ icon: Icon, title }) {
return (
<div className="flex items-center gap-2 mb-4">
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-400">
<Icon className="w-4 h-4" />
</div>
<h2 className="text-sm font-semibold text-gray-200 uppercase tracking-wider">{title}</h2>
</div>
)
}
export default function Settings() {
const { settings, setSettings } = useData()
const [form, setForm] = useState({ ...settings })
const [showPass, setShowPass] = useState(false)
const [saved, setSaved] = useState(false)
const [errors, setErrors] = useState({})
function set(field, val) {
setForm(f => ({ ...f, [field]: val }))
if (errors[field]) setErrors(e => ({ ...e, [field]: null }))
}
function validate() {
const errs = {}
if (!form.businessName.trim()) errs.businessName = 'Business name is required'
if (!form.username.trim()) errs.username = 'Username is required'
if (form.password && form.password.length < 6) errs.password = 'Password must be at least 6 characters'
return errs
}
function handleSave(e) {
e.preventDefault()
const errs = validate()
if (Object.keys(errs).length) { setErrors(errs); return }
setSettings({ ...form })
setSaved(true)
setTimeout(() => setSaved(false), 2500)
}
return (
<div className="p-6 max-w-2xl mx-auto space-y-6">
{/* Header */}
<div>
<h1 className="text-2xl font-bold text-gray-100">Settings</h1>
<p className="text-sm text-gray-400 mt-1">Customize your Texas Resellers experience.</p>
</div>
<form onSubmit={handleSave} className="space-y-5">
{/* Business settings */}
<div className="card">
<SectionTitle icon={Building2} title="Business" />
<div className="space-y-4">
<div>
<label className="label">
Business Name <span className="text-red-400">*</span>
</label>
<input
className="input"
value={form.businessName}
onChange={e => set('businessName', e.target.value)}
placeholder="Texas Resellers"
/>
{errors.businessName && <p className="text-xs text-red-400 mt-1">{errors.businessName}</p>}
<p className="text-xs text-gray-500 mt-1">Shown on labels and throughout the app.</p>
</div>
</div>
</div>
{/* Label & display */}
<div className="card">
<SectionTitle icon={Tag} title="Labels & Display" />
<div className="grid grid-cols-2 gap-4">
<div>
<label className="label">Default Label Size</label>
<select
className="input"
value={form.defaultTemplate}
onChange={e => set('defaultTemplate', e.target.value)}
>
{LABEL_SIZES.map(s => (
<option key={s} value={s}>{s} inches</option>
))}
</select>
<p className="text-xs text-gray-500 mt-1">Used when opening Label Designer.</p>
</div>
<div>
<label className="label">Currency Symbol</label>
<select
className="input"
value={form.currencySymbol}
onChange={e => set('currencySymbol', e.target.value)}
>
{CURRENCIES.map(c => (
<option key={c.symbol} value={c.symbol}>{c.label}</option>
))}
</select>
</div>
</div>
</div>
{/* Auth */}
<div className="card">
<SectionTitle icon={Shield} title="Login Credentials" />
<div className="space-y-4">
<div className="p-3 bg-yellow-900/20 border border-yellow-700/40 rounded-lg text-xs text-yellow-300">
These credentials are stored locally (UI only). No real authentication is implemented.
</div>
<div>
<label className="label">
Username <span className="text-red-400">*</span>
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
className="input pl-9"
value={form.username}
onChange={e => set('username', e.target.value)}
autoComplete="username"
/>
</div>
{errors.username && <p className="text-xs text-red-400 mt-1">{errors.username}</p>}
</div>
<div>
<label className="label">Password</label>
<div className="relative">
<DollarSign className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
className="input pl-9 pr-10"
type={showPass ? 'text' : 'password'}
value={form.password}
onChange={e => set('password', e.target.value)}
autoComplete="new-password"
/>
<button
type="button"
onClick={() => setShowPass(s => !s)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-100"
>
{showPass ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password && <p className="text-xs text-red-400 mt-1">{errors.password}</p>}
<p className="text-xs text-gray-500 mt-1">Leave blank to keep current password.</p>
</div>
</div>
</div>
{/* Save */}
<div className="flex items-center gap-3">
<button type="submit" className="btn-primary">
<Save className="w-4 h-4" />
Save Settings
</button>
{saved && (
<span className="text-sm text-green-400 flex items-center gap-1.5">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
Settings saved!
</span>
)}
</div>
</form>
{/* About */}
<div className="card">
<h2 className="text-sm font-semibold text-gray-300 uppercase tracking-wider mb-3">About</h2>
<div className="space-y-1.5 text-sm text-gray-400">
<p><span className="text-gray-300 font-medium">App:</span> Texas Resellers Inventory Manager</p>
<p><span className="text-gray-300 font-medium">Version:</span> 1.0.0</p>
<p><span className="text-gray-300 font-medium">Stack:</span> React 18 + Vite + Tailwind CSS</p>
<p><span className="text-gray-300 font-medium">Theme:</span> Dark</p>
</div>
</div>
</div>
)
}
+29
View File
@@ -0,0 +1,29 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
brand: {
50: '#fef3c7',
100: '#fde68a',
200: '#fcd34d',
300: '#fbbf24',
400: '#f59e0b',
500: '#d97706',
600: '#b45309',
700: '#92400e',
800: '#78350f',
900: '#451a03',
}
},
fontFamily: {
sans: ['Inter', 'system-ui', 'sans-serif'],
}
},
},
plugins: [],
}
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
host: true
}
})