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:
2026-03-10 07:06:52 +01:00
commit 88e151f792
35 changed files with 8494 additions and 0 deletions

12
client/index.html Normal file
View 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
View 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
View 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
View 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}`;
}

View 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>
);
}

View 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">&times;</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'}
>
&#9733;
</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"
>
&times;
</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"
>
&times;
</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)}
/>
)}
</>
);
}

View 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>;
}
}

View 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"
>
&times;
</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"
>
&#8249;
</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"
>
&#8250;
</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>
);
}

View 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>
);
}

View 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
View File

@@ -0,0 +1,2 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));

10
client/src/main.tsx Normal file
View 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>,
);

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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
View 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
View 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',
},
},
});