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
.gitignore vendored Normal file
View File

@@ -0,0 +1,12 @@
node_modules/
dist/
server/data/
server/uploads/
data/
*.db
*.db-journal
*.db-shm
*.db-wal
.env
.DS_Store
.claude/

59
README.md Normal file
View File

@@ -0,0 +1,59 @@
# Fast Inventory
A quick-entry home inventory tool. Add items in seconds, track values, manage status, attach photos and receipts.
## Quick Start
```bash
npm install
npm run dev
```
Open http://localhost:5173
## Usage
**Add an item:** Type a name in the top bar and press Enter. That's it — 1 second.
**Enriched entry:** Name → Tab → Category (autocomplete) → Tab → Location (autocomplete) → Enter. About 3 seconds.
**Edit details:** Click any item row to open the detail modal. Add purchase price, date, notes, photos, receipts, or change status.
**Statuses:** Active, Lent Out, Sold, Lost, Trashed. Defaults to Active.
**Stats:** Click the Stats tab for value breakdowns, item counts, and inflow/outflow charts.
## Production
```bash
npm run build
npm start
```
Runs on http://localhost:3001 (single server, no separate frontend process).
Set a custom port with `PORT=8080 npm start`.
## Tech Stack
- **Backend:** Express + TypeScript + SQLite (better-sqlite3)
- **Frontend:** React + Vite + Tailwind CSS + Recharts
- **Storage:** SQLite database at `server/data/inventory.db`, uploads at `server/uploads/`
## Data
All data is local. The SQLite database and uploaded files live in the `server/` directory:
- `server/data/inventory.db` — database
- `server/uploads/` — photos and receipts
Back up these two paths to preserve your inventory.
## Scripts
| Command | Description |
|---|---|
| `npm run dev` | Start dev servers (frontend + backend) |
| `npm run build` | Build for production |
| `npm start` | Run production server |
| `npm run typecheck` | Type-check both packages |

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

5823
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

15
package.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "fast-inventory",
"private": true,
"workspaces": ["server", "client"],
"scripts": {
"dev": "concurrently -n server,client -c blue,green \"npm run dev -w server\" \"npm run dev -w client\"",
"build": "npm run build -w client && npm run build -w server",
"start": "npm run start -w server",
"typecheck": "npm run typecheck -w server && npm run typecheck -w client"
},
"devDependencies": {
"concurrently": "^9.1.0",
"typescript": "^5.7.0"
}
}

27
server/package.json Normal file
View File

@@ -0,0 +1,27 @@
{
"name": "server",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"better-sqlite3": "^11.7.0",
"cors": "^2.8.5",
"express": "^5.1.0",
"multer": "^1.4.5-lts.2",
"sharp": "^0.34.5",
"uuid": "^11.1.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.12",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/multer": "^1.4.12",
"@types/uuid": "^10.0.0",
"tsx": "^4.19.0"
}
}

156
server/src/db.ts Normal file
View File

@@ -0,0 +1,156 @@
import Database, { type Database as DatabaseType } from 'better-sqlite3';
import { mkdirSync } from 'fs';
import { dirname, join } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const DATA_DIR = join(__dirname, '..', 'data');
const DB_PATH = join(DATA_DIR, 'inventory.db');
mkdirSync(DATA_DIR, { recursive: true });
const db: DatabaseType = new Database(DB_PATH);
// Enable WAL mode for better concurrent read performance
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
// --- Schema: initial tables ---
db.exec(`
CREATE TABLE IF NOT EXISTS items (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT DEFAULT '',
location TEXT DEFAULT '',
purchase_date TEXT DEFAULT '',
purchase_price REAL DEFAULT NULL,
status TEXT DEFAULT 'active'
CHECK(status IN ('active','stored','lent_out','sold','lost','trashed')),
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_items_status ON items(status);
CREATE INDEX IF NOT EXISTS idx_items_category ON items(category);
CREATE INDEX IF NOT EXISTS idx_items_location ON items(location);
CREATE INDEX IF NOT EXISTS idx_items_created_at ON items(created_at);
CREATE TABLE IF NOT EXISTS item_files (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL REFERENCES items(id) ON DELETE CASCADE,
file_type TEXT NOT NULL CHECK(file_type IN ('photo','receipt')),
filename TEXT NOT NULL,
stored_name TEXT NOT NULL,
mime_type TEXT DEFAULT '',
size_bytes INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_item_files_item_id ON item_files(item_id);
`);
// --- Versioned migrations ---
// Each migration runs exactly once, tracked by schema_version.
// NEVER remove or reorder existing migrations. Only append new ones.
db.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at TEXT DEFAULT (datetime('now'))
);
`);
function getSchemaVersion(): number {
const row = db.prepare('SELECT MAX(version) as v FROM schema_migrations').get() as { v: number | null };
return row.v ?? 0;
}
function runMigration(version: number, description: string, fn: () => void) {
if (getSchemaVersion() >= version) return;
console.log(`Running migration ${version}: ${description}`);
db.transaction(() => {
fn();
db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(version);
})();
}
// For migrations that recreate tables referenced by foreign keys,
// foreign_keys must be OFF to avoid CASCADE deletes on DROP TABLE.
function runMigrationUnsafe(version: number, description: string, fn: () => void) {
if (getSchemaVersion() >= version) return;
console.log(`Running migration ${version}: ${description}`);
db.pragma('foreign_keys = OFF');
db.transaction(() => {
fn();
db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(version);
})();
db.pragma('foreign_keys = ON');
}
// Migration 1: Add label_id auto-incrementing column
runMigration(1, 'Add label_id column', () => {
const columns = db.prepare("PRAGMA table_info(items)").all() as { name: string }[];
if (columns.some(c => c.name === 'label_id')) return; // already in CREATE TABLE for fresh DBs
db.exec("ALTER TABLE items ADD COLUMN label_id INTEGER");
db.exec(`
UPDATE items SET label_id = (
SELECT COUNT(*) FROM items AS i2
WHERE i2.created_at <= items.created_at AND i2.id <= items.id
)
`);
db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_items_label_id ON items(label_id)");
});
// Migration 2: Add is_featured column to item_files
runMigration(2, 'Add is_featured column to item_files', () => {
const columns = db.prepare("PRAGMA table_info(item_files)").all() as { name: string }[];
if (columns.some(c => c.name === 'is_featured')) return;
db.exec("ALTER TABLE item_files ADD COLUMN is_featured INTEGER DEFAULT 0");
});
// Migration 3: Add deleted_at column for soft deletes
runMigration(3, 'Add deleted_at column to items', () => {
const columns = db.prepare("PRAGMA table_info(items)").all() as { name: string }[];
if (columns.some(c => c.name === 'deleted_at')) return;
db.exec("ALTER TABLE items ADD COLUMN deleted_at TEXT DEFAULT NULL");
});
// Migration 4: Add 'stored' status to items CHECK constraint
runMigrationUnsafe(4, 'Add stored status', () => {
// SQLite can't ALTER CHECK constraints, so we recreate the table
db.exec(`
CREATE TABLE items_new (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
category TEXT DEFAULT '',
location TEXT DEFAULT '',
purchase_date TEXT DEFAULT '',
purchase_price REAL DEFAULT NULL,
status TEXT DEFAULT 'active'
CHECK(status IN ('active','stored','lent_out','sold','lost','trashed')),
notes TEXT DEFAULT '',
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
label_id INTEGER,
deleted_at TEXT DEFAULT NULL
);
INSERT INTO items_new SELECT id, name, category, location, purchase_date, purchase_price, status, notes, created_at, updated_at, label_id, deleted_at FROM items;
DROP TABLE items;
ALTER TABLE items_new RENAME TO items;
CREATE INDEX IF NOT EXISTS idx_items_status ON items(status);
CREATE INDEX IF NOT EXISTS idx_items_category ON items(category);
CREATE INDEX IF NOT EXISTS idx_items_location ON items(location);
CREATE INDEX IF NOT EXISTS idx_items_created_at ON items(created_at);
CREATE UNIQUE INDEX IF NOT EXISTS idx_items_label_id ON items(label_id);
`);
});
// Migration 5: Add flagged column to items
runMigration(5, 'Add flagged column to items', () => {
const columns = db.prepare("PRAGMA table_info(items)").all() as { name: string }[];
if (columns.some(c => c.name === 'flagged')) return;
db.exec("ALTER TABLE items ADD COLUMN flagged INTEGER NOT NULL DEFAULT 0");
});
export default db;

41
server/src/index.ts Normal file
View File

@@ -0,0 +1,41 @@
import express from 'express';
import cors from 'cors';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { existsSync } from 'fs';
import itemsRouter from './routes/items.js';
import uploadsRouter, { createFileUploadRouter, createFileDeleteRouter } from './routes/uploads.js';
import statsRouter from './routes/stats.js';
import suggestionsRouter from './routes/suggestions.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
// API routes
app.get('/api/health', (_req, res) => {
res.json({ status: 'ok' });
});
app.use('/api/items', itemsRouter);
app.use('/api/items', createFileUploadRouter());
app.use('/api/files', createFileDeleteRouter());
app.use('/api/uploads', uploadsRouter);
app.use('/api/stats', statsRouter);
app.use('/api/suggestions', suggestionsRouter);
// Serve static client in production
const clientDist = join(__dirname, '..', '..', 'client', 'dist');
if (existsSync(clientDist)) {
app.use(express.static(clientDist));
app.get('/{*path}', (_req, res) => {
res.sendFile(join(clientDist, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`Server running on http://localhost:${PORT}`);
});

View File

@@ -0,0 +1,37 @@
import multer from 'multer';
import { mkdirSync } from 'fs';
import { join, dirname, extname } from 'path';
import { fileURLToPath } from 'url';
import { v4 as uuid } from 'uuid';
const __dirname = dirname(fileURLToPath(import.meta.url));
const UPLOADS_DIR = join(__dirname, '..', '..', 'uploads');
mkdirSync(UPLOADS_DIR, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, UPLOADS_DIR),
filename: (_req, file, cb) => {
const ext = extname(file.originalname);
cb(null, `${uuid()}${ext}`);
},
});
const ALLOWED_MIMES = [
'image/jpeg', 'image/png', 'image/webp', 'image/heic',
'application/pdf',
];
export const upload = multer({
storage,
limits: { fileSize: 20 * 1024 * 1024 }, // 20MB
fileFilter: (_req, file, cb) => {
if (ALLOWED_MIMES.includes(file.mimetype)) {
cb(null, true);
} else {
cb(new Error(`File type ${file.mimetype} not allowed`));
}
},
});
export { UPLOADS_DIR };

42
server/src/resize.ts Normal file
View File

@@ -0,0 +1,42 @@
import sharp from 'sharp';
import { join, parse } from 'path';
import { UPLOADS_DIR } from './middleware/upload.js';
interface VariantSpec {
suffix: string;
maxDimension: number;
}
const VARIANTS: VariantSpec[] = [
{ suffix: '_thumb', maxDimension: 300 },
{ suffix: '_medium', maxDimension: 768 },
{ suffix: '_large', maxDimension: 1920 },
];
const RESIZABLE_MIMES = new Set([
'image/jpeg', 'image/png', 'image/webp', 'image/heic',
]);
export function isResizable(mimeType: string): boolean {
return RESIZABLE_MIMES.has(mimeType);
}
export async function generateVariants(storedName: string): Promise<void> {
const { name, ext } = parse(storedName);
const sourcePath = join(UPLOADS_DIR, storedName);
for (const variant of VARIANTS) {
const outPath = join(UPLOADS_DIR, `${name}${variant.suffix}${ext}`);
await sharp(sourcePath)
.resize(variant.maxDimension, variant.maxDimension, {
fit: 'inside',
withoutEnlargement: true,
})
.toFile(outPath);
}
}
export function getVariantFilenames(storedName: string): string[] {
const { name, ext } = parse(storedName);
return VARIANTS.map(v => `${name}${v.suffix}${ext}`);
}

276
server/src/routes/items.ts Normal file
View File

@@ -0,0 +1,276 @@
import { Router } from 'express';
import { v4 as uuid } from 'uuid';
import { copyFileSync, unlinkSync } from 'fs';
import { join, parse } from 'path';
import db from '../db.js';
import { isResizable, generateVariants, getVariantFilenames } from '../resize.js';
import { UPLOADS_DIR } from '../middleware/upload.js';
import type { Item, ItemFile, CreateItemBody, UpdateItemBody } from '../types.js';
const router = Router();
// Bulk update items
router.patch('/bulk', (req, res) => {
const { ids, updates } = req.body as { ids: string[]; updates: { category?: string; location?: string; status?: string; flagged?: number } };
if (!Array.isArray(ids) || ids.length === 0) {
res.status(400).json({ error: 'ids array is required' });
return;
}
const fields: string[] = [];
const params: unknown[] = [];
if (updates.category !== undefined) { fields.push('category = ?'); params.push(updates.category.trim()); }
if (updates.location !== undefined) { fields.push('location = ?'); params.push(updates.location.trim()); }
if (updates.status !== undefined) { fields.push('status = ?'); params.push(updates.status); }
if (updates.flagged !== undefined) { fields.push('flagged = ?'); params.push(updates.flagged); }
if (fields.length === 0) {
res.json({ updated: 0 });
return;
}
fields.push("updated_at = datetime('now')");
const placeholders = ids.map(() => '?').join(', ');
const sql = `UPDATE items SET ${fields.join(', ')} WHERE id IN (${placeholders}) AND deleted_at IS NULL`;
const result = db.transaction(() => {
return db.prepare(sql).run(...params, ...ids);
})();
res.json({ updated: result.changes });
});
// List items with optional filters
router.get('/', (req, res) => {
const { status, category, location, search, sort = 'created_at', order = 'desc', limit = '50', offset = '0', deleted, flagged } = req.query as Record<string, string>;
let sql: string;
const params: unknown[] = [];
if (deleted === '1') {
// Show only soft-deleted items
sql = `SELECT items.*, (
SELECT stored_name FROM item_files
WHERE item_files.item_id = items.id AND item_files.file_type = 'photo'
ORDER BY is_featured DESC, created_at ASC LIMIT 1
) as thumbnail FROM items WHERE deleted_at IS NOT NULL`;
} else {
// Normal: exclude soft-deleted items
sql = `SELECT items.*, (
SELECT stored_name FROM item_files
WHERE item_files.item_id = items.id AND item_files.file_type = 'photo'
ORDER BY is_featured DESC, created_at ASC LIMIT 1
) as thumbnail FROM items WHERE deleted_at IS NULL`;
}
if (status) {
sql += ' AND status = ?';
params.push(status);
}
if (category) {
sql += ' AND category = ?';
params.push(category);
}
if (location) {
sql += ' AND location = ?';
params.push(location);
}
if (search) {
sql += ' AND (name LIKE ? OR notes LIKE ?)';
params.push(`%${search}%`, `%${search}%`);
}
if (flagged === '1') {
sql += ' AND flagged = 1';
}
const allowedSorts = ['label_id', 'name', 'category', 'location', 'purchase_price', 'purchase_date', 'status', 'created_at', 'updated_at', 'deleted_at', 'flagged'];
const sortCol = allowedSorts.includes(sort) ? sort : 'created_at';
const sortOrder = order === 'asc' ? 'ASC' : 'DESC';
sql += ` ORDER BY ${sortCol} ${sortOrder}`;
sql += ' LIMIT ? OFFSET ?';
params.push(Number(limit), Number(offset));
const items = db.prepare(sql).all(...params) as Item[];
res.json(items);
});
// Get single item with files
router.get('/:id', (req, res) => {
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item | undefined;
if (!item) {
res.status(404).json({ error: 'Item not found' });
return;
}
const files = db.prepare('SELECT * FROM item_files WHERE item_id = ? ORDER BY created_at DESC').all(req.params.id);
res.json({ ...item, files });
});
// Create item
router.post('/', (req, res) => {
const body = req.body as CreateItemBody;
if (!body.name?.trim()) {
res.status(400).json({ error: 'Name is required' });
return;
}
const id = uuid();
const { nextId } = db.prepare('SELECT COALESCE(MAX(label_id), 0) + 1 AS nextId FROM items').get() as { nextId: number };
const stmt = db.prepare(`
INSERT INTO items (id, label_id, name, category, location, purchase_date, purchase_price, status, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
id,
nextId,
body.name.trim(),
body.category?.trim() || '',
body.location?.trim() || '',
body.purchase_date || '',
body.purchase_price ?? null,
body.status || 'active',
body.notes?.trim() || '',
);
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(id) as Item;
res.status(201).json(item);
});
// Duplicate item (with files)
router.post('/:id/duplicate', async (req, res) => {
const source = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item | undefined;
if (!source) {
res.status(404).json({ error: 'Item not found' });
return;
}
const newId = uuid();
const { nextId } = db.prepare('SELECT COALESCE(MAX(label_id), 0) + 1 AS nextId FROM items').get() as { nextId: number };
db.prepare(`
INSERT INTO items (id, label_id, name, category, location, purchase_date, purchase_price, status, notes, flagged)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
newId,
nextId,
`Copy of ${source.name}`,
source.category,
source.location,
source.purchase_date,
source.purchase_price,
source.status,
source.notes,
source.flagged,
);
// Copy files
const sourceFiles = db.prepare('SELECT * FROM item_files WHERE item_id = ?').all(req.params.id) as ItemFile[];
const insertFile = db.prepare(`
INSERT INTO item_files (id, item_id, file_type, filename, stored_name, mime_type, size_bytes, is_featured)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const file of sourceFiles) {
const fileId = uuid();
const { ext } = parse(file.stored_name);
const newStoredName = `${uuid()}${ext}`;
try {
copyFileSync(join(UPLOADS_DIR, file.stored_name), join(UPLOADS_DIR, newStoredName));
} catch {
continue; // skip if source file is missing
}
insertFile.run(fileId, newId, file.file_type, file.filename, newStoredName, file.mime_type, file.size_bytes, file.is_featured);
if (isResizable(file.mime_type)) {
await generateVariants(newStoredName);
}
}
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(newId) as Item;
res.status(201).json(item);
});
// Update item
router.put('/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item | undefined;
if (!existing) {
res.status(404).json({ error: 'Item not found' });
return;
}
const body = req.body as UpdateItemBody;
const fields: string[] = [];
const params: unknown[] = [];
if (body.name !== undefined) { fields.push('name = ?'); params.push(body.name.trim()); }
if (body.category !== undefined) { fields.push('category = ?'); params.push(body.category.trim()); }
if (body.location !== undefined) { fields.push('location = ?'); params.push(body.location.trim()); }
if (body.purchase_date !== undefined) { fields.push('purchase_date = ?'); params.push(body.purchase_date); }
if (body.purchase_price !== undefined) { fields.push('purchase_price = ?'); params.push(body.purchase_price); }
if (body.status !== undefined) { fields.push('status = ?'); params.push(body.status); }
if (body.notes !== undefined) { fields.push('notes = ?'); params.push(body.notes.trim()); }
if (body.flagged !== undefined) { fields.push('flagged = ?'); params.push(body.flagged); }
if (fields.length === 0) {
res.json(existing);
return;
}
fields.push("updated_at = datetime('now')");
params.push(req.params.id);
db.prepare(`UPDATE items SET ${fields.join(', ')} WHERE id = ?`).run(...params);
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item;
res.json(item);
});
// Soft delete item
router.delete('/:id', (req, res) => {
const existing = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item | undefined;
if (!existing) {
res.status(404).json({ error: 'Item not found' });
return;
}
db.prepare("UPDATE items SET deleted_at = datetime('now') WHERE id = ?").run(req.params.id);
res.status(204).end();
});
// Restore soft-deleted item
router.post('/:id/restore', (req, res) => {
const existing = db.prepare('SELECT * FROM items WHERE id = ? AND deleted_at IS NOT NULL').get(req.params.id) as Item | undefined;
if (!existing) {
res.status(404).json({ error: 'Item not found or not deleted' });
return;
}
db.prepare('UPDATE items SET deleted_at = NULL WHERE id = ?').run(req.params.id);
const item = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item;
res.json(item);
});
// Permanently delete item and its files
router.delete('/:id/permanent', (req, res) => {
const existing = db.prepare('SELECT * FROM items WHERE id = ?').get(req.params.id) as Item | undefined;
if (!existing) {
res.status(404).json({ error: 'Item not found' });
return;
}
// Delete all files from disk
const files = db.prepare('SELECT * FROM item_files WHERE item_id = ?').all(req.params.id) as ItemFile[];
for (const file of files) {
try { unlinkSync(join(UPLOADS_DIR, file.stored_name)); } catch { /* ignore */ }
for (const variant of getVariantFilenames(file.stored_name)) {
try { unlinkSync(join(UPLOADS_DIR, variant)); } catch { /* ignore */ }
}
}
db.prepare('DELETE FROM items WHERE id = ?').run(req.params.id);
res.status(204).end();
});
export default router;

View File

@@ -0,0 +1,79 @@
import { Router } from 'express';
import db from '../db.js';
import type { StatsResponse } from '../types.js';
const router = Router();
router.get('/', (_req, res) => {
const totalActive = db.prepare(
"SELECT COUNT(*) as count FROM items WHERE status = 'active' AND deleted_at IS NULL"
).get() as { count: number };
const totalValue = db.prepare(
"SELECT COALESCE(SUM(purchase_price), 0) as total FROM items WHERE status = 'active' AND deleted_at IS NULL"
).get() as { total: number };
const statusCounts = db.prepare(
'SELECT status, COUNT(*) as count FROM items WHERE deleted_at IS NULL GROUP BY status'
).all() as { status: string; count: number }[];
const valueByCategory = db.prepare(
`SELECT category, COALESCE(SUM(purchase_price), 0) as value, COUNT(*) as count
FROM items WHERE status = 'active' AND deleted_at IS NULL AND category != ''
GROUP BY category ORDER BY value DESC`
).all() as { category: string; value: number; count: number }[];
const valueByLocation = db.prepare(
`SELECT location, COALESCE(SUM(purchase_price), 0) as value, COUNT(*) as count
FROM items WHERE status = 'active' AND deleted_at IS NULL AND location != ''
GROUP BY location ORDER BY value DESC`
).all() as { location: string; value: number; count: number }[];
const inflowOutflow = db.prepare(
`SELECT
strftime('%Y-%m', created_at) as month,
COUNT(*) as added,
0 as removed
FROM items WHERE deleted_at IS NULL
GROUP BY month
UNION ALL
SELECT
strftime('%Y-%m', updated_at) as month,
0 as added,
COUNT(*) as removed
FROM items
WHERE deleted_at IS NULL AND status IN ('sold', 'trashed', 'lost')
GROUP BY month
ORDER BY month`
).all() as { month: string; added: number; removed: number }[];
// Merge inflow/outflow by month
const monthMap = new Map<string, { month: string; added: number; removed: number }>();
for (const row of inflowOutflow) {
const existing = monthMap.get(row.month);
if (existing) {
existing.added += row.added;
existing.removed += row.removed;
} else {
monthMap.set(row.month, { ...row });
}
}
const countByStatus: Record<string, number> = {};
for (const row of statusCounts) {
countByStatus[row.status] = row.count;
}
const stats: StatsResponse = {
totalActiveItems: totalActive.count,
totalActiveValue: totalValue.total,
countByStatus,
valueByCategory,
valueByLocation,
inflowOutflow: Array.from(monthMap.values()).sort((a, b) => a.month.localeCompare(b.month)),
};
res.json(stats);
});
export default router;

View File

@@ -0,0 +1,36 @@
import { Router } from 'express';
import db from '../db.js';
const router = Router();
router.get('/categories', (req, res) => {
const q = (req.query.q as string) || '';
let rows: { category: string }[];
if (q) {
rows = db.prepare(
"SELECT DISTINCT category FROM items WHERE deleted_at IS NULL AND category != '' AND category LIKE ? ORDER BY category"
).all(`${q}%`) as { category: string }[];
} else {
rows = db.prepare(
"SELECT DISTINCT category FROM items WHERE deleted_at IS NULL AND category != '' ORDER BY category"
).all() as { category: string }[];
}
res.json(rows.map(r => r.category));
});
router.get('/locations', (req, res) => {
const q = (req.query.q as string) || '';
let rows: { location: string }[];
if (q) {
rows = db.prepare(
"SELECT DISTINCT location FROM items WHERE deleted_at IS NULL AND location != '' AND location LIKE ? ORDER BY location"
).all(`${q}%`) as { location: string }[];
} else {
rows = db.prepare(
"SELECT DISTINCT location FROM items WHERE deleted_at IS NULL AND location != '' ORDER BY location"
).all() as { location: string }[];
}
res.json(rows.map(r => r.location));
});
export default router;

View File

@@ -0,0 +1,109 @@
import { Router } from 'express';
import { v4 as uuid } from 'uuid';
import { unlinkSync } from 'fs';
import { join } from 'path';
import db from '../db.js';
import { upload, UPLOADS_DIR } from '../middleware/upload.js';
import { isResizable, generateVariants, getVariantFilenames } from '../resize.js';
import type { ItemFile } from '../types.js';
const router = Router();
// Serve uploaded files (originals + variants)
router.get('/:storedName', (req, res) => {
const filePath = join(UPLOADS_DIR, req.params.storedName);
res.sendFile(filePath, (err) => {
if (err) res.status(404).json({ error: 'File not found' });
});
});
export default router;
export function createFileUploadRouter() {
const fileRouter = Router({ mergeParams: true });
// Upload files for an item
fileRouter.post('/:itemId/files', upload.array('files', 10), async (req, res) => {
const itemId = req.params.itemId;
const item = db.prepare('SELECT id FROM items WHERE id = ?').get(itemId);
if (!item) {
res.status(404).json({ error: 'Item not found' });
return;
}
const fileType = (req.body.type as string) || 'photo';
const files = (req.files as Express.Multer.File[]) || [];
const results: ItemFile[] = [];
const stmt = db.prepare(`
INSERT INTO item_files (id, item_id, file_type, filename, stored_name, mime_type, size_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?)
`);
for (const file of files) {
const id = uuid();
stmt.run(id, itemId, fileType, file.originalname, file.filename, file.mimetype, file.size);
if (isResizable(file.mimetype)) {
await generateVariants(file.filename);
}
const record = db.prepare('SELECT * FROM item_files WHERE id = ?').get(id) as ItemFile;
results.push(record);
}
res.status(201).json(results);
});
// Set featured photo for an item
fileRouter.put('/:itemId/files/:fileId/featured', (req, res) => {
const { itemId, fileId } = req.params;
const file = db.prepare(
'SELECT * FROM item_files WHERE id = ? AND item_id = ?'
).get(fileId, itemId) as ItemFile | undefined;
if (!file) {
res.status(404).json({ error: 'File not found' });
return;
}
db.transaction(() => {
db.prepare(
"UPDATE item_files SET is_featured = 0 WHERE item_id = ? AND file_type = 'photo'"
).run(itemId);
db.prepare(
'UPDATE item_files SET is_featured = 1 WHERE id = ?'
).run(fileId);
})();
res.json({ success: true });
});
return fileRouter;
}
export function createFileDeleteRouter() {
const deleteRouter = Router();
deleteRouter.delete('/:fileId', (req, res) => {
const file = db.prepare('SELECT * FROM item_files WHERE id = ?').get(req.params.fileId) as ItemFile | undefined;
if (!file) {
res.status(404).json({ error: 'File not found' });
return;
}
// Delete original
try { unlinkSync(join(UPLOADS_DIR, file.stored_name)); } catch { /* ignore */ }
// Delete all variants
for (const variant of getVariantFilenames(file.stored_name)) {
try { unlinkSync(join(UPLOADS_DIR, variant)); } catch { /* ignore */ }
}
db.prepare('DELETE FROM item_files WHERE id = ?').run(req.params.fileId);
res.status(204).end();
});
return deleteRouter;
}

51
server/src/types.ts Normal file
View File

@@ -0,0 +1,51 @@
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;
}
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 }[];
}

10
server/tsconfig.json Normal file
View File

@@ -0,0 +1,10 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

16
tsconfig.base.json Normal file
View File

@@ -0,0 +1,16 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
}
}