Jeder Sammlungseintrag hat jetzt eine Fehlteil-Liste: Teilenummer, Farbe, Anzahl und Notiz, abhakbar wenn das Teil nachgekauft ist. Ein Eintrag ohne Nummer geht auch - dann reicht die Notiz, denn beim Aufbauen kennt man die Nummer selten. Die Vollstaendigkeit pflegt sich dabei selbst: Der erste offene Eintrag setzt den Sammlungseintrag auf "Teile fehlen", der letzte erledigte zurueck auf "vollstaendig". Damit stimmt der Filter in der Sammlung, ohne dass man daran denken muss. Offene Fehlteile stehen als Badge an jedem Eintrag - in der Sammlung und auf der Set-Detailseite. Refs Phase 2 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
156 lines
6.3 KiB
TypeScript
156 lines
6.3 KiB
TypeScript
import type { Metadata } from 'next';
|
|
import Link from 'next/link';
|
|
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
|
|
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
|
import { setImageSrc } from '@/lib/images';
|
|
import { completenessLabel, statusLabel } from '@/lib/labels';
|
|
import { formatEuro } from '@/lib/money';
|
|
import {
|
|
countMissingSets,
|
|
countOpenMissingParts,
|
|
getCollectionStats,
|
|
listCollectionItems,
|
|
type CollectionFilters as Filters,
|
|
} from '@/lib/queries/collection';
|
|
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
|
import { requireUser } from '@/lib/session';
|
|
import type { CollectionStatus, Completeness } from '@/db/types';
|
|
|
|
export const metadata: Metadata = { title: 'Sammlung' };
|
|
|
|
type SearchParams = Promise<Record<string, string | string[] | undefined>>;
|
|
|
|
function single(value: string | string[] | undefined): string | undefined {
|
|
const raw = Array.isArray(value) ? value[0] : value;
|
|
return raw && raw.length > 0 ? raw : undefined;
|
|
}
|
|
|
|
export default async function SammlungPage({ searchParams }: { searchParams: SearchParams }) {
|
|
await requireUser();
|
|
const params = await searchParams;
|
|
|
|
const filters: Filters = {
|
|
q: single(params.q),
|
|
status: single(params.status) as CollectionStatus | undefined,
|
|
completeness: single(params.zustand) as Completeness | undefined,
|
|
locationId: single(params.standort),
|
|
manufacturerId: single(params.hersteller),
|
|
};
|
|
|
|
const items = listCollectionItems(filters);
|
|
const openParts = countOpenMissingParts(items.map((item) => item.id));
|
|
const stats = getCollectionStats();
|
|
const missing = countMissingSets();
|
|
|
|
const tiles = [
|
|
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
|
|
{ icon: Layers, label: 'Teile gesamt', value: formatNumber(stats.pieceCount) },
|
|
{ icon: Euro, label: 'Wert nach UVP', value: formatEuro(stats.retailValueCents) },
|
|
{ icon: PackageSearch, label: 'Fehlt noch', value: formatNumber(missing) },
|
|
] as const;
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
|
|
<p className="text-sm text-muted-foreground">Alles, was tatsächlich da ist.</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
{tiles.map((tile) => (
|
|
<Card key={tile.label}>
|
|
<CardContent className="space-y-1 p-4">
|
|
<tile.icon className="size-4 text-muted-foreground" aria-hidden />
|
|
<p className="text-xl font-semibold tabular-nums">{tile.value}</p>
|
|
<p className="text-xs text-muted-foreground">{tile.label}</p>
|
|
</CardContent>
|
|
</Card>
|
|
))}
|
|
</div>
|
|
|
|
{stats.byManufacturer.length > 0 ? (
|
|
<div className="flex flex-wrap gap-2">
|
|
{stats.byManufacturer.map((row) => (
|
|
<Badge key={row.name} variant="secondary">
|
|
{row.name}: {formatNumber(row.count)}
|
|
</Badge>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
|
|
<CollectionFilters
|
|
locations={listLocations().map((row) => ({ id: row.id, name: row.name }))}
|
|
manufacturers={listManufacturers().map((row) => ({ id: row.id, name: row.name }))}
|
|
/>
|
|
|
|
<p className="text-sm text-muted-foreground">
|
|
{pluralize(items.length, 'Eintrag', 'Einträge')}
|
|
</p>
|
|
|
|
{items.length === 0 ? (
|
|
<div className="space-y-3 rounded-lg border border-dashed border-border p-8 text-center">
|
|
<p className="text-sm text-muted-foreground">
|
|
Hier ist noch nichts. Sets kommen über den Katalog in die Sammlung.
|
|
</p>
|
|
<Link href="/katalog" className="text-sm underline underline-offset-4">
|
|
Zum Katalog
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y divide-border rounded-lg border border-border">
|
|
{items.map((item) => {
|
|
const image = setImageSrc(item);
|
|
return (
|
|
<li key={item.id}>
|
|
<Link
|
|
href={`/katalog/${item.setId}`}
|
|
className="flex items-center gap-3 p-3 transition-colors hover:bg-muted/50"
|
|
>
|
|
<div className="size-14 shrink-0 overflow-hidden rounded-lg border border-border bg-muted">
|
|
{image ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img src={image} alt="" loading="lazy" className="size-full object-contain" />
|
|
) : (
|
|
<div className="flex size-full items-center justify-center text-muted-foreground">
|
|
<ImageOff className="size-5" aria-hidden />
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="min-w-0 flex-1">
|
|
<p className="truncate text-sm font-medium">{item.setName}</p>
|
|
<p className="truncate text-xs text-muted-foreground">
|
|
{[item.setNumber, item.manufacturerName, item.locationName]
|
|
.filter(Boolean)
|
|
.join(' · ')}
|
|
</p>
|
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
|
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
|
|
{statusLabel(item.status)}
|
|
</Badge>
|
|
{item.completeness !== 'unknown' ? (
|
|
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
|
|
) : null}
|
|
{openParts.get(item.id) ? (
|
|
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
|
|
) : null}
|
|
{item.acquiredAt ? (
|
|
<span className="text-xs text-muted-foreground">
|
|
seit {formatDate(item.acquiredAt)}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</Link>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|