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