From 5c5c5213bb6de33226a9f5ae12e1d5795d99aa39 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 7 Sep 2026 07:18:50 +0200 Subject: [PATCH] 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) --- src/app/(app)/katalog/[id]/page.tsx | 3 + src/app/(app)/sammlung/actions.ts | 126 +++++++++++- src/app/(app)/sammlung/page.tsx | 5 + .../sammlung/collection-items-section.tsx | 32 ++- .../sammlung/missing-parts-dialog.tsx | 186 ++++++++++++++++++ src/lib/queries/collection.ts | 52 ++++- src/lib/validation.ts | 16 ++ 7 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 src/components/sammlung/missing-parts-dialog.tsx diff --git a/src/app/(app)/katalog/[id]/page.tsx b/src/app/(app)/katalog/[id]/page.tsx index 769ffb0..ac2add6 100644 --- a/src/app/(app)/katalog/[id]/page.tsx +++ b/src/app/(app)/katalog/[id]/page.tsx @@ -18,6 +18,7 @@ import { listInstructionsForSet, listPhotosForSet, } from '@/lib/queries/catalog'; +import { listMissingParts } from '@/lib/queries/collection'; import { listLocations } from '@/lib/queries/master-data'; import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists'; import { requireUser } from '@/lib/session'; @@ -45,6 +46,8 @@ export default async function SetDetailPage({ params }: Props) { const collectionItems = listCollectionItemsForSet(set.id).map((item) => ({ ...item, locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null, + setName: `${set.setNumber} · ${set.name}`, + missingParts: listMissingParts(item.id), })); const facts: Array<[string, string]> = [ diff --git a/src/app/(app)/sammlung/actions.ts b/src/app/(app)/sammlung/actions.ts index 3c1f5c5..a0b1ca3 100644 --- a/src/app/(app)/sammlung/actions.ts +++ b/src/app/(app)/sammlung/actions.ts @@ -1,9 +1,9 @@ 'use server'; import { revalidatePath } from 'next/cache'; -import { eq } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; -import { collectionItem, set } from '@/db/schema'; +import { collectionItem, missingPart, set } from '@/db/schema'; import { actionData, actionError, @@ -13,7 +13,7 @@ import { } from '@/lib/action-result'; import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists'; import { requireUser } from '@/lib/session'; -import { collectionItemSchema } from '@/lib/validation'; +import { collectionItemSchema, missingPartSchema } from '@/lib/validation'; import type { Condition } from '@/db/types'; function now() { @@ -103,3 +103,123 @@ export async function deleteCollectionItem(id: string): Promise { revalidateCollection(existing.setId); 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 { + 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 { + 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 { + 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(); +} diff --git a/src/app/(app)/sammlung/page.tsx b/src/app/(app)/sammlung/page.tsx index fddc188..fe47006 100644 --- a/src/app/(app)/sammlung/page.tsx +++ b/src/app/(app)/sammlung/page.tsx @@ -10,6 +10,7 @@ import { completenessLabel, statusLabel } from '@/lib/labels'; import { formatEuro } from '@/lib/money'; import { countMissingSets, + countOpenMissingParts, getCollectionStats, listCollectionItems, type CollectionFilters as Filters, @@ -40,6 +41,7 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea }; const items = listCollectionItems(filters); + const openParts = countOpenMissingParts(items.map((item) => item.id)); const stats = getCollectionStats(); const missing = countMissingSets(); @@ -132,6 +134,9 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea {item.completeness !== 'unknown' ? ( {completenessLabel(item.completeness)} ) : null} + {openParts.get(item.id) ? ( + {openParts.get(item.id)} Fehlteile + ) : null} {item.acquiredAt ? ( seit {formatDate(item.acquiredAt)} diff --git a/src/components/sammlung/collection-items-section.tsx b/src/components/sammlung/collection-items-section.tsx index 5a40f69..1e9c970 100644 --- a/src/components/sammlung/collection-items-section.tsx +++ b/src/components/sammlung/collection-items-section.tsx @@ -2,9 +2,11 @@ import { useRouter } from 'next/navigation'; 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 { 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 { formatDate, toDateInputValue } from '@/lib/format'; import { @@ -54,6 +56,8 @@ export type CollectionItemRow = { giftedBy: string | null; lentOutTo: string | null; notes: string | null; + setName: string; + missingParts: MissingPartRow[]; }; export function CollectionItemsSection({ @@ -67,6 +71,7 @@ export function CollectionItemsSection({ const [pending, startTransition] = useTransition(); const [editing, setEditing] = useState(null); const [deleting, setDeleting] = useState(null); + const [parts, setParts] = useState(null); function submit(event: React.FormEvent) { event.preventDefault(); @@ -121,6 +126,11 @@ export function CollectionItemsSection({ {statusLabel(item.status)} {completenessLabel(item.completeness)} + {item.missingParts.some((part) => !part.resolved) ? ( + + {item.missingParts.filter((part) => !part.resolved).length} Fehlteile + + ) : null} {item.locationName ? ( {item.locationName} ) : null} @@ -140,6 +150,16 @@ export function CollectionItemsSection({

{item.notes ?

{item.notes}

: null} + + + ))} + + ) : ( +

+ Noch nichts vermerkt. Trag hier ein, was beim Aufbauen fehlt – dann weißt du beim + Nachbestellen, worum es geht. +

+ )} + +
+

Fehlteil hinzufügen

+ +
+ + + + + + + + + +
+ + + + + + +
+ + + + + + + ); +} diff --git a/src/lib/queries/collection.ts b/src/lib/queries/collection.ts index b5acfa9..f0c162d 100644 --- a/src/lib/queries/collection.ts +++ b/src/lib/queries/collection.ts @@ -1,7 +1,7 @@ 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 { 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 type { CollectionStatus, Completeness, Condition } from '@/db/types'; @@ -158,3 +158,51 @@ export function countMissingSets(): number { 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 { + if (collectionItemIds.length === 0) return new Map(); + + const rows = db + .select({ + collectionItemId: missingPart.collectionItemId, + open: sql`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])); +} diff --git a/src/lib/validation.ts b/src/lib/validation.ts index 82306c7..a2a1b2a 100644 --- a/src/lib/validation.ts +++ b/src/lib/validation.ts @@ -147,6 +147,22 @@ export const collectionItemSchema = z.object({ 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 // ---------------------------------------------------------------------------