Import TexasResellers into Gitea
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.astro/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.venv/
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
Generated
+5863
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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: [],
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
host: true
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user