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