Files
bricktrack/src/components/sammlung/collection-items-section.tsx
T
unknownandClaude Opus 5 c23b754515 feat(sammler): mehrere sammler mit eigener sammlung und wunschliste
Der Katalog bleibt gemeinsam - ein Set existiert einmal, unabhaengig
davon, wer es hat. Besitz und Wunschlisten gehoeren dagegen je einem
Sammler. Damit beantwortet "hat er das schon?" jetzt die Frage "hat *er*
das schon?": ein Set, das nur die Schwester besitzt, faellt fuer den
Bruder wieder unter "fehlt noch" und darf auf seine Wunschliste.

Wichtig fuer den Alltag: Solange es nur einen Sammler gibt, taucht die
Unterscheidung nirgends in der Oberflaeche auf. Kein Umschalter, keine
Abfrage beim Hinzufuegen, keine Badges - die App bleibt exakt so
einfach wie vorher. Erst der zweite Sammler schaltet all das frei.

Die Besitzlogik liegt weiterhin an genau einer Stelle
(src/lib/ownership.ts) und hat nur eine Dimension dazubekommen. Das
automatische "erfuellt" auf Wunschlisten fragt jetzt ebenfalls je Liste:
ein Wunsch auf Max' Liste ist erfuellt, wenn Max das Set bekommt - nicht
wenn Mia es hat. Listen ohne Sammler verhalten sich wie bisher.

Die Migration legt einen Sammler "Sammlung" an und ordnet ihm alles
Vorhandene zu; umbenennen laesst er sich unter Verwaltung -> Sammler.
Der Seed uebernimmt ihn statt einen dritten anzulegen.

Refs Phase 3

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 09:00:35 +02:00

364 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client';
import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react';
import { Loader2, Pencil, PuzzleIcon, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung/actions';
import type { MissingPartRow } from '@/lib/queries/collection';
import { MissingPartsDialog } from './missing-parts-dialog';
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, toDateInputValue } from '@/lib/format';
import {
COMPLETENESS_LABELS,
CONDITION_LABELS,
STATUS_LABELS,
completenessLabel,
conditionLabel,
statusLabel,
} from '@/lib/labels';
import { centsToInput, formatEuro } from '@/lib/money';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { NativeSelect } from '@/components/ui/native-select';
import { Textarea } from '@/components/ui/textarea';
export type CollectionItemRow = {
id: string;
setId: string;
status: CollectionStatus;
completeness: Completeness;
condition: Condition | null;
locationId: string | null;
locationName?: string | null;
acquiredAt: Date | null;
acquiredPriceCents: number | null;
acquiredFrom: string | null;
giftedBy: string | null;
lentOutTo: string | null;
notes: string | null;
setName: string;
missingParts: MissingPartRow[];
collectorId: string | null;
collectorName: string | null;
collectorColor: string | null;
};
export function CollectionItemsSection({
items,
locations,
collectors,
}: {
items: CollectionItemRow[];
locations: Array<{ id: string; name: string }>;
collectors: Array<{ id: string; name: string }>;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
const [editing, setEditing] = useState<CollectionItemRow | null>(null);
const [deleting, setDeleting] = useState<CollectionItemRow | null>(null);
// Only the id, never a snapshot: the dialog shows a live list, and a copy
// taken when it opened would not show a part that was just added.
const [partsItemId, setPartsItemId] = useState<string | null>(null);
const partsItem = items.find((item) => item.id === partsItemId) ?? null;
function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const item = editing;
if (!item) return;
const input = Object.fromEntries(new FormData(event.currentTarget).entries());
startTransition(async () => {
const result = await updateCollectionItem(item.id, { ...input, setId: item.setId });
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Sammlungseintrag gespeichert.');
setEditing(null);
router.refresh();
});
}
function remove() {
const item = deleting;
if (!item) return;
startTransition(async () => {
const result = await deleteCollectionItem(item.id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Sammlungseintrag entfernt.');
setDeleting(null);
router.refresh();
});
}
if (items.length === 0) {
return (
<p className="text-sm text-muted-foreground">
Dieses Set ist noch nicht in der Sammlung.
</p>
);
}
return (
<div className="space-y-3">
<ul className="divide-y divide-border rounded-lg border border-border">
{items.map((item) => (
<li key={item.id} className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
{collectors.length > 1 && item.collectorName ? (
<Badge className={collectorBadgeClass(item.collectorColor)}>
{item.collectorName}
</Badge>
) : null}
<Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
{statusLabel(item.status)}
</Badge>
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
{item.missingParts.some((part) => !part.resolved) ? (
<Badge variant="outline">
{item.missingParts.filter((part) => !part.resolved).length} Fehlteile
</Badge>
) : null}
{item.locationName ? (
<span className="text-xs text-muted-foreground">{item.locationName}</span>
) : null}
</div>
<p className="text-xs text-muted-foreground">
{[
item.acquiredAt ? `Seit ${formatDate(item.acquiredAt)}` : null,
item.acquiredPriceCents !== null
? `Gekauft für ${formatEuro(item.acquiredPriceCents)}`
: null,
item.giftedBy ? `Geschenk von ${item.giftedBy}` : null,
item.lentOutTo ? `Verliehen an ${item.lentOutTo}` : null,
item.condition ? conditionLabel(item.condition) : null,
]
.filter(Boolean)
.join(' · ') || 'Keine weiteren Angaben'}
</p>
{item.notes ? <p className="text-xs">{item.notes}</p> : null}
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Fehlteile"
title="Fehlteile"
onClick={() => setPartsItemId(item.id)}
disabled={pending}
>
<PuzzleIcon />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Bearbeiten"
onClick={() => setEditing(item)}
disabled={pending}
>
<Pencil />
</Button>
<Button
variant="ghost"
size="icon-sm"
aria-label="Entfernen"
onClick={() => setDeleting(item)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
<Dialog open={editing !== null} onOpenChange={(open) => !open && setEditing(null)}>
<DialogContent className="max-h-[85dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Sammlungseintrag bearbeiten</DialogTitle>
</DialogHeader>
<form id="collection-item-form" onSubmit={submit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
{collectors.length > 1 ? (
<Field label="Sammler" htmlFor="collectorId" className="sm:col-span-2">
<NativeSelect
id="collectorId"
name="collectorId"
defaultValue={editing?.collectorId ?? collectors[0]?.id ?? 'none'}
>
{collectors.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</NativeSelect>
</Field>
) : null}
<Field label="Status" htmlFor="status">
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
{Object.entries(STATUS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Vollständigkeit" htmlFor="completeness">
<NativeSelect
id="completeness"
name="completeness"
defaultValue={editing?.completeness ?? 'unknown'}
>
{Object.entries(COMPLETENESS_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Zustand" htmlFor="condition">
<NativeSelect id="condition" name="condition" defaultValue={editing?.condition ?? 'none'}>
<option value="none">– keine Angabe –</option>
{Object.entries(CONDITION_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Standort" htmlFor="locationId">
<NativeSelect
id="locationId"
name="locationId"
defaultValue={editing?.locationId ?? 'none'}
>
<option value="none">– kein Standort –</option>
{locations.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</NativeSelect>
</Field>
<Field label="Im Besitz seit" htmlFor="acquiredAt">
<Input
id="acquiredAt"
name="acquiredAt"
type="date"
defaultValue={toDateInputValue(editing?.acquiredAt)}
/>
</Field>
<Field label="Kaufpreis" htmlFor="acquiredPriceCents" hint="In Euro.">
<Input
id="acquiredPriceCents"
name="acquiredPriceCents"
inputMode="decimal"
defaultValue={centsToInput(editing?.acquiredPriceCents)}
/>
</Field>
<Field label="Gekauft bei" htmlFor="acquiredFrom">
<Input
id="acquiredFrom"
name="acquiredFrom"
defaultValue={editing?.acquiredFrom ?? ''}
maxLength={120}
/>
</Field>
<Field label="Geschenkt von" htmlFor="giftedBy">
<Input
id="giftedBy"
name="giftedBy"
defaultValue={editing?.giftedBy ?? ''}
maxLength={120}
/>
</Field>
<Field label="Verliehen an" htmlFor="lentOutTo">
<Input
id="lentOutTo"
name="lentOutTo"
defaultValue={editing?.lentOutTo ?? ''}
maxLength={120}
/>
</Field>
</div>
<Field label="Notizen" htmlFor="item-notes">
<Textarea id="item-notes" name="notes" defaultValue={editing?.notes ?? ''} rows={3} />
</Field>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => setEditing(null)} disabled={pending}>
Abbrechen
</Button>
<Button type="submit" form="collection-item-form" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : null}
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{partsItem ? (
<MissingPartsDialog
collectionItemId={partsItem.id}
setName={partsItem.setName}
parts={partsItem.missingParts}
open
onOpenChange={(open) => !open && setPartsItemId(null)}
/>
) : null}
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Eintrag entfernen?</AlertDialogTitle>
<AlertDialogDescription>
Das Set bleibt im Katalog, gilt danach aber wieder als „fehlt noch“.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={remove}>Entfernen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}