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 // ---------------------------------------------------------------------------