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}
+