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>
This commit is contained in:
unknown
2026-09-07 09:00:35 +02:00
co-authored by Claude Opus 5
parent 7265c03548
commit c23b754515
35 changed files with 3104 additions and 66 deletions
+28
View File
@@ -0,0 +1,28 @@
CREATE TABLE `collector` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`color` text DEFAULT 'blue' NOT NULL,
`birthday` integer,
`notes` text,
`sort_order` integer DEFAULT 0 NOT NULL,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE INDEX `collector_sort_idx` ON `collector` (`sort_order`,`name`);--> statement-breakpoint
ALTER TABLE `collection_item` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
CREATE INDEX `collection_item_collector_idx` ON `collection_item` (`collector_id`);--> statement-breakpoint
ALTER TABLE `wishlist` ADD `collector_id` text REFERENCES collector(id);--> statement-breakpoint
-- Von Hand ergaenzt: Bestandsdaten nachziehen.
-- Vor dieser Migration gab es genau eine Sammlung. Sie bekommt einen Sammler,
-- damit alles Vorhandene zugeordnet bleibt; umbenennen laesst er sich unter
-- Verwaltung -> Sammler.
INSERT INTO `collector` (`id`, `name`, `color`, `sort_order`, `created_at`, `updated_at`)
SELECT 'c0000000-0000-4000-8000-000000000001', 'Sammlung', 'blue', 0, unixepoch(), unixepoch()
WHERE NOT EXISTS (SELECT 1 FROM `collector`);--> statement-breakpoint
UPDATE `collection_item`
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
WHERE `collector_id` IS NULL;--> statement-breakpoint
UPDATE `wishlist`
SET `collector_id` = (SELECT `id` FROM `collector` ORDER BY `sort_order`, `name` LIMIT 1)
WHERE `collector_id` IS NULL;
File diff suppressed because it is too large Load Diff
+7
View File
@@ -8,6 +8,13 @@
"when": 1788726503038, "when": 1788726503038,
"tag": "0000_rapid_gargoyle", "tag": "0000_rapid_gargoyle",
"breakpoints": true "breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1788763704015,
"tag": "0001_lumpy_bill_hollister",
"breakpoints": true
} }
] ]
} }
+90 -8
View File
@@ -33,11 +33,12 @@ type SeedSet = {
minifigCount: number | null; minifigCount: number | null;
retailPriceCents: number; retailPriceCents: number;
ageRange: string; ageRange: string;
/** Copies owned - 0 means the set is only in the catalog. */ /** Copies owned - absent means the set is only in the catalog. */
owned?: { owned?: {
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold'; status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
completeness: 'complete' | 'missing_parts' | 'unknown'; completeness: 'complete' | 'missing_parts' | 'unknown';
location: string; location: string;
collector: string;
giftedBy?: string; giftedBy?: string;
}; };
}; };
@@ -59,6 +60,13 @@ const THEMES: Array<{ manufacturer: string; name: string }> = [
const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer']; const LOCATIONS = ['Regal Kinderzimmer', 'Kiste Keller 3', 'Vitrine Wohnzimmer'];
// Two collectors, so the shared catalog and the separate collections are
// visible right away. With only one, the whole concept stays hidden in the UI.
const COLLECTORS = [
{ name: 'Max', color: 'blue' as const },
{ name: 'Mia', color: 'rose' as const },
];
const SETS: SeedSet[] = [ const SETS: SeedSet[] = [
{ {
manufacturer: 'Lego', manufacturer: 'Lego',
@@ -70,7 +78,12 @@ const SETS: SeedSet[] = [
minifigCount: 4, minifigCount: 4,
retailPriceCents: 4999, retailPriceCents: 4999,
ageRange: '6-12', ageRange: '6-12',
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' }, owned: {
status: 'built',
completeness: 'complete',
location: 'Regal Kinderzimmer',
collector: 'Max',
},
}, },
{ {
manufacturer: 'Lego', manufacturer: 'Lego',
@@ -86,6 +99,7 @@ const SETS: SeedSet[] = [
status: 'owned', status: 'owned',
completeness: 'missing_parts', completeness: 'missing_parts',
location: 'Kiste Keller 3', location: 'Kiste Keller 3',
collector: 'Max',
giftedBy: 'Oma Helga', giftedBy: 'Oma Helga',
}, },
}, },
@@ -99,7 +113,12 @@ const SETS: SeedSet[] = [
minifigCount: null, minifigCount: null,
retailPriceCents: 999, retailPriceCents: 999,
ageRange: '7-14', ageRange: '7-14',
owned: { status: 'disassembled', completeness: 'unknown', location: 'Kiste Keller 3' }, owned: {
status: 'disassembled',
completeness: 'unknown',
location: 'Kiste Keller 3',
collector: 'Mia',
},
}, },
{ {
manufacturer: 'Lego', manufacturer: 'Lego',
@@ -144,7 +163,12 @@ const SETS: SeedSet[] = [
minifigCount: 5, minifigCount: 5,
retailPriceCents: 12999, retailPriceCents: 12999,
ageRange: '4-10', ageRange: '4-10',
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' }, owned: {
status: 'built',
completeness: 'complete',
location: 'Regal Kinderzimmer',
collector: 'Mia',
},
}, },
{ {
manufacturer: 'Playmobil', manufacturer: 'Playmobil',
@@ -241,6 +265,59 @@ function seedThemes(manufacturers: Map<string, string>) {
return byKey; return byKey;
} }
function seedCollectors() {
const byName = new Map<string, string>();
COLLECTORS.forEach((entry, index) => {
const all = db
.select({ id: schema.collector.id, name: schema.collector.name })
.from(schema.collector)
.all();
const existing = all.find((row) => row.name === entry.name);
if (existing) {
byName.set(entry.name, existing.id);
return;
}
// A fresh install already has the collector the migration created. Rather
// than adding a third one next to it, the seed takes it over.
const untouchedDefault = all.find(
(row) =>
row.name === 'Sammlung' &&
!db
.select({ id: schema.collectionItem.id })
.from(schema.collectionItem)
.where(eq(schema.collectionItem.collectorId, row.id))
.get(),
);
if (untouchedDefault) {
db.update(schema.collector)
.set({ name: entry.name, color: entry.color, sortOrder: index, updatedAt: now })
.where(eq(schema.collector.id, untouchedDefault.id))
.run();
byName.set(entry.name, untouchedDefault.id);
return;
}
const newId = id();
db.insert(schema.collector)
.values({
id: newId,
name: entry.name,
color: entry.color,
sortOrder: index,
createdAt: now,
updatedAt: now,
})
.run();
byName.set(entry.name, newId);
});
return byName;
}
function seedLocations() { function seedLocations() {
const byName = new Map<string, string>(); const byName = new Map<string, string>();
@@ -270,6 +347,7 @@ function seedSets(
manufacturers: Map<string, string>, manufacturers: Map<string, string>,
themes: Map<string, string>, themes: Map<string, string>,
locations: Map<string, string>, locations: Map<string, string>,
collectors: Map<string, string>,
) { ) {
const setIds = new Map<string, string>(); const setIds = new Map<string, string>();
@@ -317,6 +395,7 @@ function seedSets(
completeness: entry.owned.completeness, completeness: entry.owned.completeness,
condition: 'good', condition: 'good',
locationId: locations.get(entry.owned.location) ?? null, locationId: locations.get(entry.owned.location) ?? null,
collectorId: collectors.get(entry.owned.collector) ?? null,
acquiredAt: new Date(entry.year, 11, 24), acquiredAt: new Date(entry.year, 11, 24),
acquiredPriceCents: entry.retailPriceCents, acquiredPriceCents: entry.retailPriceCents,
giftedBy: entry.owned.giftedBy ?? null, giftedBy: entry.owned.giftedBy ?? null,
@@ -330,7 +409,7 @@ function seedSets(
return setIds; return setIds;
} }
function seedWishlist(setIds: Map<string, string>) { function seedWishlist(setIds: Map<string, string>, collectors: Map<string, string>) {
const owner = db.select({ id: schema.user.id }).from(schema.user).get(); const owner = db.select({ id: schema.user.id }).from(schema.user).get();
if (!owner) { if (!owner) {
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.'); console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
@@ -353,6 +432,7 @@ function seedWishlist(setIds: Map<string, string>) {
.values({ .values({
id: wishlistId, id: wishlistId,
ownerUserId: owner.id, ownerUserId: owner.id,
collectorId: collectors.get('Max') ?? null,
title: 'Weihnachten 2026', title: 'Weihnachten 2026',
occasion: 'christmas', occasion: 'christmas',
eventDate: new Date(2026, 11, 24), eventDate: new Date(2026, 11, 24),
@@ -389,10 +469,12 @@ console.log('[seed] Beispieldaten werden eingespielt …');
const manufacturers = seedManufacturers(); const manufacturers = seedManufacturers();
const themes = seedThemes(manufacturers); const themes = seedThemes(manufacturers);
const locations = seedLocations(); const locations = seedLocations();
const setIds = seedSets(manufacturers, themes, locations); const collectors = seedCollectors();
seedWishlist(setIds); const setIds = seedSets(manufacturers, themes, locations, collectors);
seedWishlist(setIds, collectors);
console.log( console.log(
`[seed] Fertig: ${manufacturers.size} Hersteller, ${themes.size} Themen, ${setIds.size} Sets.`, `[seed] Fertig: ${collectors.size} Sammler, ${manufacturers.size} Hersteller, ` +
`${themes.size} Themen, ${setIds.size} Sets.`,
); );
sqlite.close(); sqlite.close();
+26 -5
View File
@@ -19,23 +19,34 @@ import {
listPhotosForSet, listPhotosForSet,
} from '@/lib/queries/catalog'; } from '@/lib/queries/catalog';
import { listMissingParts } from '@/lib/queries/collection'; import { listMissingParts } from '@/lib/queries/collection';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
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';
type Props = { params: Promise<{ id: string }> }; type Props = {
params: Promise<{ id: string }>;
searchParams: Promise<Record<string, string | string[] | undefined>>;
};
export async function generateMetadata({ params }: Props): Promise<Metadata> { export async function generateMetadata({ params }: Pick<Props, 'params'>): Promise<Metadata> {
const { id } = await params; const { id } = await params;
const set = getSetById(id); const set = getSetById(id);
return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' }; return { title: set ? `${set.setNumber} · ${set.name}` : 'Set' };
} }
export default async function SetDetailPage({ params }: Props) { export default async function SetDetailPage({ params, searchParams }: Props) {
await requireUser(); await requireUser();
const { id } = await params; const { id } = await params;
const query = await searchParams;
const set = getSetById(id); // Keeps the "vorhanden / fehlt" badge in step with the collector the user
// was looking at in the catalog.
const rawCollector = Array.isArray(query.sammler) ? query.sammler[0] : query.sammler;
const collectorId = resolveCollectorId(rawCollector);
const collectors = listCollectors();
const set = getSetById(id, collectorId);
if (!set) notFound(); if (!set) notFound();
const image = setImageSrc(set); const image = setImageSrc(set);
@@ -43,11 +54,16 @@ export default async function SetDetailPage({ params }: Props) {
const locations = listLocations().map((row) => ({ id: row.id, name: row.name })); const locations = listLocations().map((row) => ({ id: row.id, name: row.name }));
const locationNames = new Map(locations.map((row) => [row.id, row.name])); const locationNames = new Map(locations.map((row) => [row.id, row.name]));
const collectorsById = new Map(collectors.map((row) => [row.id, row]));
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}`, setName: `${set.setNumber} · ${set.name}`,
missingParts: listMissingParts(item.id), missingParts: listMissingParts(item.id),
collectorName: item.collectorId ? (collectorsById.get(item.collectorId)?.name ?? null) : null,
collectorColor: item.collectorId
? (collectorsById.get(item.collectorId)?.color ?? null)
: null,
})); }));
const facts: Array<[string, string]> = [ const facts: Array<[string, string]> = [
@@ -113,6 +129,7 @@ export default async function SetDetailPage({ params }: Props) {
setName={`${set.setNumber} · ${set.name}`} setName={`${set.setNumber} · ${set.name}`}
owned={owned} owned={owned}
wishlists={listWishlistTargets()} wishlists={listWishlistTargets()}
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/> />
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3"> <dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm sm:grid-cols-3">
@@ -144,7 +161,11 @@ export default async function SetDetailPage({ params }: Props) {
<CardTitle>In der Sammlung</CardTitle> <CardTitle>In der Sammlung</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<CollectionItemsSection items={collectionItems} locations={locations} /> <CollectionItemsSection
items={collectionItems}
locations={locations}
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
</CardContent> </CardContent>
</Card> </Card>
+15 -2
View File
@@ -1,6 +1,7 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { CollectorFilter } from '@/components/layout/collector-filter';
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner'; import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
import { CatalogFilters } from '@/components/katalog/catalog-filters'; import { CatalogFilters } from '@/components/katalog/catalog-filters';
import { CatalogGrid } from '@/components/katalog/catalog-grid'; import { CatalogGrid } from '@/components/katalog/catalog-grid';
@@ -9,6 +10,7 @@ import { Button } from '@/components/ui/button';
import { pluralize } from '@/lib/format'; import { pluralize } from '@/lib/format';
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership'; import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog'; import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
import { listManufacturers, listThemes } from '@/lib/queries/master-data'; import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { listWishlistTargets } from '@/lib/queries/wishlists'; import { listWishlistTargets } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -27,6 +29,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
const params = await searchParams; const params = await searchParams;
const ownership = parseOwnershipFilter(single(params.besitz)); const ownership = parseOwnershipFilter(single(params.besitz));
const collectors = listCollectors();
const collectorId = resolveCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const yearParam = single(params.jahr); const yearParam = single(params.jahr);
const page = Number(single(params.seite) ?? '1') || 1; const page = Number(single(params.seite) ?? '1') || 1;
@@ -36,6 +41,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
themeId: single(params.thema), themeId: single(params.thema),
year: yearParam ? Number(yearParam) : undefined, year: yearParam ? Number(yearParam) : undefined,
ownership, ownership,
collectorId,
page, page,
}; };
@@ -57,7 +63,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{ownership === 'fehlt' {ownership === 'fehlt'
? 'Sets, die noch nicht in der Sammlung sind.' ? activeCollector
? `Sets, die ${activeCollector.name} noch nicht hat.`
: 'Sets, die noch nicht in der Sammlung sind.'
: 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'} : 'Alle bekannten Sets – unabhängig davon, ob sie schon da sind.'}
</p> </p>
</div> </div>
@@ -72,6 +80,11 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
</div> </div>
</div> </div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} /> <CatalogFilters options={{ manufacturers, themes, years: listSetYears() }} />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -79,7 +92,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
{ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''} {ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}
</p> </p>
<CatalogGrid sets={rows} wishlists={wishlists} /> <CatalogGrid sets={rows} wishlists={wishlists} collectorId={collectorId} />
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} /> <Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
</div> </div>
+10 -1
View File
@@ -11,6 +11,7 @@ import {
parseForm, parseForm,
type ActionResult, type ActionResult,
} from '@/lib/action-result'; } from '@/lib/action-result';
import { defaultCollectorId } from '@/lib/queries/collectors';
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists'; import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
import { collectionItemSchema, missingPartSchema } from '@/lib/validation'; import { collectionItemSchema, missingPartSchema } from '@/lib/validation';
@@ -32,17 +33,24 @@ function revalidateCollection(setId?: string) {
* Quick add from the set detail page: one tap, sensible defaults, details can * Quick add from the set detail page: one tap, sensible defaults, details can
* be filled in later. * be filled in later.
*/ */
export async function addSetToCollection(setId: string): Promise<ActionResult<{ id: string }>> { export async function addSetToCollection(
setId: string,
collectorId?: string,
): Promise<ActionResult<{ id: string }>> {
await requireUser(); await requireUser();
const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get(); const exists = db.select({ id: set.id }).from(set).where(eq(set.id, setId)).get();
if (!exists) return actionError('Das Set gibt es nicht mehr.'); if (!exists) return actionError('Das Set gibt es nicht mehr.');
// With a single collector nobody is asked - the copy simply belongs to them.
const owner = collectorId ?? defaultCollectorId();
const id = crypto.randomUUID(); const id = crypto.randomUUID();
db.insert(collectionItem) db.insert(collectionItem)
.values({ .values({
id, id,
setId, setId,
collectorId: owner,
status: 'owned', status: 'owned',
completeness: 'unknown', completeness: 'unknown',
createdAt: now(), createdAt: now(),
@@ -68,6 +76,7 @@ export async function updateCollectionItem(id: string, input: unknown): Promise<
db.update(collectionItem) db.update(collectionItem)
.set({ .set({
collectorId: parsed.data.collectorId ?? existing.collectorId,
status: parsed.data.status, status: parsed.data.status,
completeness: parsed.data.completeness, completeness: parsed.data.completeness,
condition: parsed.data.condition as Condition | null, condition: parsed.data.condition as Condition | null,
+25 -3
View File
@@ -1,10 +1,12 @@
import type { Metadata } from 'next'; import type { Metadata } from 'next';
import Link from 'next/link'; import Link from 'next/link';
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react'; import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
import { CollectorFilter } from '@/components/layout/collector-filter';
import { CollectionFilters } from '@/components/sammlung/collection-filters'; import { CollectionFilters } from '@/components/sammlung/collection-filters';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { formatDate, formatNumber, pluralize } from '@/lib/format'; import { formatDate, formatNumber, pluralize } from '@/lib/format';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { setImageSrc } from '@/lib/images'; import { setImageSrc } from '@/lib/images';
import { completenessLabel, statusLabel } from '@/lib/labels'; import { completenessLabel, statusLabel } from '@/lib/labels';
import { formatEuro } from '@/lib/money'; import { formatEuro } from '@/lib/money';
@@ -15,6 +17,7 @@ import {
listCollectionItems, listCollectionItems,
type CollectionFilters as Filters, type CollectionFilters as Filters,
} from '@/lib/queries/collection'; } from '@/lib/queries/collection';
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
import { listLocations, listManufacturers } from '@/lib/queries/master-data'; import { listLocations, listManufacturers } from '@/lib/queries/master-data';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
import type { CollectionStatus, Completeness } from '@/db/types'; import type { CollectionStatus, Completeness } from '@/db/types';
@@ -32,18 +35,23 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
await requireUser(); await requireUser();
const params = await searchParams; const params = await searchParams;
const collectors = listCollectors();
const collectorId = resolveCollectorId(single(params.sammler));
const activeCollector = collectors.find((row) => row.id === collectorId) ?? null;
const filters: Filters = { const filters: Filters = {
q: single(params.q), q: single(params.q),
status: single(params.status) as CollectionStatus | undefined, status: single(params.status) as CollectionStatus | undefined,
completeness: single(params.zustand) as Completeness | undefined, completeness: single(params.zustand) as Completeness | undefined,
locationId: single(params.standort), locationId: single(params.standort),
manufacturerId: single(params.hersteller), manufacturerId: single(params.hersteller),
collectorId,
}; };
const items = listCollectionItems(filters); const items = listCollectionItems(filters);
const openParts = countOpenMissingParts(items.map((item) => item.id)); const openParts = countOpenMissingParts(items.map((item) => item.id));
const stats = getCollectionStats(); const stats = getCollectionStats(collectorId);
const missing = countMissingSets(); const missing = countMissingSets(collectorId);
const tiles = [ const tiles = [
{ icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) }, { icon: Boxes, label: 'Sets', value: formatNumber(stats.setCount) },
@@ -56,9 +64,18 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
<div className="space-y-5"> <div className="space-y-5">
<div> <div>
<h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1> <h1 className="text-2xl font-semibold tracking-tight">Sammlung</h1>
<p className="text-sm text-muted-foreground">Alles, was tatsächlich da ist.</p> <p className="text-sm text-muted-foreground">
{activeCollector
? `Alles, was ${activeCollector.name} tatsächlich hat.`
: 'Alles, was tatsächlich da ist.'}
</p>
</div> </div>
<CollectorFilter
collectors={collectors.map((row) => ({ id: row.id, name: row.name, color: row.color }))}
allLabel="Alle zusammen"
/>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{tiles.map((tile) => ( {tiles.map((tile) => (
<Card key={tile.label}> <Card key={tile.label}>
@@ -134,6 +151,11 @@ 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}
{collectors.length > 1 && item.collectorName ? (
<Badge className={collectorBadgeClass(item.collectorColor)}>
{item.collectorName}
</Badge>
) : null}
{openParts.get(item.id) ? ( {openParts.get(item.id) ? (
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge> <Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
) : null} ) : null}
+160
View File
@@ -0,0 +1,160 @@
'use server';
import { revalidatePath } from 'next/cache';
import { asc, eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, collector, wishlist } from '@/db/schema';
import { actionError, actionOk, parseForm, type ActionResult } from '@/lib/action-result';
import { requireUser } from '@/lib/session';
import { collectorSchema } from '@/lib/validation';
function now() {
return new Date();
}
function revalidateEverything() {
// A collector shows up in the catalog, the collection and the wishlists.
revalidatePath('/', 'layout');
}
export async function createCollector(input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(collectorSchema, input);
if (!parsed.ok) return parsed;
const [row] = db
.select({ next: sql<number>`coalesce(max(${collector.sortOrder}), -1) + 1`.mapWith(Number) })
.from(collector)
.all();
db.insert(collector)
.values({
id: crypto.randomUUID(),
name: parsed.data.name,
color: parsed.data.color,
birthday: parsed.data.birthday,
notes: parsed.data.notes,
sortOrder: row?.next ?? 0,
createdAt: now(),
updatedAt: now(),
})
.run();
revalidateEverything();
return actionOk();
}
export async function updateCollector(id: string, input: unknown): Promise<ActionResult> {
await requireUser();
const parsed = parseForm(collectorSchema, input);
if (!parsed.ok) return parsed;
db.update(collector)
.set({
name: parsed.data.name,
color: parsed.data.color,
birthday: parsed.data.birthday,
notes: parsed.data.notes,
updatedAt: now(),
})
.where(eq(collector.id, id))
.run();
revalidateEverything();
return actionOk();
}
/**
* Deleting a collector would take their whole collection with it, so it is
* only allowed while nothing is attached - and never for the last one.
*/
export async function deleteCollector(id: string): Promise<ActionResult> {
await requireUser();
const remaining = db.select({ id: collector.id }).from(collector).all();
if (remaining.length <= 1) {
return actionError('Der letzte Sammler kann nicht gelöscht werden.');
}
const owned = db
.select({ id: collectionItem.id })
.from(collectionItem)
.where(eq(collectionItem.collectorId, id))
.get();
if (owned) {
return actionError(
'Diesem Sammler sind noch Sets zugeordnet. Ordne sie erst jemand anderem zu, dann lässt er sich löschen.',
);
}
const lists = db
.select({ id: wishlist.id })
.from(wishlist)
.where(eq(wishlist.collectorId, id))
.get();
if (lists) {
return actionError(
'Zu diesem Sammler gibt es noch Wunschlisten. Ordne sie erst um oder lösch sie.',
);
}
db.delete(collector).where(eq(collector.id, id)).run();
revalidateEverything();
return actionOk();
}
/** Moves every owned copy of one collector over to another. */
export async function reassignCollectionItems(
fromCollectorId: string,
toCollectorId: string,
): Promise<ActionResult> {
await requireUser();
if (fromCollectorId === toCollectorId) return actionOk();
const target = db
.select({ id: collector.id })
.from(collector)
.where(eq(collector.id, toCollectorId))
.get();
if (!target) return actionError('Diesen Sammler gibt es nicht.');
db.update(collectionItem)
.set({ collectorId: toCollectorId, updatedAt: now() })
.where(eq(collectionItem.collectorId, fromCollectorId))
.run();
revalidateEverything();
return actionOk();
}
/** Reorders the collectors; the first one is the default for new entries. */
export async function reorderCollectors(orderedIds: string[]): Promise<ActionResult> {
await requireUser();
db.transaction((tx) => {
orderedIds.forEach((id, index) => {
tx.update(collector)
.set({ sortOrder: index, updatedAt: now() })
.where(eq(collector.id, id))
.run();
});
});
revalidateEverything();
return actionOk();
}
/** Every collector, ordered - for the selects in the forms. */
export async function listCollectorOptions(): Promise<Array<{ id: string; name: string }>> {
await requireUser();
return db
.select({ id: collector.id, name: collector.name })
.from(collector)
.orderBy(asc(collector.sortOrder), asc(collector.name))
.all();
}
+40
View File
@@ -0,0 +1,40 @@
import type { Metadata } from 'next';
import { CollectorList, type CollectorRowItem } from '@/components/verwaltung/collector-list';
import { formatDate, pluralize } from '@/lib/format';
import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
import { listCollectors } from '@/lib/queries/collectors';
import { requireUser } from '@/lib/session';
export const metadata: Metadata = { title: 'Sammler' };
export default async function SammlerPage() {
await requireUser();
const collectors = listCollectors();
const rows: CollectorRowItem[] = collectors.map((row) => ({
id: row.id,
title: row.name,
subtitle: [
pluralize(row.itemCount, 'Set', 'Sets'),
COLLECTOR_COLOR_LABELS[row.color],
row.birthday ? `geboren am ${formatDate(row.birthday)}` : null,
]
.filter(Boolean)
.join(' · '),
color: row.color,
birthday: row.birthday,
notes: row.notes,
itemCount: row.itemCount,
}));
return (
<div className="space-y-4">
<p className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
Der Katalog ist gemeinsam: Ein Set existiert einmal, unabhängig davon, wer es hat.
Sammlung und Wunschlisten gehören dagegen je einem Sammler. Solange es nur einen gibt,
taucht die Unterscheidung nirgends in der App auf – sie erscheint erst mit dem zweiten.
</p>
<CollectorList collectors={rows} />
</div>
);
}
+17 -2
View File
@@ -8,10 +8,12 @@ import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header
import { WishlistItems } from '@/components/wunschlisten/wishlist-items'; import { WishlistItems } from '@/components/wunschlisten/wishlist-items';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, pluralize } from '@/lib/format'; import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels'; import { occasionLabel } from '@/lib/labels';
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog'; import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
import { listManufacturers, listThemes } from '@/lib/queries/master-data'; import { listManufacturers, listThemes } from '@/lib/queries/master-data';
import { listCollectors } from '@/lib/queries/collectors';
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists'; import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -39,7 +41,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
const list = getWishlist(id); const list = getWishlist(id);
if (!list || list.ownerUserId !== user.id) notFound(); if (!list || list.ownerUserId !== user.id) notFound();
const items = listWishlistItems(list.id); const collectors = listCollectors();
const activeCollector = collectors.find((row) => row.id === list.collectorId) ?? null;
const items = listWishlistItems(list.id, list.collectorId);
// The embedded picker: missing sets that are not on this list yet. It shares // The embedded picker: missing sets that are not on this list yet. It shares
// the URL parameters with the catalog, so the filters behave identically. // the URL parameters with the catalog, so the filters behave identically.
@@ -52,6 +56,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
year: yearParam ? Number(yearParam) : undefined, year: yearParam ? Number(yearParam) : undefined,
ownership: 'fehlt', ownership: 'fehlt',
excludeWishlistId: list.id, excludeWishlistId: list.id,
collectorId: list.collectorId ?? undefined,
page, page,
}); });
@@ -63,6 +68,11 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
<Badge variant="secondary">{occasionLabel(list.occasion)}</Badge> <Badge variant="secondary">{occasionLabel(list.occasion)}</Badge>
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null} {list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</Badge> : null}
{list.surpriseMode ? <Badge variant="outline">Überraschungsmodus</Badge> : null} {list.surpriseMode ? <Badge variant="outline">Überraschungsmodus</Badge> : null}
{collectors.length > 1 && activeCollector ? (
<Badge className={collectorBadgeClass(activeCollector.color)}>
für {activeCollector.name}
</Badge>
) : null}
</div> </div>
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1> <h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
@@ -70,8 +80,10 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
</p> </p>
</div> </div>
<WishlistHeaderActions <WishlistHeaderActions
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
wishlist={{ wishlist={{
id: list.id, id: list.id,
collectorId: list.collectorId,
title: list.title, title: list.title,
occasion: list.occasion, occasion: list.occasion,
eventDate: list.eventDate, eventDate: list.eventDate,
@@ -99,7 +111,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
<CardHeader> <CardHeader>
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle> <CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
<CardDescription> <CardDescription>
Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind. Kachel {activeCollector
? `Nur Sets, die ${activeCollector.name} noch nicht hat und die noch nicht auf dieser Liste stehen.`
: 'Nur Sets, die noch nicht in der Sammlung und noch nicht auf dieser Liste sind.'}{' '} Kachel
lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen. lange drücken oder „Auswählen“ tippen, dann mehrere auf einmal übernehmen.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
@@ -124,6 +138,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
sets={missing.rows} sets={missing.rows}
wishlists={[{ id: list.id, title: list.title }]} wishlists={[{ id: list.id, title: list.title }]}
defaultWishlistId={list.id} defaultWishlistId={list.id}
collectorId={list.collectorId ?? undefined}
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht." emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
/> />
+4 -1
View File
@@ -12,6 +12,7 @@ import {
type ActionResult, type ActionResult,
} from '@/lib/action-result'; } from '@/lib/action-result';
import { ownedCopiesSql } from '@/lib/ownership'; import { ownedCopiesSql } from '@/lib/ownership';
import { defaultCollectorId } from '@/lib/queries/collectors';
import { import {
findExistingWishlistSetIds, findExistingWishlistSetIds,
getWishlist, getWishlist,
@@ -58,6 +59,7 @@ export async function createWishlist(input: unknown): Promise<ActionResult<{ id:
.values({ .values({
id, id,
ownerUserId: user.id, ownerUserId: user.id,
collectorId: parsed.data.collectorId ?? defaultCollectorId(),
title: parsed.data.title, title: parsed.data.title,
occasion: parsed.data.occasion, occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate, eventDate: parsed.data.eventDate,
@@ -83,6 +85,7 @@ export async function updateWishlist(id: string, input: unknown): Promise<Action
db.update(wishlist) db.update(wishlist)
.set({ .set({
collectorId: parsed.data.collectorId,
title: parsed.data.title, title: parsed.data.title,
occasion: parsed.data.occasion, occasion: parsed.data.occasion,
eventDate: parsed.data.eventDate, eventDate: parsed.data.eventDate,
@@ -153,7 +156,7 @@ export async function addSetsToWishlist(
id: set.id, id: set.id,
name: set.name, name: set.name,
setNumber: set.setNumber, setNumber: set.setNumber,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number), ownedCopies: ownedCopiesSql(set.id, list.collectorId).mapWith(Number),
}) })
.from(set) .from(set)
.where(inArray(set.id, setIds)) .where(inArray(set.id, setIds))
+11 -1
View File
@@ -4,8 +4,10 @@ import { CalendarDays, Gift, Link2Off } from 'lucide-react';
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog'; import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, pluralize } from '@/lib/format'; import { formatDate, pluralize } from '@/lib/format';
import { occasionLabel } from '@/lib/labels'; import { occasionLabel } from '@/lib/labels';
import { listCollectors } from '@/lib/queries/collectors';
import { listWishlists } from '@/lib/queries/wishlists'; import { listWishlists } from '@/lib/queries/wishlists';
import { requireUser } from '@/lib/session'; import { requireUser } from '@/lib/session';
@@ -14,6 +16,7 @@ export const metadata: Metadata = { title: 'Wunschlisten' };
export default async function WunschlistenPage() { export default async function WunschlistenPage() {
const user = await requireUser(); const user = await requireUser();
const lists = listWishlists(user.id); const lists = listWishlists(user.id);
const collectors = listCollectors();
return ( return (
<div className="space-y-5"> <div className="space-y-5">
@@ -24,7 +27,9 @@ export default async function WunschlistenPage() {
Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen. Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.
</p> </p>
</div> </div>
<NewWishlistButton /> <NewWishlistButton
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
/>
</div> </div>
{lists.length === 0 ? ( {lists.length === 0 ? (
@@ -54,6 +59,11 @@ export default async function WunschlistenPage() {
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="flex flex-wrap items-center gap-2 text-xs"> <CardContent className="flex flex-wrap items-center gap-2 text-xs">
{collectors.length > 1 && list.collectorName ? (
<Badge className={collectorBadgeClass(list.collectorColor)}>
{list.collectorName}
</Badge>
) : null}
<Badge variant="secondary">{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}</Badge> <Badge variant="secondary">{pluralize(list.itemCount, 'Wunsch', 'Wünsche')}</Badge>
{list.openCount > 0 ? ( {list.openCount > 0 ? (
<Badge variant="outline">{list.openCount} noch offen</Badge> <Badge variant="outline">{list.openCount} noch offen</Badge>
+1 -1
View File
@@ -27,7 +27,7 @@ export default async function PublicWishlistPage({ params }: Props) {
const list = getWishlistByShareToken(shareToken); const list = getWishlistByShareToken(shareToken);
if (!list || !list.isPublic) notFound(); if (!list || !list.isPublic) notFound();
const rows = listWishlistItems(list.id); const rows = listWishlistItems(list.id, list.collectorId);
// Fulfilled wishes are gone from the public view: the present is already // Fulfilled wishes are gone from the public view: the present is already
// under the tree, no reason to let someone buy it twice. // under the tree, no reason to let someone buy it twice.
+10 -1
View File
@@ -33,6 +33,8 @@ type Props = {
wishlists: WishlistTarget[]; wishlists: WishlistTarget[];
/** Preselected target, used by the embedded view on a wishlist page. */ /** Preselected target, used by the embedded view on a wishlist page. */
defaultWishlistId?: string; defaultWishlistId?: string;
/** A list created from a filtered view belongs to that collector. */
collectorId?: string;
emptyState?: React.ReactNode; emptyState?: React.ReactNode;
}; };
@@ -43,7 +45,13 @@ type Props = {
* On a desktop the "Auswählen" button does the same job. Sets that are already * On a desktop the "Auswählen" button does the same job. Sets that are already
* in the collection cannot be selected - they do not belong on a wishlist. * in the collection cannot be selected - they do not belong on a wishlist.
*/ */
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) { export function CatalogGrid({
sets,
wishlists,
defaultWishlistId,
collectorId,
emptyState,
}: Props) {
const router = useRouter(); const router = useRouter();
const [selecting, setSelecting] = useState(false); const [selecting, setSelecting] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set()); const [selected, setSelected] = useState<Set<string>>(new Set());
@@ -102,6 +110,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
title: newTitle, title: newTitle,
occasion: 'other', occasion: 'other',
isPublic: 'on', isPublic: 'on',
collectorId: collectorId ?? '',
}); });
if (!created.ok) { if (!created.ok) {
toast.error(created.error); toast.error(created.error);
+52 -4
View File
@@ -38,32 +38,47 @@ export function SetActions({
setName, setName,
owned, owned,
wishlists, wishlists,
collectors,
}: { }: {
setId: string; setId: string;
setName: string; setName: string;
owned: boolean; owned: boolean;
wishlists: Array<{ id: string; title: string }>; wishlists: Array<{ id: string; title: string }>;
/** More than one turns "Zur Sammlung" into a question of "for whom". */
collectors: Array<{ id: string; name: string }>;
}) { }) {
const router = useRouter(); const router = useRouter();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
const [wishlistOpen, setWishlistOpen] = useState(false); const [wishlistOpen, setWishlistOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST); const [targetId, setTargetId] = useState(wishlists[0]?.id ?? NEW_LIST);
const [collectorOpen, setCollectorOpen] = useState(false);
const [collectorId, setCollectorId] = useState(collectors[0]?.id ?? '');
const [newTitle, setNewTitle] = useState(''); const [newTitle, setNewTitle] = useState('');
const [priority, setPriority] = useState('2'); const [priority, setPriority] = useState('2');
function addToCollection() { function addToCollection(forCollectorId?: string) {
startTransition(async () => { startTransition(async () => {
const result = await addSetToCollection(setId); const result = await addSetToCollection(setId, forCollectorId);
if (!result.ok) { if (!result.ok) {
toast.error(result.error); toast.error(result.error);
return; return;
} }
toast.success('Zur Sammlung hinzugefügt.'); const owner = collectors.find((entry) => entry.id === forCollectorId);
toast.success(
owner ? `Zur Sammlung von ${owner.name} hinzugefügt.` : 'Zur Sammlung hinzugefügt.',
);
setCollectorOpen(false);
router.refresh(); router.refresh();
}); });
} }
// With a single collector there is nothing to ask.
function onAddClick() {
if (collectors.length > 1) setCollectorOpen(true);
else addToCollection();
}
function addToWishlist() { function addToWishlist() {
startTransition(async () => { startTransition(async () => {
let wishlistId = targetId; let wishlistId = targetId;
@@ -111,7 +126,7 @@ export function SetActions({
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button size="lg" onClick={addToCollection} disabled={pending}> <Button size="lg" onClick={onAddClick} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />} {pending ? <Loader2 className="animate-spin" /> : <Plus />}
Zur Sammlung hinzufügen Zur Sammlung hinzufügen
</Button> </Button>
@@ -138,6 +153,39 @@ export function SetActions({
</p> </p>
) : null} ) : null}
<Dialog open={collectorOpen} onOpenChange={setCollectorOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Wer bekommt das Set?</DialogTitle>
<DialogDescription>{setName}</DialogDescription>
</DialogHeader>
<Field label="Sammler" htmlFor="add-collector" required>
<NativeSelect
id="add-collector"
value={collectorId}
onChange={(event) => setCollectorId(event.target.value)}
>
{collectors.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</NativeSelect>
</Field>
<DialogFooter>
<Button variant="outline" onClick={() => setCollectorOpen(false)} disabled={pending}>
Abbrechen
</Button>
<Button onClick={() => addToCollection(collectorId)} disabled={pending}>
{pending ? <Loader2 className="animate-spin" /> : <Plus />}
Hinzufügen
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}> <Dialog open={wishlistOpen} onOpenChange={setWishlistOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
@@ -0,0 +1,78 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useTransition } from 'react';
import { cn } from 'cn';
import { collectorDotClass } from '@/lib/collector-colors';
export type CollectorOption = { id: string; name: string; color: string };
/**
* Switches the view between collectors.
*
* Rendered only when there is more than one - with a single collector the
* whole question does not exist, and the app should not ask it. The choice
* lives in the URL (`?sammler=`), so a filtered view stays shareable and the
* back button behaves.
*/
export function CollectorFilter({
collectors,
allLabel = 'Alle',
}: {
collectors: CollectorOption[];
allLabel?: string;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const [, startTransition] = useTransition();
if (collectors.length < 2) return null;
const current = searchParams.get('sammler') ?? '';
function select(id: string) {
const params = new URLSearchParams(searchParams.toString());
if (id) params.set('sammler', id);
else params.delete('sammler');
params.delete('seite');
const query = params.toString();
startTransition(() => router.push(query ? `${pathname}?${query}` : pathname));
}
const options = [{ id: '', name: allLabel, color: 'slate' }, ...collectors];
return (
<nav
aria-label="Sammler"
className="flex gap-1 overflow-x-auto rounded-lg bg-muted p-1"
>
{options.map((option) => {
const active = current === option.id;
return (
<button
key={option.id || 'alle'}
type="button"
aria-pressed={active}
onClick={() => select(option.id)}
className={cn(
'flex flex-1 items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium whitespace-nowrap transition-colors',
active
? 'bg-background text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground',
)}
>
{option.id ? (
<span
className={cn('size-2 rounded-full', collectorDotClass(option.color))}
aria-hidden
/>
) : null}
{option.name}
</button>
);
})}
</nav>
);
}
@@ -8,6 +8,7 @@ import { deleteCollectionItem, updateCollectionItem } from '@/app/(app)/sammlung
import type { MissingPartRow } from '@/lib/queries/collection'; import type { MissingPartRow } from '@/lib/queries/collection';
import { MissingPartsDialog } from './missing-parts-dialog'; import { MissingPartsDialog } from './missing-parts-dialog';
import type { CollectionStatus, Completeness, Condition } from '@/db/types'; import type { CollectionStatus, Completeness, Condition } from '@/db/types';
import { collectorBadgeClass } from '@/lib/collector-colors';
import { formatDate, toDateInputValue } from '@/lib/format'; import { formatDate, toDateInputValue } from '@/lib/format';
import { import {
COMPLETENESS_LABELS, COMPLETENESS_LABELS,
@@ -58,14 +59,19 @@ export type CollectionItemRow = {
notes: string | null; notes: string | null;
setName: string; setName: string;
missingParts: MissingPartRow[]; missingParts: MissingPartRow[];
collectorId: string | null;
collectorName: string | null;
collectorColor: string | null;
}; };
export function CollectionItemsSection({ export function CollectionItemsSection({
items, items,
locations, locations,
collectors,
}: { }: {
items: CollectionItemRow[]; items: CollectionItemRow[];
locations: Array<{ id: string; name: string }>; locations: Array<{ id: string; name: string }>;
collectors: Array<{ id: string; name: string }>;
}) { }) {
const router = useRouter(); const router = useRouter();
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -126,6 +132,11 @@ export function CollectionItemsSection({
<li key={item.id} className="flex items-start gap-3 p-3"> <li key={item.id} className="flex items-start gap-3 p-3">
<div className="min-w-0 flex-1 space-y-1"> <div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2"> <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'}> <Badge variant={item.status === 'sold' ? 'outline' : 'default'}>
{statusLabel(item.status)} {statusLabel(item.status)}
</Badge> </Badge>
@@ -194,6 +205,22 @@ export function CollectionItemsSection({
<form id="collection-item-form" onSubmit={submit} className="space-y-4"> <form id="collection-item-form" onSubmit={submit} className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2"> <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"> <Field label="Status" htmlFor="status">
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}> <NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
{Object.entries(STATUS_LABELS).map(([value, label]) => ( {Object.entries(STATUS_LABELS).map(([value, label]) => (
@@ -0,0 +1,87 @@
'use client';
import {
createCollector,
deleteCollector,
updateCollector,
} from '@/app/(app)/verwaltung/sammler/actions';
import { COLLECTOR_COLOR_LABELS } from '@/lib/collector-colors';
import { toDateInputValue } from '@/lib/format';
import { CrudSection, type CrudItem } from './crud-section';
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 CollectorRowItem = CrudItem & {
color: string;
birthday: Date | null;
notes: string | null;
itemCount: number;
};
export function CollectorList({ collectors }: { collectors: CollectorRowItem[] }) {
return (
<CrudSection<CollectorRowItem>
items={collectors}
emptyText="Noch kein Sammler angelegt."
createLabel="Sammler anlegen"
dialogTitle={(item) => (item ? 'Sammler bearbeiten' : 'Neuer Sammler')}
dialogDescription="Der Katalog bleibt gemeinsam – Sammlung und Wunschlisten gehören je einem Sammler."
onCreate={createCollector}
onUpdate={updateCollector}
onDelete={deleteCollector}
deleteWarning={(item) =>
item.itemCount > 0
? `„${item.title}“ hat ${item.itemCount} Sets in der Sammlung. Ordne sie erst jemand anderem zu – erst dann lässt sich der Sammler löschen.`
: `„${item.title}“ wird gelöscht. Der Katalog bleibt unverändert.`
}
renderFields={(item) => (
<>
<Field label="Name" htmlFor="name" required>
<Input
id="name"
name="name"
defaultValue={item?.title ?? ''}
required
maxLength={60}
placeholder="Max"
/>
</Field>
<Field
label="Farbe"
htmlFor="color"
hint="Nur zur Unterscheidung auf einen Blick."
>
<NativeSelect id="color" name="color" defaultValue={item?.color ?? 'blue'}>
{Object.entries(COLLECTOR_COLOR_LABELS).map(([value, label]) => (
<option key={value} value={value}>
{label}
</option>
))}
</NativeSelect>
</Field>
<Field label="Geburtstag" htmlFor="birthday" hint="Optional.">
<Input
id="birthday"
name="birthday"
type="date"
defaultValue={toDateInputValue(item?.birthday)}
/>
</Field>
<Field label="Notizen" htmlFor="collector-notes">
<Textarea
id="collector-notes"
name="notes"
defaultValue={item?.notes ?? ''}
rows={3}
/>
</Field>
</>
)}
/>
);
}
@@ -5,6 +5,7 @@ import { usePathname } from 'next/navigation';
import { cn } from 'cn'; import { cn } from 'cn';
const tabs = [ const tabs = [
{ href: '/verwaltung/sammler', label: 'Sammler', adminOnly: false },
{ href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false }, { href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false }, { href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false }, { href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
@@ -26,6 +26,7 @@ import { Textarea } from '@/components/ui/textarea';
export type WishlistFormValues = { export type WishlistFormValues = {
id: string; id: string;
collectorId: string | null;
title: string; title: string;
occasion: Occasion; occasion: Occasion;
eventDate: Date | null; eventDate: Date | null;
@@ -36,10 +37,13 @@ export type WishlistFormValues = {
export function WishlistFormDialog({ export function WishlistFormDialog({
wishlist, wishlist,
collectors = [],
open, open,
onOpenChange, onOpenChange,
}: { }: {
wishlist?: WishlistFormValues; wishlist?: WishlistFormValues;
/** Only asked about when there is more than one. */
collectors?: Array<{ id: string; name: string }>;
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
}) { }) {
@@ -99,6 +103,27 @@ export function WishlistFormDialog({
/> />
</Field> </Field>
{collectors.length > 1 ? (
<Field
label="Für wen"
htmlFor="collectorId"
hint="Bestimmt, wessen Sammlung als „hat er schon“ zählt."
>
<NativeSelect
id="collectorId"
name="collectorId"
defaultValue={wishlist?.collectorId ?? 'none'}
>
<option value="none">– alle zusammen –</option>
{collectors.map((entry) => (
<option key={entry.id} value={entry.id}>
{entry.name}
</option>
))}
</NativeSelect>
</Field>
) : null}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Field label="Anlass" htmlFor="occasion"> <Field label="Anlass" htmlFor="occasion">
<NativeSelect <NativeSelect
@@ -180,7 +205,11 @@ export function WishlistFormDialog({
} }
/** Button plus dialog, for the wishlist overview page. */ /** Button plus dialog, for the wishlist overview page. */
export function NewWishlistButton() { export function NewWishlistButton({
collectors = [],
}: {
collectors?: Array<{ id: string; name: string }>;
}) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@@ -189,7 +218,7 @@ export function NewWishlistButton() {
<Plus /> <Plus />
Neue Wunschliste Neue Wunschliste
</Button> </Button>
<WishlistFormDialog open={open} onOpenChange={setOpen} /> <WishlistFormDialog collectors={collectors} open={open} onOpenChange={setOpen} />
</> </>
); );
} }
@@ -18,7 +18,13 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
export function WishlistHeaderActions({ wishlist }: { wishlist: WishlistFormValues }) { export function WishlistHeaderActions({
wishlist,
collectors = [],
}: {
wishlist: WishlistFormValues;
collectors?: Array<{ id: string; name: string }>;
}) {
const router = useRouter(); const router = useRouter();
const [editOpen, setEditOpen] = useState(false); const [editOpen, setEditOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false);
@@ -52,7 +58,12 @@ export function WishlistHeaderActions({ wishlist }: { wishlist: WishlistFormValu
<Trash2 className="text-destructive" /> <Trash2 className="text-destructive" />
</Button> </Button>
<WishlistFormDialog wishlist={wishlist} open={editOpen} onOpenChange={setEditOpen} /> <WishlistFormDialog
wishlist={wishlist}
collectors={collectors}
open={editOpen}
onOpenChange={setEditOpen}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}> <AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent> <AlertDialogContent>
+8
View File
@@ -8,6 +8,7 @@ import {
text, text,
} from 'drizzle-orm/sqlite-core'; } from 'drizzle-orm/sqlite-core';
import { set } from './catalog'; import { set } from './catalog';
import { collector } from './collector';
export const location = sqliteTable('location', { export const location = sqliteTable('location', {
id: text('id').primaryKey(), id: text('id').primaryKey(),
@@ -31,6 +32,12 @@ export const collectionItem = sqliteTable(
setId: text('set_id') setId: text('set_id')
.notNull() .notNull()
.references(() => set.id, { onDelete: 'cascade' }), .references(() => set.id, { onDelete: 'cascade' }),
/**
* Who owns this copy. Nullable only for the sake of the migration path -
* the app always sets it, and the migration assigned every existing row to
* the first collector.
*/
collectorId: text('collector_id').references(() => collector.id, { onDelete: 'cascade' }),
status: text('status', { status: text('status', {
enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'], enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'],
}) })
@@ -54,6 +61,7 @@ export const collectionItem = sqliteTable(
}, },
(t) => [ (t) => [
index('collection_item_set_idx').on(t.setId), index('collection_item_set_idx').on(t.setId),
index('collection_item_collector_idx').on(t.collectorId),
index('collection_item_status_idx').on(t.status), index('collection_item_status_idx').on(t.status),
index('collection_item_location_idx').on(t.locationId), index('collection_item_location_idx').on(t.locationId),
], ],
+29
View File
@@ -0,0 +1,29 @@
import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core';
/**
* A collector - in practice: a child.
*
* The catalog is shared (a set exists once, no matter who owns it), while
* ownership and wishlists belong to one collector. With a single collector the
* whole concept stays invisible in the UI; it only appears once a second one
* exists, so a one-child household never pays for the feature.
*/
export const collector = sqliteTable(
'collector',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
/** For telling two collectors apart at a glance. */
color: text('color', {
enum: ['blue', 'green', 'amber', 'rose', 'violet', 'slate'],
})
.notNull()
.default('blue'),
birthday: integer('birthday', { mode: 'timestamp' }),
notes: text('notes'),
sortOrder: integer('sort_order').notNull().default(0),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
},
(t) => [index('collector_sort_idx').on(t.sortOrder, t.name)],
);
+1
View File
@@ -1,5 +1,6 @@
export * from './auth'; export * from './auth';
export * from './catalog'; export * from './catalog';
export * from './collector';
export * from './collection'; export * from './collection';
export * from './wishlist'; export * from './wishlist';
export * from './relations'; export * from './relations';
+14
View File
@@ -2,6 +2,7 @@ import { relations } from 'drizzle-orm';
import { passkey, session, user } from './auth'; import { passkey, session, user } from './auth';
import { instruction, manufacturer, set, setTag, tag, theme } from './catalog'; import { instruction, manufacturer, set, setTag, tag, theme } from './catalog';
import { collectionItem, location, missingPart, photo } from './collection'; import { collectionItem, location, missingPart, photo } from './collection';
import { collector } from './collector';
import { wishlist, wishlistItem } from './wishlist'; import { wishlist, wishlistItem } from './wishlist';
export const userRelations = relations(user, ({ many }) => ({ export const userRelations = relations(user, ({ many }) => ({
@@ -73,8 +74,17 @@ export const locationRelations = relations(location, ({ one, many }) => ({
collectionItems: many(collectionItem), collectionItems: many(collectionItem),
})); }));
export const collectorRelations = relations(collector, ({ many }) => ({
collectionItems: many(collectionItem),
wishlists: many(wishlist),
}));
export const collectionItemRelations = relations(collectionItem, ({ one, many }) => ({ export const collectionItemRelations = relations(collectionItem, ({ one, many }) => ({
set: one(set, { fields: [collectionItem.setId], references: [set.id] }), set: one(set, { fields: [collectionItem.setId], references: [set.id] }),
collector: one(collector, {
fields: [collectionItem.collectorId],
references: [collector.id],
}),
location: one(location, { location: one(location, {
fields: [collectionItem.locationId], fields: [collectionItem.locationId],
references: [location.id], references: [location.id],
@@ -100,6 +110,10 @@ export const photoRelations = relations(photo, ({ one }) => ({
export const wishlistRelations = relations(wishlist, ({ one, many }) => ({ export const wishlistRelations = relations(wishlist, ({ one, many }) => ({
owner: one(user, { fields: [wishlist.ownerUserId], references: [user.id] }), owner: one(user, { fields: [wishlist.ownerUserId], references: [user.id] }),
collector: one(collector, {
fields: [wishlist.collectorId],
references: [collector.id],
}),
items: many(wishlistItem), items: many(wishlistItem),
})); }));
+6
View File
@@ -2,6 +2,7 @@ import { sql } from 'drizzle-orm';
import { check, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { check, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
import { user } from './auth'; import { user } from './auth';
import { set } from './catalog'; import { set } from './catalog';
import { collector } from './collector';
export const wishlist = sqliteTable( export const wishlist = sqliteTable(
'wishlist', 'wishlist',
@@ -10,6 +11,11 @@ export const wishlist = sqliteTable(
ownerUserId: text('owner_user_id') ownerUserId: text('owner_user_id')
.notNull() .notNull()
.references(() => user.id, { onDelete: 'cascade' }), .references(() => user.id, { onDelete: 'cascade' }),
/**
* Whose list this is. Null means "not tied to one collector" - useful
* while there is only one, and for a shared list.
*/
collectorId: text('collector_id').references(() => collector.id, { onDelete: 'set null' }),
/** e.g. "Weihnachten 2026". */ /** e.g. "Weihnachten 2026". */
title: text('title').notNull(), title: text('title').notNull(),
occasion: text('occasion', { enum: ['christmas', 'birthday', 'easter', 'other'] }) occasion: text('occasion', { enum: ['christmas', 'birthday', 'easter', 'other'] })
+40
View File
@@ -0,0 +1,40 @@
/**
* The small colour palette that tells collectors apart.
*
* Fixed classes rather than computed ones, because Tailwind only ships the
* classes it can see in the source.
*/
export const COLLECTOR_COLOR_LABELS: Record<string, string> = {
blue: 'Blau',
green: 'Grün',
amber: 'Gelb',
rose: 'Rot',
violet: 'Violett',
slate: 'Grau',
};
const BADGE_CLASSES: Record<string, string> = {
blue: 'bg-blue-100 text-blue-900 dark:bg-blue-950 dark:text-blue-200',
green: 'bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200',
amber: 'bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200',
rose: 'bg-rose-100 text-rose-900 dark:bg-rose-950 dark:text-rose-200',
violet: 'bg-violet-100 text-violet-900 dark:bg-violet-950 dark:text-violet-200',
slate: 'bg-slate-200 text-slate-900 dark:bg-slate-800 dark:text-slate-100',
};
export function collectorBadgeClass(color: string | null | undefined): string {
return BADGE_CLASSES[color ?? 'blue'] ?? BADGE_CLASSES.blue!;
}
const DOT_CLASSES: Record<string, string> = {
blue: 'bg-blue-500',
green: 'bg-emerald-500',
amber: 'bg-amber-500',
rose: 'bg-rose-500',
violet: 'bg-violet-500',
slate: 'bg-slate-500',
};
export function collectorDotClass(color: string | null | undefined): string {
return DOT_CLASSES[color ?? 'blue'] ?? DOT_CLASSES.blue!;
}
+65 -8
View File
@@ -8,8 +8,9 @@ import type { CollectionStatus } from '@/db/types';
* status is anything but `sold`. A sold copy is history, not ownership - so a * status is anything but `sold`. A sold copy is history, not ownership - so a
* set that was sold shows up under "fehlt" again and can go back on a wishlist. * set that was sold shows up under "fehlt" again and can go back on a wishlist.
* *
* Every query that asks about ownership goes through this module. If the rule * With more than one collector the question gains a "for whom": the catalog is
* ever changes, it changes here. * shared, ownership is not. Every query that asks about ownership goes through
* this module, optionally scoped to one collector.
*/ */
export const OWNED_STATUSES = ['owned', 'built', 'disassembled', 'lent_out'] as const; export const OWNED_STATUSES = ['owned', 'built', 'disassembled', 'lent_out'] as const;
@@ -18,6 +19,12 @@ export const SOLD_STATUS: CollectionStatus = 'sold';
export type OwnedStatus = (typeof OWNED_STATUSES)[number]; export type OwnedStatus = (typeof OWNED_STATUSES)[number];
/** One owned copy, as far as ownership is concerned. */
export type OwnableCopy = {
status: CollectionStatus;
collectorId?: string | null;
};
/** Does a single collection item count towards ownership? */ /** Does a single collection item count towards ownership? */
export function isOwningStatus(status: CollectionStatus): status is OwnedStatus { export function isOwningStatus(status: CollectionStatus): status is OwnedStatus {
return status !== SOLD_STATUS; return status !== SOLD_STATUS;
@@ -37,22 +44,72 @@ export function countOwnedCopies(statuses: readonly CollectionStatus[]): number
} }
/** /**
* SQL counterpart of `countOwnedCopies`, as a correlated subquery. * Ownership from the point of view of one collector.
*
* Without a collector the question is "does anyone here have it", which is
* what a single-collector household asks anyway.
*/
export function countOwnedCopiesFor(
copies: readonly OwnableCopy[],
collectorId?: string | null,
): number {
return copies.filter(
(copy) =>
isOwningStatus(copy.status) &&
(collectorId === undefined || collectorId === null || copy.collectorId === collectorId),
).length;
}
export function isSetOwnedBy(
copies: readonly OwnableCopy[],
collectorId?: string | null,
): boolean {
return countOwnedCopiesFor(copies, collectorId) > 0;
}
/**
* SQL counterpart of `countOwnedCopiesFor`, as a correlated subquery.
* *
* A subquery instead of a join, because a set owned twice must not appear * A subquery instead of a join, because a set owned twice must not appear
* twice in the catalog list. * twice in the catalog list.
*/ */
export function ownedCopiesSql(setIdColumn: SQL | unknown): SQL<number> { export function ownedCopiesSql(
setIdColumn: SQL | unknown,
collectorId?: string | null,
): SQL<number> {
if (!collectorId) {
return sql<number>`(
select count(*) from collection_item ci
where ci.set_id = ${setIdColumn} and ci.status <> ${SOLD_STATUS}
)`;
}
return sql<number>`( return sql<number>`(
select count(*) from collection_item ci select count(*) from collection_item ci
where ci.set_id = ${setIdColumn} and ci.status <> ${SOLD_STATUS} where ci.set_id = ${setIdColumn}
and ci.status <> ${SOLD_STATUS}
and ci.collector_id = ${collectorId}
)`; )`;
} }
/** How many wishlists this set is currently on. */ /**
export function wishlistCountSql(setIdColumn: SQL | unknown): SQL<number> { * How many wishlists this set is on. Scoped to a collector where one is
* selected - a set on the other child's list is not "already wished for" here.
*/
export function wishlistCountSql(
setIdColumn: SQL | unknown,
collectorId?: string | null,
): SQL<number> {
if (!collectorId) {
return sql<number>`(
select count(*) from wishlist_item wi where wi.set_id = ${setIdColumn}
)`;
}
return sql<number>`( return sql<number>`(
select count(*) from wishlist_item wi where wi.set_id = ${setIdColumn} select count(*) from wishlist_item wi
join wishlist w on w.id = wi.wishlist_id
where wi.set_id = ${setIdColumn} and w.collector_id = ${collectorId}
)`; )`;
} }
+9 -7
View File
@@ -14,6 +14,8 @@ export type CatalogFilters = {
ownership?: OwnershipFilter; ownership?: OwnershipFilter;
/** Hides sets that are already on this wishlist (for "aus fehlenden Sets hinzufügen"). */ /** Hides sets that are already on this wishlist (for "aus fehlenden Sets hinzufügen"). */
excludeWishlistId?: string; excludeWishlistId?: string;
/** Scopes "owned" and "on a wishlist" to one collector. */
collectorId?: string;
page?: number; page?: number;
}; };
@@ -52,7 +54,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
if (filters.themeId) conditions.push(eq(set.themeId, filters.themeId)); if (filters.themeId) conditions.push(eq(set.themeId, filters.themeId));
if (filters.year !== undefined) conditions.push(eq(set.year, filters.year)); if (filters.year !== undefined) conditions.push(eq(set.year, filters.year));
const owned = ownedCopiesSql(set.id); const owned = ownedCopiesSql(set.id, filters.collectorId);
switch (filters.ownership) { switch (filters.ownership) {
case 'sammlung': case 'sammlung':
conditions.push(sql`${owned} > 0`); conditions.push(sql`${owned} > 0`);
@@ -61,7 +63,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
conditions.push(sql`${owned} = 0`); conditions.push(sql`${owned} = 0`);
break; break;
case 'wunschliste': case 'wunschliste':
conditions.push(sql`${wishlistCountSql(set.id)} > 0`); conditions.push(sql`${wishlistCountSql(set.id, filters.collectorId)} > 0`);
break; break;
default: default:
break; break;
@@ -95,8 +97,8 @@ export function listSets(filters: CatalogFilters): { rows: CatalogRow[]; total:
retailPriceCents: set.retailPriceCents, retailPriceCents: set.retailPriceCents,
manufacturerName: manufacturer.name, manufacturerName: manufacturer.name,
themeName: theme.name, themeName: theme.name,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number), ownedCopies: ownedCopiesSql(set.id, filters.collectorId).mapWith(Number),
wishlistCount: wishlistCountSql(set.id).mapWith(Number), wishlistCount: wishlistCountSql(set.id, filters.collectorId).mapWith(Number),
}) })
.from(set) .from(set)
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId)) .innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
@@ -126,7 +128,7 @@ export function listSetIds(filters: CatalogFilters): string[] {
.map((row) => row.id); .map((row) => row.id);
} }
export function getSetById(id: string) { export function getSetById(id: string, collectorId?: string) {
const row = db const row = db
.select({ .select({
id: set.id, id: set.id,
@@ -148,8 +150,8 @@ export function getSetById(id: string) {
notes: set.notes, notes: set.notes,
createdAt: set.createdAt, createdAt: set.createdAt,
updatedAt: set.updatedAt, updatedAt: set.updatedAt,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number), ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
wishlistCount: wishlistCountSql(set.id).mapWith(Number), wishlistCount: wishlistCountSql(set.id, collectorId).mapWith(Number),
}) })
.from(set) .from(set)
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId)) .innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
+28 -11
View File
@@ -1,8 +1,16 @@
import 'server-only'; import 'server-only';
import { and, asc, countDistinct, eq, inArray, 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, missingPart, set, theme } from '@/db/schema'; import {
import { SOLD_STATUS } from '@/lib/ownership'; collectionItem,
collector,
location,
manufacturer,
missingPart,
set,
theme,
} from '@/db/schema';
import { ownedCopiesSql, SOLD_STATUS } from '@/lib/ownership';
import type { CollectionStatus, Completeness, Condition } from '@/db/types'; import type { CollectionStatus, Completeness, Condition } from '@/db/types';
export type CollectionFilters = { export type CollectionFilters = {
@@ -11,6 +19,7 @@ export type CollectionFilters = {
completeness?: Completeness; completeness?: Completeness;
locationId?: string; locationId?: string;
manufacturerId?: string; manufacturerId?: string;
collectorId?: string;
}; };
export type CollectionRow = { export type CollectionRow = {
@@ -21,6 +30,9 @@ export type CollectionRow = {
condition: Condition | null; condition: Condition | null;
locationId: string | null; locationId: string | null;
locationName: string | null; locationName: string | null;
collectorId: string | null;
collectorName: string | null;
collectorColor: string | null;
acquiredAt: Date | null; acquiredAt: Date | null;
acquiredPriceCents: number | null; acquiredPriceCents: number | null;
acquiredFrom: string | null; acquiredFrom: string | null;
@@ -51,6 +63,7 @@ function buildWhere(filters: CollectionFilters): SQL | undefined {
if (filters.completeness) conditions.push(eq(collectionItem.completeness, filters.completeness)); if (filters.completeness) conditions.push(eq(collectionItem.completeness, filters.completeness));
if (filters.locationId) conditions.push(eq(collectionItem.locationId, filters.locationId)); if (filters.locationId) conditions.push(eq(collectionItem.locationId, filters.locationId));
if (filters.manufacturerId) conditions.push(eq(set.manufacturerId, filters.manufacturerId)); if (filters.manufacturerId) conditions.push(eq(set.manufacturerId, filters.manufacturerId));
if (filters.collectorId) conditions.push(eq(collectionItem.collectorId, filters.collectorId));
return conditions.length > 0 ? and(...conditions) : undefined; return conditions.length > 0 ? and(...conditions) : undefined;
} }
@@ -65,6 +78,9 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
condition: collectionItem.condition, condition: collectionItem.condition,
locationId: collectionItem.locationId, locationId: collectionItem.locationId,
locationName: location.name, locationName: location.name,
collectorId: collectionItem.collectorId,
collectorName: collector.name,
collectorColor: collector.color,
acquiredAt: collectionItem.acquiredAt, acquiredAt: collectionItem.acquiredAt,
acquiredPriceCents: collectionItem.acquiredPriceCents, acquiredPriceCents: collectionItem.acquiredPriceCents,
acquiredFrom: collectionItem.acquiredFrom, acquiredFrom: collectionItem.acquiredFrom,
@@ -86,6 +102,7 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId)) .innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
.leftJoin(theme, eq(theme.id, set.themeId)) .leftJoin(theme, eq(theme.id, set.themeId))
.leftJoin(location, eq(location.id, collectionItem.locationId)) .leftJoin(location, eq(location.id, collectionItem.locationId))
.leftJoin(collector, eq(collector.id, collectionItem.collectorId))
.where(buildWhere(filters)) .where(buildWhere(filters))
.orderBy(asc(manufacturer.name), asc(set.setNumber)) .orderBy(asc(manufacturer.name), asc(set.setNumber))
.all(); .all();
@@ -104,8 +121,13 @@ export type CollectionStats = {
* The numbers on top of the collection page. Sold copies are history and do * The numbers on top of the collection page. Sold copies are history and do
* not count anywhere - same rule as everywhere else in the app. * not count anywhere - same rule as everywhere else in the app.
*/ */
export function getCollectionStats(): CollectionStats { export function getCollectionStats(collectorId?: string): CollectionStats {
const active = sql`${collectionItem.status} <> ${SOLD_STATUS}`; const active = collectorId
? and(
sql`${collectionItem.status} <> ${SOLD_STATUS}`,
eq(collectionItem.collectorId, collectorId),
)
: sql`${collectionItem.status} <> ${SOLD_STATUS}`;
const [totals] = db const [totals] = db
.select({ .select({
@@ -144,16 +166,11 @@ export function getCollectionStats(): CollectionStats {
} }
/** Number of catalog sets that are not in the collection - the "fehlt" count. */ /** Number of catalog sets that are not in the collection - the "fehlt" count. */
export function countMissingSets(): number { export function countMissingSets(collectorId?: string): number {
const [row] = db const [row] = db
.select({ total: sql<number>`count(*)`.mapWith(Number) }) .select({ total: sql<number>`count(*)`.mapWith(Number) })
.from(set) .from(set)
.where( .where(sql`${ownedCopiesSql(set.id, collectorId)} = 0`)
sql`not exists (
select 1 from collection_item ci
where ci.set_id = ${set.id} and ci.status <> ${SOLD_STATUS}
)`,
)
.all(); .all();
return row?.total ?? 0; return row?.total ?? 0;
+89
View File
@@ -0,0 +1,89 @@
import 'server-only';
import { asc, eq, sql } from 'drizzle-orm';
import { db } from '@/db';
import { collectionItem, collector } from '@/db/schema';
import { SOLD_STATUS } from '@/lib/ownership';
export type CollectorRow = {
id: string;
name: string;
color: string;
birthday: Date | null;
notes: string | null;
sortOrder: number;
itemCount: number;
};
export function listCollectors(): CollectorRow[] {
const itemCount = sql<number>`(
select count(*) from collection_item ci
where ci.collector_id = ${collector.id} and ci.status <> ${SOLD_STATUS}
)`.mapWith(Number);
return db
.select({
id: collector.id,
name: collector.name,
color: collector.color,
birthday: collector.birthday,
notes: collector.notes,
sortOrder: collector.sortOrder,
itemCount,
})
.from(collector)
.orderBy(asc(collector.sortOrder), asc(collector.name))
.all();
}
export function getCollector(id: string) {
return db.select().from(collector).where(eq(collector.id, id)).get() ?? null;
}
/**
* The collector a new collection item belongs to when the user did not pick
* one - which is every time there is only a single collector.
*/
export function defaultCollectorId(): string | null {
const row = db
.select({ id: collector.id })
.from(collector)
.orderBy(asc(collector.sortOrder), asc(collector.name))
.get();
return row?.id ?? null;
}
/**
* The whole multi-collector idea only becomes visible once a second collector
* exists. Until then the UI stays exactly as simple as it was.
*/
export function hasMultipleCollectors(): boolean {
const [row] = db
.select({ total: sql<number>`count(*)`.mapWith(Number) })
.from(collector)
.all();
return (row?.total ?? 0) > 1;
}
/** Reads the collector from a URL parameter, ignoring anything unknown. */
export function resolveCollectorId(value: string | undefined | null): string | undefined {
if (!value) return undefined;
const row = db.select({ id: collector.id }).from(collector).where(eq(collector.id, value)).get();
return row?.id;
}
/** Owned copies per collector - used by the collector management page. */
export function countItemsPerCollector(): Map<string, number> {
const rows = db
.select({
collectorId: collectionItem.collectorId,
total: sql<number>`count(*)`.mapWith(Number),
})
.from(collectionItem)
.where(sql`${collectionItem.status} <> ${SOLD_STATUS}`)
.groupBy(collectionItem.collectorId)
.all();
return new Map(
rows.flatMap((row) => (row.collectorId ? [[row.collectorId, row.total] as const] : [])),
);
}
+25 -7
View File
@@ -1,8 +1,8 @@
import 'server-only'; import 'server-only';
import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm'; import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema'; import { collector, manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
import { ownedCopiesSql } from '@/lib/ownership'; import { ownedCopiesSql, SOLD_STATUS } from '@/lib/ownership';
/** Short list for the "auf welche Wunschliste?" dialogs. */ /** Short list for the "auf welche Wunschliste?" dialogs. */
export function listWishlistTargets() { export function listWishlistTargets() {
@@ -34,12 +34,16 @@ export function listWishlists(ownerUserId: string) {
isPublic: wishlist.isPublic, isPublic: wishlist.isPublic,
surpriseMode: wishlist.surpriseMode, surpriseMode: wishlist.surpriseMode,
shareToken: wishlist.shareToken, shareToken: wishlist.shareToken,
collectorId: wishlist.collectorId,
collectorName: collector.name,
collectorColor: collector.color,
createdAt: wishlist.createdAt, createdAt: wishlist.createdAt,
itemCount: count(wishlistItem.id), itemCount: count(wishlistItem.id),
openCount: openItems, openCount: openItems,
}) })
.from(wishlist) .from(wishlist)
.leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id)) .leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id))
.leftJoin(collector, eq(collector.id, wishlist.collectorId))
.where(eq(wishlist.ownerUserId, ownerUserId)) .where(eq(wishlist.ownerUserId, ownerUserId))
.groupBy(wishlist.id) .groupBy(wishlist.id)
.orderBy(asc(wishlist.title)) .orderBy(asc(wishlist.title))
@@ -76,7 +80,10 @@ export type WishlistItemRow = {
ownedCopies: number; ownedCopies: number;
}; };
export function listWishlistItems(wishlistId: string): WishlistItemRow[] { export function listWishlistItems(
wishlistId: string,
collectorId?: string | null,
): WishlistItemRow[] {
return db return db
.select({ .select({
id: wishlistItem.id, id: wishlistItem.id,
@@ -97,7 +104,7 @@ export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
manufacturerName: manufacturer.name, manufacturerName: manufacturer.name,
manufacturerWebsite: manufacturer.website, manufacturerWebsite: manufacturer.website,
themeName: theme.name, themeName: theme.name,
ownedCopies: ownedCopiesSql(set.id).mapWith(Number), ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
}) })
.from(wishlistItem) .from(wishlistItem)
.innerJoin(set, eq(set.id, wishlistItem.setId)) .innerJoin(set, eq(set.id, wishlistItem.setId))
@@ -145,17 +152,28 @@ export function listWishlistsForSet(setId: string) {
* Marks wishlist entries as fulfilled once the set is in the collection, and * Marks wishlist entries as fulfilled once the set is in the collection, and
* clears the flag again if the copy was sold. Called after every change to the * clears the flag again if the copy was sold. Called after every change to the
* collection, so the lists stay honest without a background job. * collection, so the lists stay honest without a background job.
*
* With several collectors the question is per list: a wish on Max's list is
* fulfilled when *Max* owns the set, not when his sister does. A list without
* a collector falls back to "anyone here owns it".
*/ */
export function reconcileFulfilledWishlistItems(): void { export function reconcileFulfilledWishlistItems(): void {
const owned = ownedCopiesSql(wishlistItem.setId); const ownedForThisList = sql`exists (
select 1
from collection_item ci
join wishlist w on w.id = ${wishlistItem.wishlistId}
where ci.set_id = ${wishlistItem.setId}
and ci.status <> ${SOLD_STATUS}
and (w.collector_id is null or ci.collector_id = w.collector_id)
)`;
db.update(wishlistItem) db.update(wishlistItem)
.set({ fulfilledAt: new Date() }) .set({ fulfilledAt: new Date() })
.where(and(isNull(wishlistItem.fulfilledAt), sql`${owned} > 0`)) .where(and(isNull(wishlistItem.fulfilledAt), ownedForThisList))
.run(); .run();
db.update(wishlistItem) db.update(wishlistItem)
.set({ fulfilledAt: null }) .set({ fulfilledAt: null })
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`${owned} = 0`)) .where(and(isNotNull(wishlistItem.fulfilledAt), sql`not ${ownedForThisList}`))
.run(); .run();
} }
+19
View File
@@ -88,6 +88,22 @@ export const locationSchema = z.object({
parentLocationId: optionalId, parentLocationId: optionalId,
}); });
export const collectorColorEnum = z.enum([
'blue',
'green',
'amber',
'rose',
'violet',
'slate',
]);
export const collectorSchema = z.object({
name: z.string().trim().min(1, 'Bitte einen Namen angeben.').max(60),
color: collectorColorEnum.default('blue'),
birthday: optionalDate,
notes: optionalText(500),
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Catalog // Catalog
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -127,6 +143,7 @@ export const conditionEnum = z.enum(['new_sealed', 'like_new', 'good', 'used', '
export const collectionItemSchema = z.object({ export const collectionItemSchema = z.object({
setId: z.string().trim().min(1), setId: z.string().trim().min(1),
collectorId: optionalId,
status: collectionStatusEnum.default('owned'), status: collectionStatusEnum.default('owned'),
completeness: completenessEnum.default('unknown'), completeness: completenessEnum.default('unknown'),
condition: z condition: z
@@ -171,6 +188,7 @@ export const occasionEnum = z.enum(['christmas', 'birthday', 'easter', 'other'])
export const wishlistSchema = z.object({ export const wishlistSchema = z.object({
title: z.string().trim().min(1, 'Bitte einen Titel angeben.').max(80), title: z.string().trim().min(1, 'Bitte einen Titel angeben.').max(80),
collectorId: optionalId,
occasion: occasionEnum.default('other'), occasion: occasionEnum.default('other'),
eventDate: optionalDate, eventDate: optionalDate,
note: optionalText(1000), note: optionalText(1000),
@@ -205,3 +223,4 @@ export type LocationInput = z.infer<typeof locationSchema>;
export type SetInput = z.infer<typeof setSchema>; export type SetInput = z.infer<typeof setSchema>;
export type CollectionItemInput = z.infer<typeof collectionItemSchema>; export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
export type WishlistInput = z.infer<typeof wishlistSchema>; export type WishlistInput = z.infer<typeof wishlistSchema>;
export type CollectorInput = z.infer<typeof collectorSchema>;
+46
View File
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
import type { CollectionStatus } from '@/db/types'; import type { CollectionStatus } from '@/db/types';
import { import {
countOwnedCopies, countOwnedCopies,
countOwnedCopiesFor,
isOwningStatus, isOwningStatus,
isSetOwned, isSetOwned,
isSetOwnedBy,
OWNED_STATUSES, OWNED_STATUSES,
parseOwnershipFilter, parseOwnershipFilter,
} from '@/lib/ownership'; } from '@/lib/ownership';
@@ -59,3 +61,47 @@ describe('Besitz-Filter aus der URL', () => {
expect(parseOwnershipFilter('')).toBe('alle'); expect(parseOwnershipFilter('')).toBe('alle');
}); });
}); });
describe('Besitz je Sammler', () => {
const copies = [
{ status: 'built' as const, collectorId: 'max' },
{ status: 'owned' as const, collectorId: 'mia' },
{ status: 'sold' as const, collectorId: 'max' },
];
it('zaehlt ohne Sammler alles, was irgendjemand hier hat', () => {
expect(countOwnedCopiesFor(copies)).toBe(2);
expect(isSetOwnedBy(copies)).toBe(true);
});
it('zaehlt mit Sammler nur dessen Exemplare', () => {
expect(countOwnedCopiesFor(copies, 'max')).toBe(1);
expect(countOwnedCopiesFor(copies, 'mia')).toBe(1);
});
it('sieht ein Set als fehlend, wenn nur das Geschwisterkind es hat', () => {
const onlyMia = [{ status: 'built' as const, collectorId: 'mia' }];
expect(isSetOwnedBy(onlyMia, 'mia')).toBe(true);
// Genau der Fall, fuer den es mehrere Sammler gibt.
expect(isSetOwnedBy(onlyMia, 'max')).toBe(false);
});
it('ignoriert verkaufte Exemplare auch je Sammler', () => {
const sold = [{ status: 'sold' as const, collectorId: 'max' }];
expect(isSetOwnedBy(sold, 'max')).toBe(false);
expect(isSetOwnedBy(sold)).toBe(false);
});
it('behandelt einen unbekannten Sammler als "hat nichts"', () => {
expect(countOwnedCopiesFor(copies, 'niemand')).toBe(0);
});
it('kommt mit Exemplaren ohne Sammler klar', () => {
const unassigned = [{ status: 'owned' as const, collectorId: null }];
expect(isSetOwnedBy(unassigned)).toBe(true);
expect(isSetOwnedBy(unassigned, 'max')).toBe(false);
});
});