Initial commit: Fast Inventory app
Monorepo with Express + SQLite backend and Vite + React frontend. Features: item CRUD, file uploads with thumbnails, soft delete, item duplication with file copying, autocomplete inputs, stats dashboard. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
12
client/index.html
Normal file
12
client/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Fast Inventory</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
25
client/package.json
Normal file
25
client/package.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-hook-form": "^7.54.0",
|
||||
"react-router-dom": "^7.1.0",
|
||||
"recharts": "^2.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
66
client/src/App.tsx
Normal file
66
client/src/App.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { BrowserRouter, Routes, Route, NavLink } from 'react-router-dom';
|
||||
import ItemsPage from './pages/ItemsPage';
|
||||
import StatsPage from './pages/StatsPage';
|
||||
import DeletedItemsPage from './pages/DeletedItemsPage';
|
||||
import { useTheme } from './useTheme';
|
||||
|
||||
export default function App() {
|
||||
const { dark, toggle } = useTheme();
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<nav className="bg-white dark:bg-gray-900 border-b border-gray-200 dark:border-gray-700 px-4 py-2 flex items-center gap-6">
|
||||
<h1 className="text-lg font-bold text-gray-900 dark:text-gray-100">Fast Inventory</h1>
|
||||
<NavLink
|
||||
to="/"
|
||||
end
|
||||
className={({ isActive }) =>
|
||||
`text-sm font-medium px-3 py-1.5 rounded-md ${isActive ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900' : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}`
|
||||
}
|
||||
>
|
||||
Items
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/stats"
|
||||
className={({ isActive }) =>
|
||||
`text-sm font-medium px-3 py-1.5 rounded-md ${isActive ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900' : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}`
|
||||
}
|
||||
>
|
||||
Stats
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/deleted"
|
||||
className={({ isActive }) =>
|
||||
`text-sm font-medium px-3 py-1.5 rounded-md ${isActive ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900' : 'text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-100'}`
|
||||
}
|
||||
>
|
||||
Deleted
|
||||
</NavLink>
|
||||
<button
|
||||
onClick={toggle}
|
||||
className="ml-auto text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 p-1.5 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
>
|
||||
{dark ? (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-5 h-5">
|
||||
<path d="M10 2a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 2zM10 15a.75.75 0 01.75.75v1.5a.75.75 0 01-1.5 0v-1.5A.75.75 0 0110 15zM10 7a3 3 0 100 6 3 3 0 000-6zM15.657 5.404a.75.75 0 10-1.06-1.06l-1.061 1.06a.75.75 0 001.06 1.06l1.06-1.06zM6.464 14.596a.75.75 0 10-1.06-1.06l-1.06 1.06a.75.75 0 001.06 1.06l1.06-1.06zM18 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 0118 10zM5 10a.75.75 0 01-.75.75h-1.5a.75.75 0 010-1.5h1.5A.75.75 0 015 10zM14.596 15.657a.75.75 0 001.06-1.06l-1.06-1.061a.75.75 0 10-1.06 1.06l1.06 1.06zM5.404 6.464a.75.75 0 001.06-1.06l-1.06-1.06a.75.75 0 10-1.06 1.06l1.06 1.06z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" className="w-5 h-5">
|
||||
<path fillRule="evenodd" d="M7.455 2.004a.75.75 0 01.26.77 7 7 0 009.958 7.967.75.75 0 011.067.853A8.5 8.5 0 116.647 1.921a.75.75 0 01.808.083z" clipRule="evenodd" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
</nav>
|
||||
<main className="max-w-[1600px] mx-auto px-4 py-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<ItemsPage />} />
|
||||
<Route path="/stats" element={<StatsPage />} />
|
||||
<Route path="/deleted" element={<DeletedItemsPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
96
client/src/api.ts
Normal file
96
client/src/api.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import type { Item, CreateItemBody, UpdateItemBody, StatsResponse, ItemFile } from './types';
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...init,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
throw new Error(body.error || `Request failed: ${res.status}`);
|
||||
}
|
||||
if (res.status === 204) return undefined as T;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export const api = {
|
||||
// Items
|
||||
listItems(params?: Record<string, string>): Promise<Item[]> {
|
||||
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||
return request(`/items${qs}`);
|
||||
},
|
||||
getItem(id: string): Promise<Item & { files: ItemFile[] }> {
|
||||
return request(`/items/${id}`);
|
||||
},
|
||||
createItem(body: CreateItemBody): Promise<Item> {
|
||||
return request('/items', { method: 'POST', body: JSON.stringify(body) });
|
||||
},
|
||||
updateItem(id: string, body: UpdateItemBody): Promise<Item> {
|
||||
return request(`/items/${id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
},
|
||||
deleteItem(id: string): Promise<void> {
|
||||
return request(`/items/${id}`, { method: 'DELETE' });
|
||||
},
|
||||
restoreItem(id: string): Promise<Item> {
|
||||
return request(`/items/${id}/restore`, { method: 'POST' });
|
||||
},
|
||||
permanentDeleteItem(id: string): Promise<void> {
|
||||
return request(`/items/${id}/permanent`, { method: 'DELETE' });
|
||||
},
|
||||
duplicateItem(id: string): Promise<Item> {
|
||||
return request(`/items/${id}/duplicate`, { method: 'POST' });
|
||||
},
|
||||
|
||||
bulkUpdateItems(ids: string[], updates: { category?: string; location?: string; status?: string; flagged?: number }): Promise<{ updated: number }> {
|
||||
return request('/items/bulk', { method: 'PATCH', body: JSON.stringify({ ids, updates }) });
|
||||
},
|
||||
|
||||
// Suggestions
|
||||
getCategories(): Promise<string[]> {
|
||||
return request('/suggestions/categories');
|
||||
},
|
||||
getLocations(): Promise<string[]> {
|
||||
return request('/suggestions/locations');
|
||||
},
|
||||
|
||||
// Files
|
||||
async uploadFiles(itemId: string, files: File[], type: 'photo' | 'receipt'): Promise<ItemFile[]> {
|
||||
const form = new FormData();
|
||||
form.append('type', type);
|
||||
for (const f of files) form.append('files', f);
|
||||
const res = await fetch(`${BASE}/items/${itemId}/files`, { method: 'POST', body: form });
|
||||
if (!res.ok) throw new Error('Upload failed');
|
||||
return res.json();
|
||||
},
|
||||
deleteFile(fileId: string): Promise<void> {
|
||||
return request(`/files/${fileId}`, { method: 'DELETE' });
|
||||
},
|
||||
setFeaturedPhoto(itemId: string, fileId: string): Promise<void> {
|
||||
return request(`/items/${itemId}/files/${fileId}/featured`, { method: 'PUT' });
|
||||
},
|
||||
|
||||
// Stats
|
||||
getStats(): Promise<StatsResponse> {
|
||||
return request('/stats');
|
||||
},
|
||||
};
|
||||
|
||||
// Image variant URL helpers
|
||||
function variantUrl(storedName: string, suffix: string): string {
|
||||
const dot = storedName.lastIndexOf('.');
|
||||
return `/api/uploads/${storedName.slice(0, dot)}${suffix}${storedName.slice(dot)}`;
|
||||
}
|
||||
|
||||
export function thumbUrl(storedName: string): string {
|
||||
return variantUrl(storedName, '_thumb');
|
||||
}
|
||||
|
||||
export function largeUrl(storedName: string): string {
|
||||
return variantUrl(storedName, '_large');
|
||||
}
|
||||
|
||||
export function uploadUrl(storedName: string): string {
|
||||
return `/api/uploads/${storedName}`;
|
||||
}
|
||||
106
client/src/components/AutocompleteInput.tsx
Normal file
106
client/src/components/AutocompleteInput.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
interface Props {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
suggestions: string[];
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
onKeyDown?: (e: React.KeyboardEvent) => void;
|
||||
}
|
||||
|
||||
export default function AutocompleteInput({ value, onChange, suggestions, placeholder, className, onKeyDown }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [highlightIdx, setHighlightIdx] = useState(-1);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const listRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const filtered = value
|
||||
? suggestions.filter(s => s.toLowerCase().includes(value.toLowerCase()))
|
||||
: suggestions;
|
||||
|
||||
const handleSelect = useCallback((val: string) => {
|
||||
onChange(val);
|
||||
setOpen(false);
|
||||
setHighlightIdx(-1);
|
||||
}, [onChange]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (!open || filtered.length === 0) {
|
||||
// Tab with no dropdown: accept what's typed, let focus move naturally
|
||||
onKeyDown?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setHighlightIdx(i => Math.min(i + 1, filtered.length - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setHighlightIdx(i => Math.max(i - 1, 0));
|
||||
} else if (e.key === 'Enter' && highlightIdx >= 0) {
|
||||
e.preventDefault();
|
||||
handleSelect(filtered[highlightIdx]);
|
||||
} else if (e.key === 'Tab' && highlightIdx >= 0) {
|
||||
// Tab accepts highlighted suggestion
|
||||
handleSelect(filtered[highlightIdx]);
|
||||
// Don't prevent default — let focus move to next field
|
||||
} else if (e.key === 'Tab' && filtered.length > 0 && value) {
|
||||
// Tab with text typed: accept first match
|
||||
handleSelect(filtered[0]);
|
||||
} else if (e.key === 'Escape') {
|
||||
setOpen(false);
|
||||
setHighlightIdx(-1);
|
||||
} else {
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightIdx >= 0 && listRef.current) {
|
||||
const el = listRef.current.children[highlightIdx] as HTMLElement;
|
||||
el?.scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}, [highlightIdx]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={e => {
|
||||
onChange(e.target.value);
|
||||
setOpen(true);
|
||||
setHighlightIdx(-1);
|
||||
}}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => {
|
||||
// Delay to allow click on dropdown items
|
||||
setTimeout(() => setOpen(false), 150);
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{open && filtered.length > 0 && (
|
||||
<ul
|
||||
ref={listRef}
|
||||
className="absolute z-50 left-0 min-w-full w-max top-full mt-1 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg max-h-40 overflow-y-auto text-sm"
|
||||
>
|
||||
{filtered.slice(0, 10).map((item, idx) => (
|
||||
<li
|
||||
key={item}
|
||||
className={`px-3 py-1.5 cursor-pointer ${idx === highlightIdx ? 'bg-gray-100 dark:bg-gray-700 text-gray-900 dark:text-gray-100' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'}`}
|
||||
onMouseDown={() => handleSelect(item)}
|
||||
onMouseEnter={() => setHighlightIdx(idx)}
|
||||
>
|
||||
{item}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
375
client/src/components/ItemDetail.tsx
Normal file
375
client/src/components/ItemDetail.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { api, thumbUrl, uploadUrl } from '../api';
|
||||
import type { Item, ItemFile, ItemStatus, UpdateItemBody } from '../types';
|
||||
import { STATUS_LABELS } from '../types';
|
||||
import AutocompleteInput from './AutocompleteInput';
|
||||
import Lightbox from './Lightbox';
|
||||
|
||||
interface Props {
|
||||
itemId: string;
|
||||
onClose: () => void;
|
||||
onUpdated: () => void;
|
||||
}
|
||||
|
||||
const STATUSES: ItemStatus[] = ['active', 'stored', 'lent_out', 'sold', 'lost', 'trashed'];
|
||||
|
||||
export default function ItemDetail({ itemId, onClose, onUpdated }: Props) {
|
||||
const [item, setItem] = useState<(Item & { files: ItemFile[] }) | null>(null);
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [locations, setLocations] = useState<string[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [duplicating, setDuplicating] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
const photoInputRef = useRef<HTMLInputElement>(null);
|
||||
const receiptInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const { register, handleSubmit, reset, setValue, watch } = useForm<UpdateItemBody>();
|
||||
const categoryVal = watch('category') || '';
|
||||
const locationVal = watch('location') || '';
|
||||
|
||||
useEffect(() => {
|
||||
api.getItem(itemId).then(data => {
|
||||
setItem(data);
|
||||
reset({
|
||||
name: data.name,
|
||||
category: data.category,
|
||||
location: data.location,
|
||||
purchase_date: data.purchase_date,
|
||||
purchase_price: data.purchase_price,
|
||||
status: data.status,
|
||||
notes: data.notes,
|
||||
flagged: data.flagged,
|
||||
});
|
||||
});
|
||||
api.getCategories().then(setCategories);
|
||||
api.getLocations().then(setLocations);
|
||||
}, [itemId, reset]);
|
||||
|
||||
const onSave = async (data: UpdateItemBody) => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateItem(itemId, data);
|
||||
onUpdated();
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!confirmDelete) {
|
||||
setConfirmDelete(true);
|
||||
return;
|
||||
}
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deleteItem(itemId);
|
||||
onUpdated();
|
||||
onClose();
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicate = async () => {
|
||||
if (!item) return;
|
||||
setDuplicating(true);
|
||||
try {
|
||||
await api.duplicateItem(itemId);
|
||||
onUpdated();
|
||||
onClose();
|
||||
} finally {
|
||||
setDuplicating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (files: FileList | null, type: 'photo' | 'receipt') => {
|
||||
if (!files?.length) return;
|
||||
setUploading(true);
|
||||
try {
|
||||
await api.uploadFiles(itemId, Array.from(files), type);
|
||||
const updated = await api.getItem(itemId);
|
||||
setItem(updated);
|
||||
onUpdated();
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileDelete = async (fileId: string) => {
|
||||
await api.deleteFile(fileId);
|
||||
const updated = await api.getItem(itemId);
|
||||
setItem(updated);
|
||||
onUpdated();
|
||||
};
|
||||
|
||||
const handleSetFeatured = async (fileId: string) => {
|
||||
await api.setFeaturedPhoto(itemId, fileId);
|
||||
const updated = await api.getItem(itemId);
|
||||
setItem(updated);
|
||||
onUpdated();
|
||||
};
|
||||
|
||||
if (!item) return null;
|
||||
|
||||
const photos = item.files.filter(f => f.file_type === 'photo');
|
||||
const receipts = item.files.filter(f => f.file_type === 'receipt');
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 flex items-start justify-center pt-12 px-4">
|
||||
<div className="fixed inset-0 bg-black/30" onClick={onClose} />
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-xl shadow-xl w-full max-w-lg max-h-[85vh] overflow-y-auto">
|
||||
<div className="sticky top-0 bg-white dark:bg-gray-900 border-b border-gray-100 dark:border-gray-800 px-5 py-3 flex items-center justify-between">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
Edit Item <span className="text-gray-400 dark:text-gray-500 font-mono text-sm font-normal">#{item.label_id}</span>
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 text-lg">×</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit(onSave)} className="p-5 space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Name *</label>
|
||||
<input {...register('name', { required: true })} className="w-full text-sm border border-gray-200 dark:border-gray-700 rounded-md px-3 py-2 outline-none focus:border-gray-400 dark:focus:border-gray-500 bg-white dark:bg-gray-800 dark:text-gray-100" />
|
||||
</div>
|
||||
|
||||
{/* Category + Location */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Category</label>
|
||||
<AutocompleteInput
|
||||
value={categoryVal}
|
||||
onChange={v => setValue('category', v)}
|
||||
suggestions={categories}
|
||||
placeholder="Category"
|
||||
className="w-full text-sm border border-gray-200 dark:border-gray-700 rounded-md px-3 py-2 outline-none focus:border-gray-400 dark:focus:border-gray-500 bg-white dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Location</label>
|
||||
<AutocompleteInput
|
||||
value={locationVal}
|
||||
onChange={v => setValue('location', v)}
|
||||
suggestions={locations}
|
||||
placeholder="Location"
|
||||
className="w-full text-sm border border-gray-200 dark:border-gray-700 rounded-md px-3 py-2 outline-none focus:border-gray-400 dark:focus:border-gray-500 bg-white dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price + Date */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Purchase Price (SEK)</label>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
{...register('purchase_price', { valueAsNumber: true })}
|
||||
className="w-full text-sm border border-gray-200 dark:border-gray-700 rounded-md px-3 py-2 outline-none focus:border-gray-400 dark:focus:border-gray-500 bg-white dark:bg-gray-800 dark:text-gray-100"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Purchase Date</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="YYYY-MM-DD"
|
||||
pattern="\d{4}-\d{2}-\d{2}"
|
||||
{...register('purchase_date')}
|
||||
className="w-full text-sm border border-gray-200 dark:border-gray-700 rounded-md px-3 py-2 outline-none focus:border-gray-400 dark:focus:border-gray-500 bg-white dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Status</label>
|
||||
<select {...register('status')} className="w-full text-sm border border-gray-200 dark:border-gray-700 rounded-md px-3 py-2 outline-none focus:border-gray-400 dark:focus:border-gray-500 bg-white dark:bg-gray-800 dark:text-gray-100">
|
||||
{STATUSES.map(s => (
|
||||
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Flagged */}
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 cursor-pointer select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={watch('flagged') === 1}
|
||||
onChange={e => setValue('flagged', e.target.checked ? 1 : 0)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
Flagged for removal
|
||||
</label>
|
||||
|
||||
{/* Notes */}
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Notes</label>
|
||||
<textarea
|
||||
{...register('notes')}
|
||||
rows={3}
|
||||
className="w-full text-sm border border-gray-200 dark:border-gray-700 rounded-md px-3 py-2 outline-none focus:border-gray-400 dark:focus:border-gray-500 resize-none bg-white dark:bg-gray-800 dark:text-gray-100"
|
||||
placeholder="Optional notes..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Photos */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-500 dark:text-gray-400">Photos</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => photoInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
<input
|
||||
ref={photoInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => handleFileUpload(e.target.files, 'photo')}
|
||||
/>
|
||||
</div>
|
||||
{photos.length > 0 ? (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{photos.map((f, idx) => (
|
||||
<div key={f.id} className="relative group w-20 h-20">
|
||||
<img
|
||||
src={thumbUrl(f.stored_name)}
|
||||
alt={f.filename}
|
||||
className="w-full h-full object-cover rounded-md border border-gray-200 dark:border-gray-700 cursor-zoom-in"
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
onError={e => { e.currentTarget.src = uploadUrl(f.stored_name); }}
|
||||
/>
|
||||
{/* Featured star */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetFeatured(f.id)}
|
||||
className={`absolute bottom-0.5 left-0.5 w-5 h-5 flex items-center justify-center rounded-full text-xs ${
|
||||
f.is_featured
|
||||
? 'bg-yellow-400 text-yellow-900'
|
||||
: 'bg-black/40 text-white/60 opacity-0 group-hover:opacity-100 transition-opacity'
|
||||
}`}
|
||||
title={f.is_featured ? 'Featured photo' : 'Set as featured'}
|
||||
>
|
||||
★
|
||||
</button>
|
||||
{/* Delete */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFileDelete(f.id)}
|
||||
className="absolute -top-1.5 -right-1.5 bg-red-500 text-white rounded-full w-5 h-5 text-xs leading-none opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">No photos</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Receipts */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="text-xs font-medium text-gray-500 dark:text-gray-400">Receipts</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => receiptInputRef.current?.click()}
|
||||
disabled={uploading}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
<input
|
||||
ref={receiptInputRef}
|
||||
type="file"
|
||||
accept="image/*,application/pdf"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={e => handleFileUpload(e.target.files, 'receipt')}
|
||||
/>
|
||||
</div>
|
||||
{receipts.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{receipts.map(f => (
|
||||
<div key={f.id} className="flex items-center gap-2 text-xs text-gray-600 dark:text-gray-400">
|
||||
<span className="truncate flex-1">{f.filename}</span>
|
||||
<a
|
||||
href={`/api/uploads/${f.stored_name}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-blue-500 hover:underline"
|
||||
>
|
||||
View
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleFileDelete(f.id)}
|
||||
className="text-red-400 hover:text-red-600"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-gray-400">No receipts</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDelete}
|
||||
className={`text-xs font-medium px-3 py-1.5 rounded-md ${confirmDelete ? 'bg-red-600 text-white' : 'text-red-500 hover:bg-red-50 dark:hover:bg-red-900/30'}`}
|
||||
disabled={deleting}
|
||||
>
|
||||
{confirmDelete ? 'Confirm Delete' : 'Delete'}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDuplicate}
|
||||
disabled={duplicating}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 px-3 py-1.5 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
>
|
||||
Duplicate
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className="text-xs text-gray-500 dark:text-gray-400 px-3 py-1.5 rounded-md hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving}
|
||||
className="text-xs font-medium px-4 py-1.5 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-md hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-40"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
photos={photos}
|
||||
initialIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
435
client/src/components/ItemList.tsx
Normal file
435
client/src/components/ItemList.tsx
Normal file
@@ -0,0 +1,435 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { api, thumbUrl, uploadUrl } from '../api';
|
||||
import type { Item, ItemStatus } from '../types';
|
||||
import { STATUS_LABELS } from '../types';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import ItemDetail from './ItemDetail';
|
||||
import Lightbox from './Lightbox';
|
||||
import AutocompleteInput from './AutocompleteInput';
|
||||
|
||||
interface Props {
|
||||
onItemChanged: () => void;
|
||||
}
|
||||
|
||||
const ALL_STATUSES: (ItemStatus | 'all')[] = ['all', 'active', 'stored', 'lent_out', 'sold', 'lost', 'trashed'];
|
||||
|
||||
type ColumnKey = 'label_id' | 'thumbnail' | 'name' | 'category' | 'location' | 'purchase_price' | 'purchase_date' | 'status' | 'flagged' | 'created_at';
|
||||
|
||||
interface ColumnDef {
|
||||
key: ColumnKey;
|
||||
label: string;
|
||||
sortKey?: string; // API sort key, omit if not sortable
|
||||
align?: 'right';
|
||||
defaultVisible: boolean;
|
||||
}
|
||||
|
||||
const COLUMNS: ColumnDef[] = [
|
||||
{ key: 'label_id', label: 'ID', sortKey: 'label_id', defaultVisible: true },
|
||||
{ key: 'thumbnail', label: 'Image', defaultVisible: false },
|
||||
{ key: 'name', label: 'Name', sortKey: 'name', defaultVisible: true },
|
||||
{ key: 'category', label: 'Category', sortKey: 'category', defaultVisible: true },
|
||||
{ key: 'location', label: 'Location', sortKey: 'location', defaultVisible: true },
|
||||
{ key: 'purchase_price', label: 'Price', sortKey: 'purchase_price', align: 'right', defaultVisible: true },
|
||||
{ key: 'purchase_date', label: 'Purchased', sortKey: 'purchase_date', defaultVisible: false },
|
||||
{ key: 'status', label: 'Status', sortKey: 'status', defaultVisible: true },
|
||||
{ key: 'flagged', label: 'Flag', sortKey: 'flagged', defaultVisible: true },
|
||||
{ key: 'created_at', label: 'Added', sortKey: 'created_at', defaultVisible: true },
|
||||
];
|
||||
|
||||
const STORAGE_KEY = 'fast-inv-visible-cols';
|
||||
|
||||
function loadVisibleCols(): Set<ColumnKey> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) return new Set(JSON.parse(raw) as ColumnKey[]);
|
||||
} catch { /* ignore */ }
|
||||
return new Set(COLUMNS.filter(c => c.defaultVisible).map(c => c.key));
|
||||
}
|
||||
|
||||
function saveVisibleCols(cols: Set<ColumnKey>) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...cols]));
|
||||
}
|
||||
|
||||
function formatDate(raw: string): string {
|
||||
if (!raw) return '—';
|
||||
// Already YYYY-MM-DD from SQLite datetime → take first 10 chars
|
||||
const d = raw.slice(0, 10);
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(d) ? d : '—';
|
||||
}
|
||||
|
||||
function formatSEK(price: number | null): string {
|
||||
if (price == null) return '—';
|
||||
return price.toLocaleString('sv-SE', { minimumFractionDigits: 0, maximumFractionDigits: 2 }) + ' kr';
|
||||
}
|
||||
|
||||
export default function ItemList({ onItemChanged }: Props) {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<ItemStatus | 'all'>('all');
|
||||
const [search, setSearch] = useState('');
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [sortCol, setSortCol] = useState('created_at');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [visibleCols, setVisibleCols] = useState<Set<ColumnKey>>(loadVisibleCols);
|
||||
const [colMenuOpen, setColMenuOpen] = useState(false);
|
||||
const [lightboxPhoto, setLightboxPhoto] = useState<string | null>(null);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [bulkCategory, setBulkCategory] = useState('');
|
||||
const [bulkLocation, setBulkLocation] = useState('');
|
||||
const [bulkStatus, setBulkStatus] = useState('');
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [locations, setLocations] = useState<string[]>([]);
|
||||
const [bulkFlagged, setBulkFlagged] = useState('');
|
||||
const [flaggedFilter, setFlaggedFilter] = useState(false);
|
||||
const [bulkLoading, setBulkLoading] = useState(false);
|
||||
const prevFilterRef = useRef({ statusFilter, search });
|
||||
|
||||
const fetchItems = useCallback(() => {
|
||||
const params: Record<string, string> = { sort: sortCol, order: sortOrder };
|
||||
if (statusFilter !== 'all') params.status = statusFilter;
|
||||
if (search.trim()) params.search = search.trim();
|
||||
if (flaggedFilter) params.flagged = '1';
|
||||
api.listItems(params).then(setItems);
|
||||
}, [statusFilter, search, sortCol, sortOrder, flaggedFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
// Clear selection when filters/search change
|
||||
useEffect(() => {
|
||||
const prev = prevFilterRef.current;
|
||||
if (prev.statusFilter !== statusFilter || prev.search !== search) {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
prevFilterRef.current = { statusFilter, search };
|
||||
}, [statusFilter, search]);
|
||||
|
||||
// Fetch suggestions when selection starts
|
||||
useEffect(() => {
|
||||
if (selectedIds.size > 0 && categories.length === 0) {
|
||||
api.getCategories().then(setCategories);
|
||||
api.getLocations().then(setLocations);
|
||||
}
|
||||
}, [selectedIds.size, categories.length]);
|
||||
|
||||
const handleSort = (col: ColumnDef) => {
|
||||
if (!col.sortKey) return;
|
||||
if (sortCol === col.sortKey) {
|
||||
setSortOrder(o => o === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortCol(col.sortKey);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCol = (key: ColumnKey) => {
|
||||
setVisibleCols(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(key)) {
|
||||
// Don't allow hiding the last column
|
||||
if (next.size > 1) next.delete(key);
|
||||
} else {
|
||||
next.add(key);
|
||||
}
|
||||
saveVisibleCols(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleItemUpdated = () => {
|
||||
fetchItems();
|
||||
onItemChanged();
|
||||
};
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === items.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(items.map(i => i.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const toggleFlagged = async (item: Item) => {
|
||||
await api.updateItem(item.id, { flagged: item.flagged ? 0 : 1 });
|
||||
fetchItems();
|
||||
onItemChanged();
|
||||
};
|
||||
|
||||
const handleBulkApply = async () => {
|
||||
const updates: Record<string, string | number> = {};
|
||||
if (bulkCategory) updates.category = bulkCategory;
|
||||
if (bulkLocation) updates.location = bulkLocation;
|
||||
if (bulkStatus) updates.status = bulkStatus;
|
||||
if (bulkFlagged !== '') updates.flagged = Number(bulkFlagged);
|
||||
if (Object.keys(updates).length === 0) return;
|
||||
|
||||
setBulkLoading(true);
|
||||
try {
|
||||
await api.bulkUpdateItems([...selectedIds], updates);
|
||||
setSelectedIds(new Set());
|
||||
setBulkCategory('');
|
||||
setBulkLocation('');
|
||||
setBulkStatus('');
|
||||
setBulkFlagged('');
|
||||
fetchItems();
|
||||
onItemChanged();
|
||||
} finally {
|
||||
setBulkLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeCols = COLUMNS.filter(c => visibleCols.has(c.key));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Filters + column toggle */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex bg-gray-100 dark:bg-gray-800 rounded-md p-0.5 text-xs">
|
||||
{ALL_STATUSES.map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setStatusFilter(s)}
|
||||
className={`px-2.5 py-1 rounded ${statusFilter === s ? 'bg-white dark:bg-gray-700 shadow-sm font-medium text-gray-900 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}`}
|
||||
>
|
||||
{s === 'all' ? 'All' : STATUS_LABELS[s]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => setFlaggedFilter(f => !f)}
|
||||
className={`text-xs px-2.5 py-1 rounded-full border ${flaggedFilter ? 'bg-red-50 border-red-300 text-red-700 font-medium dark:bg-red-900/30 dark:border-red-700 dark:text-red-400' : 'border-gray-200 dark:border-gray-700 text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300'}`}
|
||||
>
|
||||
Flagged
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* Column toggle */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setColMenuOpen(o => !o)}
|
||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 border border-gray-200 dark:border-gray-700 rounded-md px-2.5 py-1.5 bg-white dark:bg-gray-900"
|
||||
>
|
||||
Columns
|
||||
</button>
|
||||
{colMenuOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-30" onClick={() => setColMenuOpen(false)} />
|
||||
<div className="absolute right-0 top-full mt-1 z-40 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1 w-40">
|
||||
{COLUMNS.map(col => (
|
||||
<label
|
||||
key={col.key}
|
||||
className="flex items-center gap-2 px-3 py-1.5 text-xs text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleCols.has(col.key)}
|
||||
onChange={() => toggleCol(col.key)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
{col.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
placeholder="Search..."
|
||||
className="text-sm outline-none border border-gray-200 dark:border-gray-700 rounded-md px-2.5 py-1.5 bg-white dark:bg-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center gap-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg px-4 py-2 text-sm">
|
||||
<span className="font-medium text-blue-800 dark:text-blue-300 whitespace-nowrap">{selectedIds.size} selected</span>
|
||||
<AutocompleteInput
|
||||
value={bulkCategory}
|
||||
onChange={setBulkCategory}
|
||||
suggestions={categories}
|
||||
placeholder="Category"
|
||||
className="text-sm border border-gray-200 dark:border-gray-700 rounded-md px-2 py-1 w-36 bg-white dark:bg-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
<AutocompleteInput
|
||||
value={bulkLocation}
|
||||
onChange={setBulkLocation}
|
||||
suggestions={locations}
|
||||
placeholder="Location"
|
||||
className="text-sm border border-gray-200 dark:border-gray-700 rounded-md px-2 py-1 w-36 bg-white dark:bg-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-500"
|
||||
/>
|
||||
<select
|
||||
value={bulkStatus}
|
||||
onChange={e => setBulkStatus(e.target.value)}
|
||||
className="text-sm border border-gray-200 dark:border-gray-700 rounded-md px-2 py-1 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="">Status...</option>
|
||||
{Object.entries(STATUS_LABELS).map(([val, label]) => (
|
||||
<option key={val} value={val}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
value={bulkFlagged}
|
||||
onChange={e => setBulkFlagged(e.target.value)}
|
||||
className="text-sm border border-gray-200 dark:border-gray-700 rounded-md px-2 py-1 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
<option value="">Flag...</option>
|
||||
<option value="1">Flagged</option>
|
||||
<option value="0">Unflagged</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleBulkApply}
|
||||
disabled={bulkLoading || (!bulkCategory && !bulkLocation && !bulkStatus && bulkFlagged === '')}
|
||||
className="px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-medium hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{bulkLoading ? 'Applying...' : 'Apply'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
className="text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 text-xs ml-auto"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{items.length === 0 ? (
|
||||
<div className="text-sm text-gray-400 text-center py-12">
|
||||
No items yet. Add one above!
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-gray-800 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
<th className="px-3 py-2 w-8">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={items.length > 0 && selectedIds.size === items.length}
|
||||
onChange={toggleSelectAll}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</th>
|
||||
{activeCols.map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`px-3 py-2 font-medium ${col.align === 'right' ? 'text-right' : ''} ${col.sortKey ? 'cursor-pointer select-none hover:text-gray-700 dark:hover:text-gray-300' : ''}`}
|
||||
onClick={() => handleSort(col)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{col.label}
|
||||
{col.sortKey && sortCol === col.sortKey && (
|
||||
<span className="text-gray-400 dark:text-gray-500">{sortOrder === 'asc' ? '↑' : '↓'}</span>
|
||||
)}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
<tr
|
||||
key={item.id}
|
||||
onClick={() => setSelectedId(item.id)}
|
||||
className={`border-b border-gray-50 dark:border-gray-800 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer group/row ${selectedIds.has(item.id) ? 'bg-blue-50 dark:bg-blue-900/30' : ''}`}
|
||||
>
|
||||
<td className="px-3 py-2 w-8" onClick={e => e.stopPropagation()}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedIds.has(item.id)}
|
||||
onChange={() => toggleSelect(item.id)}
|
||||
className="rounded border-gray-300 dark:border-gray-600"
|
||||
/>
|
||||
</td>
|
||||
{activeCols.map(col => (
|
||||
<td
|
||||
key={col.key}
|
||||
className={`px-3 py-2 whitespace-nowrap ${col.align === 'right' ? 'text-right' : ''} ${col.key === 'name' ? 'font-medium text-gray-900 dark:text-gray-100' : 'text-gray-500 dark:text-gray-400'}`}
|
||||
>
|
||||
<CellValue col={col.key} item={item} onThumbnailClick={setLightboxPhoto} onToggleFlagged={toggleFlagged} />
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Detail modal */}
|
||||
{selectedId && (
|
||||
<ItemDetail
|
||||
itemId={selectedId}
|
||||
onClose={() => setSelectedId(null)}
|
||||
onUpdated={handleItemUpdated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Lightbox for list thumbnail click */}
|
||||
{lightboxPhoto && (
|
||||
<Lightbox
|
||||
photos={[{ stored_name: lightboxPhoto, filename: '' }]}
|
||||
initialIndex={0}
|
||||
onClose={() => setLightboxPhoto(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CellValue({ col, item, onThumbnailClick, onToggleFlagged }: { col: ColumnKey; item: Item; onThumbnailClick?: (storedName: string) => void; onToggleFlagged?: (item: Item) => void }) {
|
||||
switch (col) {
|
||||
case 'label_id':
|
||||
return <span className="font-mono text-gray-400 dark:text-gray-500">{item.label_id}</span>;
|
||||
case 'thumbnail':
|
||||
return item.thumbnail ? (
|
||||
<img
|
||||
src={thumbUrl(item.thumbnail)}
|
||||
alt=""
|
||||
className="w-8 h-8 object-cover rounded border border-gray-200 dark:border-gray-700 cursor-zoom-in"
|
||||
onClick={e => { e.stopPropagation(); onThumbnailClick?.(item.thumbnail!); }}
|
||||
onError={e => { e.currentTarget.src = uploadUrl(item.thumbnail!); }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded border border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800" />
|
||||
);
|
||||
case 'name':
|
||||
return <>{item.name}</>;
|
||||
case 'category':
|
||||
return <>{item.category || '—'}</>;
|
||||
case 'location':
|
||||
return <>{item.location || '—'}</>;
|
||||
case 'purchase_price':
|
||||
return <>{formatSEK(item.purchase_price)}</>;
|
||||
case 'purchase_date':
|
||||
return <>{formatDate(item.purchase_date)}</>;
|
||||
case 'status':
|
||||
return <StatusBadge status={item.status} />;
|
||||
case 'flagged':
|
||||
return (
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); onToggleFlagged?.(item); }}
|
||||
className={`text-base leading-none ${item.flagged ? 'opacity-100' : 'opacity-0 group-hover/row:opacity-30'} hover:!opacity-100 transition-opacity`}
|
||||
title={item.flagged ? 'Unflag' : 'Flag for removal'}
|
||||
>
|
||||
{'\u{1F6A9}'}
|
||||
</button>
|
||||
);
|
||||
case 'created_at':
|
||||
return <span className="text-gray-400 dark:text-gray-500">{formatDate(item.created_at)}</span>;
|
||||
}
|
||||
}
|
||||
83
client/src/components/Lightbox.tsx
Normal file
83
client/src/components/Lightbox.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { largeUrl, uploadUrl } from '../api';
|
||||
|
||||
interface LightboxProps {
|
||||
photos: { stored_name: string; filename: string }[];
|
||||
initialIndex: number;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function Lightbox({ photos, initialIndex, onClose }: LightboxProps) {
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setIndex(i => (i > 0 ? i - 1 : photos.length - 1));
|
||||
}, [photos.length]);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setIndex(i => (i < photos.length - 1 ? i + 1 : 0));
|
||||
}, [photos.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'ArrowLeft') goPrev();
|
||||
if (e.key === 'ArrowRight') goNext();
|
||||
};
|
||||
document.addEventListener('keydown', handler);
|
||||
return () => document.removeEventListener('keydown', handler);
|
||||
}, [onClose, goPrev, goNext]);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => { document.body.style.overflow = ''; };
|
||||
}, []);
|
||||
|
||||
const photo = photos[index];
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/90" onClick={onClose}>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 text-white/70 hover:text-white text-3xl z-10"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
{/* Navigation arrows */}
|
||||
{photos.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); goPrev(); }}
|
||||
className="absolute left-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white text-5xl z-10 select-none"
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<button
|
||||
onClick={e => { e.stopPropagation(); goNext(); }}
|
||||
className="absolute right-4 top-1/2 -translate-y-1/2 text-white/70 hover:text-white text-5xl z-10 select-none"
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Image */}
|
||||
<img
|
||||
src={largeUrl(photo.stored_name)}
|
||||
alt={photo.filename}
|
||||
className="max-h-[90vh] max-w-[90vw] object-contain"
|
||||
onClick={e => e.stopPropagation()}
|
||||
onError={e => { e.currentTarget.src = uploadUrl(photo.stored_name); }}
|
||||
/>
|
||||
|
||||
{/* Counter */}
|
||||
{photos.length > 1 && (
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/70 text-sm">
|
||||
{index + 1} / {photos.length}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
108
client/src/components/QuickEntry.tsx
Normal file
108
client/src/components/QuickEntry.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { api } from '../api';
|
||||
import AutocompleteInput from './AutocompleteInput';
|
||||
|
||||
interface Props {
|
||||
onItemAdded: () => void;
|
||||
}
|
||||
|
||||
export default function QuickEntry({ onItemAdded }: Props) {
|
||||
const [name, setName] = useState('');
|
||||
const [category, setCategory] = useState('');
|
||||
const [location, setLocation] = useState('');
|
||||
const [price, setPrice] = useState('');
|
||||
const [categories, setCategories] = useState<string[]>([]);
|
||||
const [locations, setLocations] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
const nameRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getCategories().then(setCategories);
|
||||
api.getLocations().then(setLocations);
|
||||
}, []);
|
||||
|
||||
const refreshSuggestions = () => {
|
||||
api.getCategories().then(setCategories);
|
||||
api.getLocations().then(setLocations);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
e?.preventDefault();
|
||||
if (!name.trim() || submitting) return;
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const item = await api.createItem({
|
||||
name: name.trim(),
|
||||
category: category.trim() || undefined,
|
||||
location: location.trim() || undefined,
|
||||
purchase_price: price.trim() ? Number(price) : undefined,
|
||||
});
|
||||
setName('');
|
||||
setCategory('');
|
||||
setLocation('');
|
||||
setPrice('');
|
||||
nameRef.current?.focus();
|
||||
setToast(`Added "${item.name}"`);
|
||||
setTimeout(() => setToast(''), 2000);
|
||||
refreshSuggestions();
|
||||
onItemAdded();
|
||||
} catch (err) {
|
||||
setToast('Failed to add item');
|
||||
setTimeout(() => setToast(''), 2000);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="relative">
|
||||
<div className="flex items-center gap-2 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg px-3 py-2 shadow-sm">
|
||||
<input
|
||||
ref={nameRef}
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Item name..."
|
||||
className="flex-1 min-w-0 text-sm outline-none bg-transparent placeholder:text-gray-400 dark:placeholder:text-gray-500 dark:text-gray-100"
|
||||
autoFocus
|
||||
/>
|
||||
<AutocompleteInput
|
||||
value={category}
|
||||
onChange={setCategory}
|
||||
suggestions={categories}
|
||||
placeholder="Category"
|
||||
className="w-32 text-sm outline-none bg-gray-50 dark:bg-gray-800 rounded px-2 py-1 placeholder:text-gray-400 dark:placeholder:text-gray-500 dark:text-gray-100"
|
||||
/>
|
||||
<AutocompleteInput
|
||||
value={location}
|
||||
onChange={setLocation}
|
||||
suggestions={locations}
|
||||
placeholder="Location"
|
||||
className="w-32 text-sm outline-none bg-gray-50 dark:bg-gray-800 rounded px-2 py-1 placeholder:text-gray-400 dark:placeholder:text-gray-500 dark:text-gray-100"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
step="1"
|
||||
value={price}
|
||||
onChange={e => setPrice(e.target.value)}
|
||||
placeholder="SEK"
|
||||
className="w-20 text-sm outline-none bg-gray-50 dark:bg-gray-800 rounded px-2 py-1 placeholder:text-gray-400 dark:placeholder:text-gray-500 text-right dark:text-gray-100"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!name.trim() || submitting}
|
||||
className="text-sm font-medium px-3 py-1.5 bg-gray-900 dark:bg-gray-100 text-white dark:text-gray-900 rounded-md hover:bg-gray-800 dark:hover:bg-gray-200 disabled:opacity-40 disabled:cursor-not-allowed whitespace-nowrap"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
{toast && (
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 bg-gray-900 text-white text-xs rounded-md shadow-lg animate-fade-in">
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
10
client/src/components/StatusBadge.tsx
Normal file
10
client/src/components/StatusBadge.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ItemStatus } from '../types';
|
||||
import { STATUS_LABELS, STATUS_COLORS } from '../types';
|
||||
|
||||
export default function StatusBadge({ status }: { status: ItemStatus }) {
|
||||
return (
|
||||
<span className={`inline-block text-xs font-medium px-2 py-0.5 rounded-full ${STATUS_COLORS[status]}`}>
|
||||
{STATUS_LABELS[status]}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
2
client/src/index.css
Normal file
2
client/src/index.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
106
client/src/pages/DeletedItemsPage.tsx
Normal file
106
client/src/pages/DeletedItemsPage.tsx
Normal file
@@ -0,0 +1,106 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { api, thumbUrl, uploadUrl } from '../api';
|
||||
import type { Item } from '../types';
|
||||
|
||||
function formatDate(raw: string): string {
|
||||
if (!raw) return '—';
|
||||
const d = raw.slice(0, 10);
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(d) ? d : '—';
|
||||
}
|
||||
|
||||
export default function DeletedItemsPage() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
const [confirmId, setConfirmId] = useState<string | null>(null);
|
||||
|
||||
const fetchItems = useCallback(() => {
|
||||
api.listItems({ deleted: '1', sort: 'deleted_at', order: 'desc' }).then(setItems);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems();
|
||||
}, [fetchItems]);
|
||||
|
||||
const handleRestore = async (id: string) => {
|
||||
await api.restoreItem(id);
|
||||
fetchItems();
|
||||
};
|
||||
|
||||
const handlePermanentDelete = async (id: string) => {
|
||||
if (confirmId !== id) {
|
||||
setConfirmId(id);
|
||||
return;
|
||||
}
|
||||
await api.permanentDeleteItem(id);
|
||||
setConfirmId(null);
|
||||
fetchItems();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Deleted Items</h2>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<div className="text-sm text-gray-400 text-center py-12">
|
||||
No deleted items.
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-100 dark:border-gray-800 text-left text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">
|
||||
<th className="px-3 py-2 font-medium">ID</th>
|
||||
<th className="px-3 py-2 font-medium">Image</th>
|
||||
<th className="px-3 py-2 font-medium">Name</th>
|
||||
<th className="px-3 py-2 font-medium">Category</th>
|
||||
<th className="px-3 py-2 font-medium">Deleted</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map(item => (
|
||||
<tr key={item.id} className="border-b border-gray-50 dark:border-gray-800">
|
||||
<td className="px-3 py-2 font-mono text-gray-400 dark:text-gray-500">{item.label_id}</td>
|
||||
<td className="px-3 py-2">
|
||||
{item.thumbnail ? (
|
||||
<img
|
||||
src={thumbUrl(item.thumbnail)}
|
||||
alt=""
|
||||
className="w-8 h-8 object-cover rounded border border-gray-200 dark:border-gray-700"
|
||||
onError={e => { e.currentTarget.src = uploadUrl(item.thumbnail!); }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-8 h-8 rounded border border-gray-100 dark:border-gray-800 bg-gray-50 dark:bg-gray-800" />
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-900 dark:text-gray-100">{item.name}</td>
|
||||
<td className="px-3 py-2 text-gray-500 dark:text-gray-400">{item.category || '—'}</td>
|
||||
<td className="px-3 py-2 text-gray-400 dark:text-gray-500 whitespace-nowrap">{formatDate(item.deleted_at || '')}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleRestore(item.id)}
|
||||
className="text-xs font-medium text-green-600 dark:text-green-400 hover:text-green-800 dark:hover:text-green-300 px-2 py-1 rounded hover:bg-green-50 dark:hover:bg-green-900/30"
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePermanentDelete(item.id)}
|
||||
className={`text-xs font-medium px-2 py-1 rounded ${
|
||||
confirmId === item.id
|
||||
? 'bg-red-600 text-white'
|
||||
: 'text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/30'
|
||||
}`}
|
||||
>
|
||||
{confirmId === item.id ? 'Confirm' : 'Delete Forever'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
18
client/src/pages/ItemsPage.tsx
Normal file
18
client/src/pages/ItemsPage.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import QuickEntry from '../components/QuickEntry';
|
||||
import ItemList from '../components/ItemList';
|
||||
|
||||
export default function ItemsPage() {
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
const handleItemAdded = useCallback(() => {
|
||||
setRefreshKey(k => k + 1);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<QuickEntry onItemAdded={handleItemAdded} />
|
||||
<ItemList key={refreshKey} onItemChanged={handleItemAdded} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
139
client/src/pages/StatsPage.tsx
Normal file
139
client/src/pages/StatsPage.tsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
AreaChart, Area, BarChart, Bar, PieChart, Pie, Cell,
|
||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||
} from 'recharts';
|
||||
import { api } from '../api';
|
||||
import type { StatsResponse } from '../types';
|
||||
import { STATUS_LABELS, type ItemStatus } from '../types';
|
||||
|
||||
const COLORS = ['#111827', '#6b7280', '#3b82f6', '#ef4444', '#f59e0b', '#10b981', '#8b5cf6', '#ec4899'];
|
||||
|
||||
export default function StatsPage() {
|
||||
const [stats, setStats] = useState<StatsResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.getStats().then(setStats);
|
||||
}, []);
|
||||
|
||||
if (!stats) return <div className="text-gray-500 dark:text-gray-400 text-sm">Loading stats...</div>;
|
||||
|
||||
const statusData = Object.entries(stats.countByStatus).map(([status, count]) => ({
|
||||
name: STATUS_LABELS[status as ItemStatus] || status.replace('_', ' '),
|
||||
value: count,
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary cards */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard label="Active Items" value={stats.totalActiveItems} />
|
||||
<StatCard
|
||||
label="Total Value"
|
||||
value={`${stats.totalActiveValue.toLocaleString('sv-SE', { minimumFractionDigits: 0, maximumFractionDigits: 2 })} kr`}
|
||||
/>
|
||||
<StatCard
|
||||
label="Total Items"
|
||||
value={Object.values(stats.countByStatus).reduce((a, b) => a + b, 0)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Categories"
|
||||
value={stats.valueByCategory.length}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Inflow / Outflow */}
|
||||
{stats.inflowOutflow.length > 0 && (
|
||||
<ChartCard title="Inflow / Outflow">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<AreaChart data={stats.inflowOutflow}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||
<XAxis dataKey="month" tick={{ fontSize: 11 }} />
|
||||
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ fontSize: 12 }} />
|
||||
<Area type="monotone" dataKey="added" name="Added" stroke="#111827" fill="#111827" fillOpacity={0.1} />
|
||||
<Area type="monotone" dataKey="removed" name="Removed" stroke="#ef4444" fill="#ef4444" fillOpacity={0.1} />
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{/* Items by Status */}
|
||||
{statusData.length > 0 && (
|
||||
<ChartCard title="Items by Status">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={statusData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={50}
|
||||
outerRadius={80}
|
||||
dataKey="value"
|
||||
label={({ name, value }) => `${name}: ${value}`}
|
||||
labelLine={false}
|
||||
style={{ fontSize: 11 }}
|
||||
>
|
||||
{statusData.map((_, i) => (
|
||||
<Cell key={i} fill={COLORS[i % COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ fontSize: 12 }} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{/* Value by Category */}
|
||||
{stats.valueByCategory.length > 0 && (
|
||||
<ChartCard title="Value by Category">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={stats.valueByCategory} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} tickFormatter={v => `${v} kr`} />
|
||||
<YAxis type="category" dataKey="category" tick={{ fontSize: 11 }} width={100} />
|
||||
<Tooltip contentStyle={{ fontSize: 12 }} formatter={(v: number) => [`${v.toLocaleString('sv-SE')} kr`, 'Value']} />
|
||||
<Bar dataKey="value" fill="#111827" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
)}
|
||||
|
||||
{/* Value by Location */}
|
||||
{stats.valueByLocation.length > 0 && (
|
||||
<ChartCard title="Value by Location">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<BarChart data={stats.valueByLocation} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#f3f4f6" />
|
||||
<XAxis type="number" tick={{ fontSize: 11 }} tickFormatter={v => `${v} kr`} />
|
||||
<YAxis type="category" dataKey="location" tick={{ fontSize: 11 }} width={100} />
|
||||
<Tooltip contentStyle={{ fontSize: 12 }} formatter={(v: number) => [`${v.toLocaleString('sv-SE')} kr`, 'Value']} />
|
||||
<Bar dataKey="value" fill="#6b7280" radius={[0, 4, 4, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</ChartCard>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">{label}</div>
|
||||
<div className="text-2xl font-bold text-gray-900 dark:text-gray-100 mt-1">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-900 rounded-lg border border-gray-200 dark:border-gray-700 p-4">
|
||||
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-3">{title}</h3>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
client/src/types.ts
Normal file
71
client/src/types.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export interface Item {
|
||||
id: string;
|
||||
label_id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
location: string;
|
||||
purchase_date: string;
|
||||
purchase_price: number | null;
|
||||
status: ItemStatus;
|
||||
notes: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
deleted_at: string | null;
|
||||
flagged: number;
|
||||
thumbnail?: string | null;
|
||||
files?: ItemFile[];
|
||||
}
|
||||
|
||||
export type ItemStatus = 'active' | 'stored' | 'lent_out' | 'sold' | 'lost' | 'trashed';
|
||||
|
||||
export interface ItemFile {
|
||||
id: string;
|
||||
item_id: string;
|
||||
file_type: 'photo' | 'receipt';
|
||||
filename: string;
|
||||
stored_name: string;
|
||||
mime_type: string;
|
||||
size_bytes: number;
|
||||
is_featured: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateItemBody {
|
||||
name: string;
|
||||
category?: string;
|
||||
location?: string;
|
||||
purchase_date?: string;
|
||||
purchase_price?: number | null;
|
||||
status?: ItemStatus;
|
||||
notes?: string;
|
||||
flagged?: number;
|
||||
}
|
||||
|
||||
export interface UpdateItemBody extends Partial<CreateItemBody> {}
|
||||
|
||||
export interface StatsResponse {
|
||||
totalActiveItems: number;
|
||||
totalActiveValue: number;
|
||||
countByStatus: Record<string, number>;
|
||||
valueByCategory: { category: string; value: number; count: number }[];
|
||||
valueByLocation: { location: string; value: number; count: number }[];
|
||||
inflowOutflow: { month: string; added: number; removed: number }[];
|
||||
}
|
||||
|
||||
export const STATUS_LABELS: Record<ItemStatus, string> = {
|
||||
active: 'Active',
|
||||
stored: 'Stored',
|
||||
lent_out: 'Lent Out',
|
||||
sold: 'Sold',
|
||||
lost: 'Lost',
|
||||
trashed: 'Trashed',
|
||||
};
|
||||
|
||||
export const STATUS_COLORS: Record<ItemStatus, string> = {
|
||||
active: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
|
||||
stored: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
|
||||
lent_out: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
|
||||
sold: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
|
||||
lost: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
|
||||
trashed: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
|
||||
};
|
||||
20
client/src/useTheme.ts
Normal file
20
client/src/useTheme.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
function getInitial(): boolean {
|
||||
const stored = localStorage.getItem('theme');
|
||||
if (stored) return stored === 'dark';
|
||||
return window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
}
|
||||
|
||||
export function useTheme() {
|
||||
const [dark, setDark] = useState(getInitial);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', dark);
|
||||
localStorage.setItem('theme', dark ? 'dark' : 'light');
|
||||
}, [dark]);
|
||||
|
||||
const toggle = () => setDark(d => !d);
|
||||
|
||||
return { dark, toggle };
|
||||
}
|
||||
11
client/tsconfig.json
Normal file
11
client/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"outDir": "dist",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
12
client/vite.config.ts
Normal file
12
client/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:3001',
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user