feat(fehlteile): fehlende teile je sammlungseintrag pflegen

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>
This commit is contained in:
unknown
2026-09-07 07:18:50 +02:00
co-authored by Claude Opus 5
parent baa94bf7e5
commit 5c5c5213bb
7 changed files with 414 additions and 6 deletions
+3
View File
@@ -18,6 +18,7 @@ import {
listInstructionsForSet, listInstructionsForSet,
listPhotosForSet, listPhotosForSet,
} from '@/lib/queries/catalog'; } from '@/lib/queries/catalog';
import { listMissingParts } from '@/lib/queries/collection';
import { listLocations } from '@/lib/queries/master-data'; import { listLocations } from '@/lib/queries/master-data';
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists'; import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -45,6 +46,8 @@ export default async function SetDetailPage({ params }: Props) {
const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({ const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({
...item, ...item,
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null, locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
setName: `${set.setNumber} · ${set.name}`,
missingParts: listMissingParts(item.id),
})); }));
const facts: Array<[string, string]> = [ const facts: Array<[string, string]> = [
+123 -3
View File
@@ -1,9 +1,9 @@
'use server'; 'use server';
import { revalidatePath } from 'next/cache'; import { revalidatePath } from 'next/cache';
import { eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { collectionItem, set } from '@/db/schema'; import { collectionItem, missingPart, set } from '@/db/schema';
import { import {
actionData, actionData,
actionError, actionError,
@@ -13,7 +13,7 @@ import {
} from '@/lib/action-result'; } from '@/lib/action-result';
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists'; import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
import { collectionItemSchema } from '@/lib/validation'; import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
import type { Condition } from '@/db/types'; import type { Condition } from '@/db/types';
function now() { function now() {
@@ -103,3 +103,123 @@ export async function deleteCollectionItem(id: string): Promise<ActionResult> {
revalidateCollection(existing.setId); revalidateCollection(existing.setId);
return actionOk(); return actionOk();
} }
// ---------------------------------------------------------------------------
// Missing parts
// ---------------------------------------------------------------------------
/**
* Keeps `completeness` honest without ever fighting the user.
*
* The moment a part is reported missing the item is not complete any more, and
* once the last open entry is resolved it is complete again. A deliberate
* "unknown" is left alone in the second direction only.
*/
function syncCompleteness(collectionItemId: string): void {
const open = db
.select({ id: missingPart.id })
.from(missingPart)
.where(and(eq(missingPart.collectionItemId, collectionItemId), eq(missingPart.resolved, false)))
.all();
const item = db
.select({ completeness: collectionItem.completeness })
.from(collectionItem)
.where(eq(collectionItem.id, collectionItemId))
.get();
if (!item) return;
if (open.length > 0 && item.completeness !== 'missing_parts') {
db.update(collectionItem)
.set({ completeness: 'missing_parts', updatedAt: now() })
.where(eq(collectionItem.id, collectionItemId))
.run();
return;
}
if (open.length === 0 && item.completeness === 'missing_parts') {
db.update(collectionItem)
.set({ completeness: 'complete', updatedAt: now() })
.where(eq(collectionItem.id, collectionItemId))
.run();
}
}
function loadItemSetId(collectionItemId: string): string | null {
const row = db
.select({ setId: collectionItem.setId })
.from(collectionItem)
.where(eq(collectionItem.id, collectionItemId))
.get();
return row?.setId ?? null;
}
export async function addMissingPart(
collectionItemId: string,
input: unknown,
): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(missingPartSchema, input);
if (!parsed.ok) return parsed;
const setId = loadItemSetId(collectionItemId);
if (!setId) return actionError('Diesen Sammlungseintrag gibt es nicht mehr.');
db.insert(missingPart)
.values({
id: crypto.randomUUID(),
collectionItemId,
partNumber: parsed.data.partNumber,
colorName: parsed.data.colorName,
quantity: parsed.data.quantity,
resolved: false,
notes: parsed.data.notes,
createdAt: now(),
})
.run();
syncCompleteness(collectionItemId);
revalidateCollection(setId);
return actionOk();
}
export async function setMissingPartResolved(
missingPartId: string,
resolved: boolean,
): Promise<ActionResult> {
await requireUser();
const row = db
.select({ collectionItemId: missingPart.collectionItemId })
.from(missingPart)
.where(eq(missingPart.id, missingPartId))
.get();
if (!row) return actionOk();
db.update(missingPart)
.set({ resolved })
.where(eq(missingPart.id, missingPartId))
.run();
syncCompleteness(row.collectionItemId);
revalidateCollection(loadItemSetId(row.collectionItemId) ?? undefined);
return actionOk();
}
export async function deleteMissingPart(missingPartId: string): Promise<ActionResult> {
await requireUser();
const row = db
.select({ collectionItemId: missingPart.collectionItemId })
.from(missingPart)
.where(eq(missingPart.id, missingPartId))
.get();
if (!row) return actionOk();
db.delete(missingPart).where(eq(missingPart.id, missingPartId)).run();
syncCompleteness(row.collectionItemId);
revalidateCollection(loadItemSetId(row.collectionItemId) ?? undefined);
return actionOk();
}
+5
View File
@@ -10,6 +10,7 @@ import { completenessLabel, statusLabel } from '@/lib/labels';
import { formatEuro } from '@/lib/money'; import { formatEuro } from '@/lib/money';
import { import {
countMissingSets, countMissingSets,
countOpenMissingParts,
getCollectionStats, getCollectionStats,
listCollectionItems, listCollectionItems,
type CollectionFilters as Filters, type CollectionFilters as Filters,
@@ -40,6 +41,7 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
}; };
const items = listCollectionItems(filters); const items = listCollectionItems(filters);
const openParts = countOpenMissingParts(items.map((item) => item.id));
const stats = getCollectionStats(); const stats = getCollectionStats();
const missing = countMissingSets(); const missing = countMissingSets();
@@ -132,6 +134,9 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
{item.completeness !== 'unknown' ? ( {item.completeness !== 'unknown' ? (
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge> <Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
) : null} ) : null}
{openParts.get(item.id) ? (
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
) : null}
{item.acquiredAt ? ( {item.acquiredAt ? (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
seit {formatDate(item.acquiredAt)} seit {formatDate(item.acquiredAt)}
@@ -2,9 +2,11 @@
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import { useState, useTransition } from 'react'; import { useState, useTransition } from 'react';
import { Loader2, Pencil, Trash2 } from 'lucide-react'; import { Loader2, Pencil, PuzzleIcon, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung/actions'; 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 type { CollectionStatus, Completeness, Condition } from '@/db/types';
import { formatDate, toDateInputValue } from '@/lib/format'; import { formatDate, toDateInputValue } from '@/lib/format';
import { import {
@@ -54,6 +56,8 @@ export type CollectionItemRow = {
giftedBy: string | null; giftedBy: string | null;
lentOutTo: string | null; lentOutTo: string | null;
notes: string | null; notes: string | null;
setName: string;
missingParts: MissingPartRow[];
}; };
export function CollectionItemsSection({ export function CollectionItemsSection({
@@ -67,6 +71,7 @@ export function CollectionItemsSection({
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [editing, setEditing] = useState<CollectionItemRow | null>(null); const [editing, setEditing] = useState<CollectionItemRow | null>(null);
const [deleting, setDeleting] = useState<CollectionItemRow | null>(null); const [deleting, setDeleting] = useState<CollectionItemRow | null>(null);
const [parts, setParts] = useState<CollectionItemRow | null>(null);
function submit(event: React.FormEvent<HTMLFormElement>) { function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
@@ -121,6 +126,11 @@ export function CollectionItemsSection({
{statusLabel(item.status)} {statusLabel(item.status)}
</Badge> </Badge>
<Badge variant="secondary">{completenessLabel(item.completeness)}</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 ? ( {item.locationName ? (
<span className="text-xs text-muted-foreground">{item.locationName}</span> <span className="text-xs text-muted-foreground">{item.locationName}</span>
) : null} ) : null}
@@ -140,6 +150,16 @@ export function CollectionItemsSection({
</p> </p>
{item.notes ? <p className="text-xs">{item.notes}</p> : null} {item.notes ? <p className="text-xs">{item.notes}</p> : null}
</div> </div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Fehlteile"
title="Fehlteile"
onClick={() => setParts(item)}
disabled={pending}
>
<PuzzleIcon />
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon-sm" size="icon-sm"
@@ -283,6 +303,16 @@ export function CollectionItemsSection({
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{parts ? (
<MissingPartsDialog
collectionItemId={parts.id}
setName={parts.setName}
parts={parts.missingParts}
open
onOpenChange={(open) => !open && setParts(null)}
/>
) : null}
<AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}> <AlertDialog open={deleting !== null} onOpenChange={(open) => !open && setDeleting(null)}>
<AlertDialogContent> <AlertDialogContent>
<AlertDialogHeader> <AlertDialogHeader>
@@ -0,0 +1,186 @@
'use client';
import { useRouter } from 'next/navigation';
import { useTransition } from 'react';
import { Loader2, Plus, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
addMissingPart,
deleteMissingPart,
setMissingPartResolved,
} from '@/app/(app)/sammlung/actions';
import type { MissingPartRow } from '@/lib/queries/collection';
import { cn } from 'cn';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Field } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
/**
* Missing parts of one owned copy.
*
* Adding an entry marks the copy as incomplete, resolving the last one marks
* it complete again - so the collection filter stays truthful without anyone
* having to remember to update it.
*/
export function MissingPartsDialog({
collectionItemId,
setName,
parts,
open,
onOpenChange,
}: {
collectionItemId: string;
setName: string;
parts: MissingPartRow[];
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const router = useRouter();
const [pending, startTransition] = useTransition();
function add(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const input = Object.fromEntries(new FormData(form).entries());
startTransition(async () => {
const result = await addMissingPart(collectionItemId, input);
if (!result.ok) {
toast.error(result.error);
return;
}
form.reset();
toast.success('Fehlteil vermerkt.');
router.refresh();
});
}
function toggle(part: MissingPartRow) {
startTransition(async () => {
const result = await setMissingPartResolved(part.id, !part.resolved);
if (!result.ok) {
toast.error(result.error);
return;
}
router.refresh();
});
}
function remove(part: MissingPartRow) {
startTransition(async () => {
const result = await deleteMissingPart(part.id);
if (!result.ok) {
toast.error(result.error);
return;
}
toast.success('Eintrag gelöscht.');
router.refresh();
});
}
const open_ = parts.filter((part) => !part.resolved).length;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-h-[85dvh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Fehlteile</DialogTitle>
<DialogDescription>
{setName} –{' '}
{open_ === 0
? 'aktuell fehlt nichts.'
: open_ === 1
? 'ein Teil fehlt noch.'
: `${open_} Teile fehlen noch.`}
</DialogDescription>
</DialogHeader>
{parts.length > 0 ? (
<ul className="divide-y divide-border rounded-lg border border-border">
{parts.map((part) => (
<li key={part.id} className="flex items-center gap-3 p-3">
<Checkbox
checked={part.resolved}
onCheckedChange={() => toggle(part)}
disabled={pending}
aria-label={part.resolved ? 'Wieder als fehlend markieren' : 'Als erledigt markieren'}
/>
<div className={cn('min-w-0 flex-1', part.resolved && 'opacity-50')}>
<p className={cn('truncate text-sm font-medium', part.resolved && 'line-through')}>
{part.quantity}× {part.partNumber ?? 'Teil'}
{part.colorName ? ` · ${part.colorName}` : ''}
</p>
{part.notes ? (
<p className="truncate text-xs text-muted-foreground">{part.notes}</p>
) : null}
</div>
<Button
variant="ghost"
size="icon-sm"
aria-label="Eintrag löschen"
onClick={() => remove(part)}
disabled={pending}
>
<Trash2 className="text-destructive" />
</Button>
</li>
))}
</ul>
) : (
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
Noch nichts vermerkt. Trag hier ein, was beim Aufbauen fehlt – dann weißt du beim
Nachbestellen, worum es geht.
</p>
)}
<form onSubmit={add} className="space-y-3 rounded-lg border border-border p-3">
<p className="text-sm font-medium">Fehlteil hinzufügen</p>
<div className="grid gap-3 sm:grid-cols-3">
<Field label="Teilenummer" htmlFor="partNumber" className="sm:col-span-1">
<Input id="partNumber" name="partNumber" maxLength={40} placeholder="3001" className="font-mono" />
</Field>
<Field label="Farbe" htmlFor="colorName">
<Input id="colorName" name="colorName" maxLength={40} placeholder="Rot" />
</Field>
<Field label="Anzahl" htmlFor="quantity">
<Input
id="quantity"
name="quantity"
type="number"
inputMode="numeric"
min={1}
max={999}
defaultValue={1}
/>
</Field>
</div>
<Field label="Notiz" htmlFor="part-notes" hint="Reicht auch allein, wenn du die Nummer nicht kennst.">
<Input id="part-notes" name="notes" maxLength={300} placeholder="Dachstein vorne links" />
</Field>
<Button type="submit" disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Hinzufügen
</Button>
</form>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
Fertig
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+50 -2
View File
@@ -1,7 +1,7 @@
import 'server-only'; import 'server-only';
import { and, asc, countDistinct, eq, sql, type SQL } from 'drizzle-orm'; import { and, asc, countDistinct, eq, inArray, sql, type SQL } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { collectionItem, location, manufacturer, set, theme } from '@/db/schema'; import { collectionItem, location, manufacturer, missingPart, set, theme } from '@/db/schema';
import { SOLD_STATUS } from '@/lib/ownership'; import { SOLD_STATUS } from '@/lib/ownership';
import type { CollectionStatus, Completeness, Condition } from '@/db/types'; import type { CollectionStatus, Completeness, Condition } from '@/db/types';
@@ -158,3 +158,51 @@ export function countMissingSets(): number {
return row?.total ?? 0; return row?.total ?? 0;
} }
export type MissingPartRow = {
id: string;
partNumber: string | null;
colorName: string | null;
quantity: number;
resolved: boolean;
notes: string | null;
};
export function listMissingParts(collectionItemId: string): MissingPartRow[] {
return db
.select({
id: missingPart.id,
partNumber: missingPart.partNumber,
colorName: missingPart.colorName,
quantity: missingPart.quantity,
resolved: missingPart.resolved,
notes: missingPart.notes,
})
.from(missingPart)
.where(eq(missingPart.collectionItemId, collectionItemId))
// Open entries first: those are the ones you act on.
.orderBy(asc(missingPart.resolved), asc(missingPart.createdAt))
.all();
}
/** Open missing parts per collection item, for the badges in the lists. */
export function countOpenMissingParts(collectionItemIds: string[]): Map<string, number> {
if (collectionItemIds.length === 0) return new Map();
const rows = db
.select({
collectionItemId: missingPart.collectionItemId,
open: sql<number>`count(*)`.mapWith(Number),
})
.from(missingPart)
.where(
and(
inArray(missingPart.collectionItemId, collectionItemIds),
eq(missingPart.resolved, false),
),
)
.groupBy(missingPart.collectionItemId)
.all();
return new Map(rows.map((row) => [row.collectionItemId, row.open]));
}
+16
View File
@@ -147,6 +147,22 @@ export const collectionItemSchema = z.object({
notes: optionalText(2000), notes: optionalText(2000),
}); });
/**
* A missing part needs at least something identifying: a part number or a
* note. An entry with neither says nothing and would only clutter the list.
*/
export const missingPartSchema = z
.object({
partNumber: optionalText(40),
colorName: optionalText(40),
quantity: z.coerce.number().int().min(1).max(999).default(1),
notes: optionalText(300),
})
.refine(
(value) => value.partNumber !== null || value.notes !== null,
'Bitte gib mindestens die Teilenummer oder eine Notiz an.',
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Wishlists // Wishlists
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------