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:
@@ -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
@@ -8,6 +8,13 @@
|
||||
"when": 1788726503038,
|
||||
"tag": "0000_rapid_gargoyle",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "6",
|
||||
"when": 1788763704015,
|
||||
"tag": "0001_lumpy_bill_hollister",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
+90
-8
@@ -33,11 +33,12 @@ type SeedSet = {
|
||||
minifigCount: number | null;
|
||||
retailPriceCents: number;
|
||||
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?: {
|
||||
status: 'owned' | 'built' | 'disassembled' | 'lent_out' | 'sold';
|
||||
completeness: 'complete' | 'missing_parts' | 'unknown';
|
||||
location: string;
|
||||
collector: string;
|
||||
giftedBy?: string;
|
||||
};
|
||||
};
|
||||
@@ -59,6 +60,13 @@ const THEMES: Array<{ manufacturer: string; name: string }> = [
|
||||
|
||||
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[] = [
|
||||
{
|
||||
manufacturer: 'Lego',
|
||||
@@ -70,7 +78,12 @@ const SETS: SeedSet[] = [
|
||||
minifigCount: 4,
|
||||
retailPriceCents: 4999,
|
||||
ageRange: '6-12',
|
||||
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
|
||||
owned: {
|
||||
status: 'built',
|
||||
completeness: 'complete',
|
||||
location: 'Regal Kinderzimmer',
|
||||
collector: 'Max',
|
||||
},
|
||||
},
|
||||
{
|
||||
manufacturer: 'Lego',
|
||||
@@ -86,6 +99,7 @@ const SETS: SeedSet[] = [
|
||||
status: 'owned',
|
||||
completeness: 'missing_parts',
|
||||
location: 'Kiste Keller 3',
|
||||
collector: 'Max',
|
||||
giftedBy: 'Oma Helga',
|
||||
},
|
||||
},
|
||||
@@ -99,7 +113,12 @@ const SETS: SeedSet[] = [
|
||||
minifigCount: null,
|
||||
retailPriceCents: 999,
|
||||
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',
|
||||
@@ -144,7 +163,12 @@ const SETS: SeedSet[] = [
|
||||
minifigCount: 5,
|
||||
retailPriceCents: 12999,
|
||||
ageRange: '4-10',
|
||||
owned: { status: 'built', completeness: 'complete', location: 'Regal Kinderzimmer' },
|
||||
owned: {
|
||||
status: 'built',
|
||||
completeness: 'complete',
|
||||
location: 'Regal Kinderzimmer',
|
||||
collector: 'Mia',
|
||||
},
|
||||
},
|
||||
{
|
||||
manufacturer: 'Playmobil',
|
||||
@@ -241,6 +265,59 @@ function seedThemes(manufacturers: Map<string, string>) {
|
||||
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() {
|
||||
const byName = new Map<string, string>();
|
||||
|
||||
@@ -270,6 +347,7 @@ function seedSets(
|
||||
manufacturers: Map<string, string>,
|
||||
themes: Map<string, string>,
|
||||
locations: Map<string, string>,
|
||||
collectors: Map<string, string>,
|
||||
) {
|
||||
const setIds = new Map<string, string>();
|
||||
|
||||
@@ -317,6 +395,7 @@ function seedSets(
|
||||
completeness: entry.owned.completeness,
|
||||
condition: 'good',
|
||||
locationId: locations.get(entry.owned.location) ?? null,
|
||||
collectorId: collectors.get(entry.owned.collector) ?? null,
|
||||
acquiredAt: new Date(entry.year, 11, 24),
|
||||
acquiredPriceCents: entry.retailPriceCents,
|
||||
giftedBy: entry.owned.giftedBy ?? null,
|
||||
@@ -330,7 +409,7 @@ function seedSets(
|
||||
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();
|
||||
if (!owner) {
|
||||
console.log('[seed] Kein Nutzer vorhanden - die Beispiel-Wunschliste wird uebersprungen.');
|
||||
@@ -353,6 +432,7 @@ function seedWishlist(setIds: Map<string, string>) {
|
||||
.values({
|
||||
id: wishlistId,
|
||||
ownerUserId: owner.id,
|
||||
collectorId: collectors.get('Max') ?? null,
|
||||
title: 'Weihnachten 2026',
|
||||
occasion: 'christmas',
|
||||
eventDate: new Date(2026, 11, 24),
|
||||
@@ -389,10 +469,12 @@ console.log('[seed] Beispieldaten werden eingespielt …');
|
||||
const manufacturers = seedManufacturers();
|
||||
const themes = seedThemes(manufacturers);
|
||||
const locations = seedLocations();
|
||||
const setIds = seedSets(manufacturers, themes, locations);
|
||||
seedWishlist(setIds);
|
||||
const collectors = seedCollectors();
|
||||
const setIds = seedSets(manufacturers, themes, locations, collectors);
|
||||
seedWishlist(setIds, collectors);
|
||||
|
||||
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();
|
||||
|
||||
@@ -19,23 +19,34 @@ import {
|
||||
listPhotosForSet,
|
||||
} from '@/lib/queries/catalog';
|
||||
import { listMissingParts } from '@/lib/queries/collection';
|
||||
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
||||
import { listLocations } from '@/lib/queries/master-data';
|
||||
import { listWishlistsForSet, listWishlistTargets } from '@/lib/queries/wishlists';
|
||||
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 set = getSetById(id);
|
||||
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();
|
||||
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();
|
||||
|
||||
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 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) => ({
|
||||
...item,
|
||||
locationName: item.locationId ? (locationNames.get(item.locationId) ?? null) : null,
|
||||
setName: `${set.setNumber} · ${set.name}`,
|
||||
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]> = [
|
||||
@@ -113,6 +129,7 @@ export default async function SetDetailPage({ params }: Props) {
|
||||
setName={`${set.setNumber} · ${set.name}`}
|
||||
owned={owned}
|
||||
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">
|
||||
@@ -144,7 +161,11 @@ export default async function SetDetailPage({ params }: Props) {
|
||||
<CardTitle>In der Sammlung</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CollectionItemsSection items={collectionItems} locations={locations} />
|
||||
<CollectionItemsSection
|
||||
items={collectionItems}
|
||||
locations={locations}
|
||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { CollectorFilter } from '@/components/layout/collector-filter';
|
||||
import { BarcodeScannerButton } from '@/components/katalog/barcode-scanner';
|
||||
import { CatalogFilters } from '@/components/katalog/catalog-filters';
|
||||
import { CatalogGrid } from '@/components/katalog/catalog-grid';
|
||||
@@ -9,6 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { pluralize } from '@/lib/format';
|
||||
import { OWNERSHIP_FILTER_LABELS, parseOwnershipFilter } from '@/lib/ownership';
|
||||
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 { listWishlistTargets } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
@@ -27,6 +29,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
||||
const params = await searchParams;
|
||||
|
||||
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 page = Number(single(params.seite) ?? '1') || 1;
|
||||
|
||||
@@ -36,6 +41,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
||||
themeId: single(params.thema),
|
||||
year: yearParam ? Number(yearParam) : undefined,
|
||||
ownership,
|
||||
collectorId,
|
||||
page,
|
||||
};
|
||||
|
||||
@@ -57,7 +63,9 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{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.'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -72,6 +80,11 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear
|
||||
</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() }} />
|
||||
|
||||
<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]}` : ''}
|
||||
</p>
|
||||
|
||||
<CatalogGrid sets={rows} wishlists={wishlists} />
|
||||
<CatalogGrid sets={rows} wishlists={wishlists} collectorId={collectorId} />
|
||||
|
||||
<Pagination page={page} total={total} pageSize={CATALOG_PAGE_SIZE} />
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
parseForm,
|
||||
type ActionResult,
|
||||
} from '@/lib/action-result';
|
||||
import { defaultCollectorId } from '@/lib/queries/collectors';
|
||||
import { reconcileFulfilledWishlistItems } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
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
|
||||
* 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();
|
||||
|
||||
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.');
|
||||
|
||||
// With a single collector nobody is asked - the copy simply belongs to them.
|
||||
const owner = collectorId ?? defaultCollectorId();
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
db.insert(collectionItem)
|
||||
.values({
|
||||
id,
|
||||
setId,
|
||||
collectorId: owner,
|
||||
status: 'owned',
|
||||
completeness: 'unknown',
|
||||
createdAt: now(),
|
||||
@@ -68,6 +76,7 @@ export async function updateCollectionItem(id: string, input: unknown): Promise<
|
||||
|
||||
db.update(collectionItem)
|
||||
.set({
|
||||
collectorId: parsed.data.collectorId ?? existing.collectorId,
|
||||
status: parsed.data.status,
|
||||
completeness: parsed.data.completeness,
|
||||
condition: parsed.data.condition as Condition | null,
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
import { Boxes, Euro, ImageOff, Layers, PackageSearch } from 'lucide-react';
|
||||
import { CollectorFilter } from '@/components/layout/collector-filter';
|
||||
import { CollectionFilters } from '@/components/sammlung/collection-filters';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { formatDate, formatNumber, pluralize } from '@/lib/format';
|
||||
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||
import { setImageSrc } from '@/lib/images';
|
||||
import { completenessLabel, statusLabel } from '@/lib/labels';
|
||||
import { formatEuro } from '@/lib/money';
|
||||
@@ -15,6 +17,7 @@ import {
|
||||
listCollectionItems,
|
||||
type CollectionFilters as Filters,
|
||||
} from '@/lib/queries/collection';
|
||||
import { listCollectors, resolveCollectorId } from '@/lib/queries/collectors';
|
||||
import { listLocations, listManufacturers } from '@/lib/queries/master-data';
|
||||
import { requireUser } from '@/lib/session';
|
||||
import type { CollectionStatus, Completeness } from '@/db/types';
|
||||
@@ -32,18 +35,23 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
||||
await requireUser();
|
||||
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 = {
|
||||
q: single(params.q),
|
||||
status: single(params.status) as CollectionStatus | undefined,
|
||||
completeness: single(params.zustand) as Completeness | undefined,
|
||||
locationId: single(params.standort),
|
||||
manufacturerId: single(params.hersteller),
|
||||
collectorId,
|
||||
};
|
||||
|
||||
const items = listCollectionItems(filters);
|
||||
const openParts = countOpenMissingParts(items.map((item) => item.id));
|
||||
const stats = getCollectionStats();
|
||||
const missing = countMissingSets();
|
||||
const stats = getCollectionStats(collectorId);
|
||||
const missing = countMissingSets(collectorId);
|
||||
|
||||
const tiles = [
|
||||
{ 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>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{tiles.map((tile) => (
|
||||
<Card key={tile.label}>
|
||||
@@ -134,6 +151,11 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea
|
||||
{item.completeness !== 'unknown' ? (
|
||||
<Badge variant="secondary">{completenessLabel(item.completeness)}</Badge>
|
||||
) : null}
|
||||
{collectors.length > 1 && item.collectorName ? (
|
||||
<Badge className={collectorBadgeClass(item.collectorColor)}>
|
||||
{item.collectorName}
|
||||
</Badge>
|
||||
) : null}
|
||||
{openParts.get(item.id) ? (
|
||||
<Badge variant="outline">{openParts.get(item.id)} Fehlteile</Badge>
|
||||
) : null}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -8,10 +8,12 @@ import { WishlistHeaderActions } from '@/components/wunschlisten/wishlist-header
|
||||
import { WishlistItems } from '@/components/wunschlisten/wishlist-items';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||
import { formatDate, pluralize } from '@/lib/format';
|
||||
import { occasionLabel } from '@/lib/labels';
|
||||
import { CATALOG_PAGE_SIZE, listSets, listSetYears } from '@/lib/queries/catalog';
|
||||
import { listManufacturers, listThemes } from '@/lib/queries/master-data';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { getWishlist, listWishlistItems } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
@@ -39,7 +41,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
||||
const list = getWishlist(id);
|
||||
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 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,
|
||||
ownership: 'fehlt',
|
||||
excludeWishlistId: list.id,
|
||||
collectorId: list.collectorId ?? undefined,
|
||||
page,
|
||||
});
|
||||
|
||||
@@ -63,6 +68,11 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
||||
<Badge variant="secondary">{occasionLabel(list.occasion)}</Badge>
|
||||
{list.eventDate ? <Badge variant="outline">{formatDate(list.eventDate)}</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>
|
||||
<h1 className="mt-2 truncate text-2xl font-semibold tracking-tight">{list.title}</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -70,8 +80,10 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
||||
</p>
|
||||
</div>
|
||||
<WishlistHeaderActions
|
||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||
wishlist={{
|
||||
id: list.id,
|
||||
collectorId: list.collectorId,
|
||||
title: list.title,
|
||||
occasion: list.occasion,
|
||||
eventDate: list.eventDate,
|
||||
@@ -99,7 +111,9 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
||||
<CardHeader>
|
||||
<CardTitle>Aus fehlenden Sets hinzufügen</CardTitle>
|
||||
<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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
@@ -124,6 +138,7 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr
|
||||
sets={missing.rows}
|
||||
wishlists={[{ id: list.id, title: list.title }]}
|
||||
defaultWishlistId={list.id}
|
||||
collectorId={list.collectorId ?? undefined}
|
||||
emptyState="Es fehlt gerade nichts, was noch nicht auf dieser Liste steht."
|
||||
/>
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
type ActionResult,
|
||||
} from '@/lib/action-result';
|
||||
import { ownedCopiesSql } from '@/lib/ownership';
|
||||
import { defaultCollectorId } from '@/lib/queries/collectors';
|
||||
import {
|
||||
findExistingWishlistSetIds,
|
||||
getWishlist,
|
||||
@@ -58,6 +59,7 @@ export async function createWishlist(input: unknown): Promise<ActionResult<{ id:
|
||||
.values({
|
||||
id,
|
||||
ownerUserId: user.id,
|
||||
collectorId: parsed.data.collectorId ?? defaultCollectorId(),
|
||||
title: parsed.data.title,
|
||||
occasion: parsed.data.occasion,
|
||||
eventDate: parsed.data.eventDate,
|
||||
@@ -83,6 +85,7 @@ export async function updateWishlist(id: string, input: unknown): Promise<Action
|
||||
|
||||
db.update(wishlist)
|
||||
.set({
|
||||
collectorId: parsed.data.collectorId,
|
||||
title: parsed.data.title,
|
||||
occasion: parsed.data.occasion,
|
||||
eventDate: parsed.data.eventDate,
|
||||
@@ -153,7 +156,7 @@ export async function addSetsToWishlist(
|
||||
id: set.id,
|
||||
name: set.name,
|
||||
setNumber: set.setNumber,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
ownedCopies: ownedCopiesSql(set.id, list.collectorId).mapWith(Number),
|
||||
})
|
||||
.from(set)
|
||||
.where(inArray(set.id, setIds))
|
||||
|
||||
@@ -4,8 +4,10 @@ import { CalendarDays, Gift, Link2Off } from 'lucide-react';
|
||||
import { NewWishlistButton } from '@/components/wunschlisten/wishlist-form-dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||
import { formatDate, pluralize } from '@/lib/format';
|
||||
import { occasionLabel } from '@/lib/labels';
|
||||
import { listCollectors } from '@/lib/queries/collectors';
|
||||
import { listWishlists } from '@/lib/queries/wishlists';
|
||||
import { requireUser } from '@/lib/session';
|
||||
|
||||
@@ -14,6 +16,7 @@ export const metadata: Metadata = { title: 'Wunschlisten' };
|
||||
export default async function WunschlistenPage() {
|
||||
const user = await requireUser();
|
||||
const lists = listWishlists(user.id);
|
||||
const collectors = listCollectors();
|
||||
|
||||
return (
|
||||
<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.
|
||||
</p>
|
||||
</div>
|
||||
<NewWishlistButton />
|
||||
<NewWishlistButton
|
||||
collectors={collectors.map((row) => ({ id: row.id, name: row.name }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{lists.length === 0 ? (
|
||||
@@ -54,6 +59,11 @@ export default async function WunschlistenPage() {
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<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>
|
||||
{list.openCount > 0 ? (
|
||||
<Badge variant="outline">{list.openCount} noch offen</Badge>
|
||||
|
||||
@@ -27,7 +27,7 @@ export default async function PublicWishlistPage({ params }: Props) {
|
||||
const list = getWishlistByShareToken(shareToken);
|
||||
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
|
||||
// under the tree, no reason to let someone buy it twice.
|
||||
|
||||
@@ -33,6 +33,8 @@ type Props = {
|
||||
wishlists: WishlistTarget[];
|
||||
/** Preselected target, used by the embedded view on a wishlist page. */
|
||||
defaultWishlistId?: string;
|
||||
/** A list created from a filtered view belongs to that collector. */
|
||||
collectorId?: string;
|
||||
emptyState?: React.ReactNode;
|
||||
};
|
||||
|
||||
@@ -43,7 +45,13 @@ type Props = {
|
||||
* 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.
|
||||
*/
|
||||
export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }: Props) {
|
||||
export function CatalogGrid({
|
||||
sets,
|
||||
wishlists,
|
||||
defaultWishlistId,
|
||||
collectorId,
|
||||
emptyState,
|
||||
}: Props) {
|
||||
const router = useRouter();
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
@@ -102,6 +110,7 @@ export function CatalogGrid({ sets, wishlists, defaultWishlistId, emptyState }:
|
||||
title: newTitle,
|
||||
occasion: 'other',
|
||||
isPublic: 'on',
|
||||
collectorId: collectorId ?? '',
|
||||
});
|
||||
if (!created.ok) {
|
||||
toast.error(created.error);
|
||||
|
||||
@@ -38,32 +38,47 @@ export function SetActions({
|
||||
setName,
|
||||
owned,
|
||||
wishlists,
|
||||
collectors,
|
||||
}: {
|
||||
setId: string;
|
||||
setName: string;
|
||||
owned: boolean;
|
||||
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 [pending, startTransition] = useTransition();
|
||||
const [wishlistOpen, setWishlistOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
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 [priority, setPriority] = useState('2');
|
||||
|
||||
function addToCollection() {
|
||||
function addToCollection(forCollectorId?: string) {
|
||||
startTransition(async () => {
|
||||
const result = await addSetToCollection(setId);
|
||||
const result = await addSetToCollection(setId, forCollectorId);
|
||||
if (!result.ok) {
|
||||
toast.error(result.error);
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
||||
// With a single collector there is nothing to ask.
|
||||
function onAddClick() {
|
||||
if (collectors.length > 1) setCollectorOpen(true);
|
||||
else addToCollection();
|
||||
}
|
||||
|
||||
function addToWishlist() {
|
||||
startTransition(async () => {
|
||||
let wishlistId = targetId;
|
||||
@@ -111,7 +126,7 @@ export function SetActions({
|
||||
|
||||
return (
|
||||
<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 />}
|
||||
Zur Sammlung hinzufügen
|
||||
</Button>
|
||||
@@ -138,6 +153,39 @@ export function SetActions({
|
||||
</p>
|
||||
) : 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}>
|
||||
<DialogContent>
|
||||
<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 { MissingPartsDialog } from './missing-parts-dialog';
|
||||
import type { CollectionStatus, Completeness, Condition } from '@/db/types';
|
||||
import { collectorBadgeClass } from '@/lib/collector-colors';
|
||||
import { formatDate, toDateInputValue } from '@/lib/format';
|
||||
import {
|
||||
COMPLETENESS_LABELS,
|
||||
@@ -58,14 +59,19 @@ export type CollectionItemRow = {
|
||||
notes: string | null;
|
||||
setName: string;
|
||||
missingParts: MissingPartRow[];
|
||||
collectorId: string | null;
|
||||
collectorName: string | null;
|
||||
collectorColor: string | null;
|
||||
};
|
||||
|
||||
export function CollectionItemsSection({
|
||||
items,
|
||||
locations,
|
||||
collectors,
|
||||
}: {
|
||||
items: CollectionItemRow[];
|
||||
locations: Array<{ id: string; name: string }>;
|
||||
collectors: Array<{ id: string; name: string }>;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [pending, startTransition] = useTransition();
|
||||
@@ -126,6 +132,11 @@ export function CollectionItemsSection({
|
||||
<li key={item.id} className="flex items-start gap-3 p-3">
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<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'}>
|
||||
{statusLabel(item.status)}
|
||||
</Badge>
|
||||
@@ -194,6 +205,22 @@ export function CollectionItemsSection({
|
||||
|
||||
<form id="collection-item-form" onSubmit={submit} className="space-y-4">
|
||||
<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">
|
||||
<NativeSelect id="status" name="status" defaultValue={editing?.status ?? 'owned'}>
|
||||
{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';
|
||||
|
||||
const tabs = [
|
||||
{ href: '/verwaltung/sammler', label: 'Sammler', adminOnly: false },
|
||||
{ href: '/verwaltung/hersteller', label: 'Hersteller', adminOnly: false },
|
||||
{ href: '/verwaltung/themen', label: 'Themen', adminOnly: false },
|
||||
{ href: '/verwaltung/standorte', label: 'Standorte', adminOnly: false },
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Textarea } from '@/components/ui/textarea';
|
||||
|
||||
export type WishlistFormValues = {
|
||||
id: string;
|
||||
collectorId: string | null;
|
||||
title: string;
|
||||
occasion: Occasion;
|
||||
eventDate: Date | null;
|
||||
@@ -36,10 +37,13 @@ export type WishlistFormValues = {
|
||||
|
||||
export function WishlistFormDialog({
|
||||
wishlist,
|
||||
collectors = [],
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
wishlist?: WishlistFormValues;
|
||||
/** Only asked about when there is more than one. */
|
||||
collectors?: Array<{ id: string; name: string }>;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
@@ -99,6 +103,27 @@ export function WishlistFormDialog({
|
||||
/>
|
||||
</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">
|
||||
<Field label="Anlass" htmlFor="occasion">
|
||||
<NativeSelect
|
||||
@@ -180,7 +205,11 @@ export function WishlistFormDialog({
|
||||
}
|
||||
|
||||
/** 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);
|
||||
|
||||
return (
|
||||
@@ -189,7 +218,7 @@ export function NewWishlistButton() {
|
||||
<Plus />
|
||||
Neue Wunschliste
|
||||
</Button>
|
||||
<WishlistFormDialog open={open} onOpenChange={setOpen} />
|
||||
<WishlistFormDialog collectors={collectors} open={open} onOpenChange={setOpen} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,13 @@ import {
|
||||
AlertDialogTitle,
|
||||
} 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 [editOpen, setEditOpen] = useState(false);
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
@@ -52,7 +58,12 @@ export function WishlistHeaderActions({ wishlist }: { wishlist: WishlistFormValu
|
||||
<Trash2 className="text-destructive" />
|
||||
</Button>
|
||||
|
||||
<WishlistFormDialog wishlist={wishlist} open={editOpen} onOpenChange={setEditOpen} />
|
||||
<WishlistFormDialog
|
||||
wishlist={wishlist}
|
||||
collectors={collectors}
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
text,
|
||||
} from 'drizzle-orm/sqlite-core';
|
||||
import { set } from './catalog';
|
||||
import { collector } from './collector';
|
||||
|
||||
export const location = sqliteTable('location', {
|
||||
id: text('id').primaryKey(),
|
||||
@@ -31,6 +32,12 @@ export const collectionItem = sqliteTable(
|
||||
setId: text('set_id')
|
||||
.notNull()
|
||||
.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', {
|
||||
enum: ['owned', 'built', 'disassembled', 'lent_out', 'sold'],
|
||||
})
|
||||
@@ -54,6 +61,7 @@ export const collectionItem = sqliteTable(
|
||||
},
|
||||
(t) => [
|
||||
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_location_idx').on(t.locationId),
|
||||
],
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
export * from './auth';
|
||||
export * from './catalog';
|
||||
export * from './collector';
|
||||
export * from './collection';
|
||||
export * from './wishlist';
|
||||
export * from './relations';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { relations } from 'drizzle-orm';
|
||||
import { passkey, session, user } from './auth';
|
||||
import { instruction, manufacturer, set, setTag, tag, theme } from './catalog';
|
||||
import { collectionItem, location, missingPart, photo } from './collection';
|
||||
import { collector } from './collector';
|
||||
import { wishlist, wishlistItem } from './wishlist';
|
||||
|
||||
export const userRelations = relations(user, ({ many }) => ({
|
||||
@@ -73,8 +74,17 @@ export const locationRelations = relations(location, ({ one, many }) => ({
|
||||
collectionItems: many(collectionItem),
|
||||
}));
|
||||
|
||||
export const collectorRelations = relations(collector, ({ many }) => ({
|
||||
collectionItems: many(collectionItem),
|
||||
wishlists: many(wishlist),
|
||||
}));
|
||||
|
||||
export const collectionItemRelations = relations(collectionItem, ({ one, many }) => ({
|
||||
set: one(set, { fields: [collectionItem.setId], references: [set.id] }),
|
||||
collector: one(collector, {
|
||||
fields: [collectionItem.collectorId],
|
||||
references: [collector.id],
|
||||
}),
|
||||
location: one(location, {
|
||||
fields: [collectionItem.locationId],
|
||||
references: [location.id],
|
||||
@@ -100,6 +110,10 @@ export const photoRelations = relations(photo, ({ one }) => ({
|
||||
|
||||
export const wishlistRelations = relations(wishlist, ({ one, many }) => ({
|
||||
owner: one(user, { fields: [wishlist.ownerUserId], references: [user.id] }),
|
||||
collector: one(collector, {
|
||||
fields: [wishlist.collectorId],
|
||||
references: [collector.id],
|
||||
}),
|
||||
items: many(wishlistItem),
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import { sql } from 'drizzle-orm';
|
||||
import { check, index, integer, sqliteTable, text, uniqueIndex } from 'drizzle-orm/sqlite-core';
|
||||
import { user } from './auth';
|
||||
import { set } from './catalog';
|
||||
import { collector } from './collector';
|
||||
|
||||
export const wishlist = sqliteTable(
|
||||
'wishlist',
|
||||
@@ -10,6 +11,11 @@ export const wishlist = sqliteTable(
|
||||
ownerUserId: text('owner_user_id')
|
||||
.notNull()
|
||||
.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". */
|
||||
title: text('title').notNull(),
|
||||
occasion: text('occasion', { enum: ['christmas', 'birthday', 'easter', 'other'] })
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
+63
-6
@@ -8,8 +8,9 @@ import type { CollectionStatus } from '@/db/types';
|
||||
* 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.
|
||||
*
|
||||
* Every query that asks about ownership goes through this module. If the rule
|
||||
* ever changes, it changes here.
|
||||
* With more than one collector the question gains a "for whom": the catalog is
|
||||
* 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;
|
||||
@@ -18,6 +19,12 @@ export const SOLD_STATUS: CollectionStatus = 'sold';
|
||||
|
||||
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? */
|
||||
export function isOwningStatus(status: CollectionStatus): status is OwnedStatus {
|
||||
return status !== SOLD_STATUS;
|
||||
@@ -37,25 +44,75 @@ 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
|
||||
* 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}
|
||||
)`;
|
||||
}
|
||||
|
||||
/** How many wishlists this set is currently on. */
|
||||
export function wishlistCountSql(setIdColumn: SQL | unknown): SQL<number> {
|
||||
return sql<number>`(
|
||||
select count(*) from collection_item ci
|
||||
where ci.set_id = ${setIdColumn}
|
||||
and ci.status <> ${SOLD_STATUS}
|
||||
and ci.collector_id = ${collectorId}
|
||||
)`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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>`(
|
||||
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}
|
||||
)`;
|
||||
}
|
||||
|
||||
/** The four states the catalog filter offers, as they appear in the URL. */
|
||||
export const OWNERSHIP_FILTERS = ['alle', 'sammlung', 'fehlt', 'wunschliste'] as const;
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export type CatalogFilters = {
|
||||
ownership?: OwnershipFilter;
|
||||
/** Hides sets that are already on this wishlist (for "aus fehlenden Sets hinzufügen"). */
|
||||
excludeWishlistId?: string;
|
||||
/** Scopes "owned" and "on a wishlist" to one collector. */
|
||||
collectorId?: string;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
@@ -52,7 +54,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
|
||||
if (filters.themeId) conditions.push(eq(set.themeId, filters.themeId));
|
||||
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) {
|
||||
case 'sammlung':
|
||||
conditions.push(sql`${owned} > 0`);
|
||||
@@ -61,7 +63,7 @@ function buildWhere(filters: CatalogFilters): SQL | undefined {
|
||||
conditions.push(sql`${owned} = 0`);
|
||||
break;
|
||||
case 'wunschliste':
|
||||
conditions.push(sql`${wishlistCountSql(set.id)} > 0`);
|
||||
conditions.push(sql`${wishlistCountSql(set.id, filters.collectorId)} > 0`);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -95,8 +97,8 @@ export function listSets(filters: CatalogFilters): { rows: CatalogRow[]; total:
|
||||
retailPriceCents: set.retailPriceCents,
|
||||
manufacturerName: manufacturer.name,
|
||||
themeName: theme.name,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
|
||||
ownedCopies: ownedCopiesSql(set.id, filters.collectorId).mapWith(Number),
|
||||
wishlistCount: wishlistCountSql(set.id, filters.collectorId).mapWith(Number),
|
||||
})
|
||||
.from(set)
|
||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||
@@ -126,7 +128,7 @@ export function listSetIds(filters: CatalogFilters): string[] {
|
||||
.map((row) => row.id);
|
||||
}
|
||||
|
||||
export function getSetById(id: string) {
|
||||
export function getSetById(id: string, collectorId?: string) {
|
||||
const row = db
|
||||
.select({
|
||||
id: set.id,
|
||||
@@ -148,8 +150,8 @@ export function getSetById(id: string) {
|
||||
notes: set.notes,
|
||||
createdAt: set.createdAt,
|
||||
updatedAt: set.updatedAt,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
wishlistCount: wishlistCountSql(set.id).mapWith(Number),
|
||||
ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
|
||||
wishlistCount: wishlistCountSql(set.id, collectorId).mapWith(Number),
|
||||
})
|
||||
.from(set)
|
||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import 'server-only';
|
||||
import { and, asc, countDistinct, eq, inArray, sql, type SQL } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { collectionItem, location, manufacturer, missingPart, set, theme } from '@/db/schema';
|
||||
import { SOLD_STATUS } from '@/lib/ownership';
|
||||
import {
|
||||
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';
|
||||
|
||||
export type CollectionFilters = {
|
||||
@@ -11,6 +19,7 @@ export type CollectionFilters = {
|
||||
completeness?: Completeness;
|
||||
locationId?: string;
|
||||
manufacturerId?: string;
|
||||
collectorId?: string;
|
||||
};
|
||||
|
||||
export type CollectionRow = {
|
||||
@@ -21,6 +30,9 @@ export type CollectionRow = {
|
||||
condition: Condition | null;
|
||||
locationId: string | null;
|
||||
locationName: string | null;
|
||||
collectorId: string | null;
|
||||
collectorName: string | null;
|
||||
collectorColor: string | null;
|
||||
acquiredAt: Date | null;
|
||||
acquiredPriceCents: number | 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.locationId) conditions.push(eq(collectionItem.locationId, filters.locationId));
|
||||
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;
|
||||
}
|
||||
@@ -65,6 +78,9 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
|
||||
condition: collectionItem.condition,
|
||||
locationId: collectionItem.locationId,
|
||||
locationName: location.name,
|
||||
collectorId: collectionItem.collectorId,
|
||||
collectorName: collector.name,
|
||||
collectorColor: collector.color,
|
||||
acquiredAt: collectionItem.acquiredAt,
|
||||
acquiredPriceCents: collectionItem.acquiredPriceCents,
|
||||
acquiredFrom: collectionItem.acquiredFrom,
|
||||
@@ -86,6 +102,7 @@ export function listCollectionItems(filters: CollectionFilters): CollectionRow[]
|
||||
.innerJoin(manufacturer, eq(manufacturer.id, set.manufacturerId))
|
||||
.leftJoin(theme, eq(theme.id, set.themeId))
|
||||
.leftJoin(location, eq(location.id, collectionItem.locationId))
|
||||
.leftJoin(collector, eq(collector.id, collectionItem.collectorId))
|
||||
.where(buildWhere(filters))
|
||||
.orderBy(asc(manufacturer.name), asc(set.setNumber))
|
||||
.all();
|
||||
@@ -104,8 +121,13 @@ export type CollectionStats = {
|
||||
* 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.
|
||||
*/
|
||||
export function getCollectionStats(): CollectionStats {
|
||||
const active = sql`${collectionItem.status} <> ${SOLD_STATUS}`;
|
||||
export function getCollectionStats(collectorId?: string): CollectionStats {
|
||||
const active = collectorId
|
||||
? and(
|
||||
sql`${collectionItem.status} <> ${SOLD_STATUS}`,
|
||||
eq(collectionItem.collectorId, collectorId),
|
||||
)
|
||||
: sql`${collectionItem.status} <> ${SOLD_STATUS}`;
|
||||
|
||||
const [totals] = db
|
||||
.select({
|
||||
@@ -144,16 +166,11 @@ export function getCollectionStats(): CollectionStats {
|
||||
}
|
||||
|
||||
/** 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
|
||||
.select({ total: sql<number>`count(*)`.mapWith(Number) })
|
||||
.from(set)
|
||||
.where(
|
||||
sql`not exists (
|
||||
select 1 from collection_item ci
|
||||
where ci.set_id = ${set.id} and ci.status <> ${SOLD_STATUS}
|
||||
)`,
|
||||
)
|
||||
.where(sql`${ownedCopiesSql(set.id, collectorId)} = 0`)
|
||||
.all();
|
||||
|
||||
return row?.total ?? 0;
|
||||
|
||||
@@ -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] : [])),
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import 'server-only';
|
||||
import { and, asc, count, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
|
||||
import { ownedCopiesSql } from '@/lib/ownership';
|
||||
import { collector, manufacturer, set, theme, wishlist, wishlistItem } from '@/db/schema';
|
||||
import { ownedCopiesSql, SOLD_STATUS } from '@/lib/ownership';
|
||||
|
||||
/** Short list for the "auf welche Wunschliste?" dialogs. */
|
||||
export function listWishlistTargets() {
|
||||
@@ -34,12 +34,16 @@ export function listWishlists(ownerUserId: string) {
|
||||
isPublic: wishlist.isPublic,
|
||||
surpriseMode: wishlist.surpriseMode,
|
||||
shareToken: wishlist.shareToken,
|
||||
collectorId: wishlist.collectorId,
|
||||
collectorName: collector.name,
|
||||
collectorColor: collector.color,
|
||||
createdAt: wishlist.createdAt,
|
||||
itemCount: count(wishlistItem.id),
|
||||
openCount: openItems,
|
||||
})
|
||||
.from(wishlist)
|
||||
.leftJoin(wishlistItem, eq(wishlistItem.wishlistId, wishlist.id))
|
||||
.leftJoin(collector, eq(collector.id, wishlist.collectorId))
|
||||
.where(eq(wishlist.ownerUserId, ownerUserId))
|
||||
.groupBy(wishlist.id)
|
||||
.orderBy(asc(wishlist.title))
|
||||
@@ -76,7 +80,10 @@ export type WishlistItemRow = {
|
||||
ownedCopies: number;
|
||||
};
|
||||
|
||||
export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
|
||||
export function listWishlistItems(
|
||||
wishlistId: string,
|
||||
collectorId?: string | null,
|
||||
): WishlistItemRow[] {
|
||||
return db
|
||||
.select({
|
||||
id: wishlistItem.id,
|
||||
@@ -97,7 +104,7 @@ export function listWishlistItems(wishlistId: string): WishlistItemRow[] {
|
||||
manufacturerName: manufacturer.name,
|
||||
manufacturerWebsite: manufacturer.website,
|
||||
themeName: theme.name,
|
||||
ownedCopies: ownedCopiesSql(set.id).mapWith(Number),
|
||||
ownedCopies: ownedCopiesSql(set.id, collectorId).mapWith(Number),
|
||||
})
|
||||
.from(wishlistItem)
|
||||
.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
|
||||
* 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.
|
||||
*
|
||||
* 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 {
|
||||
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)
|
||||
.set({ fulfilledAt: new Date() })
|
||||
.where(and(isNull(wishlistItem.fulfilledAt), sql`${owned} > 0`))
|
||||
.where(and(isNull(wishlistItem.fulfilledAt), ownedForThisList))
|
||||
.run();
|
||||
|
||||
db.update(wishlistItem)
|
||||
.set({ fulfilledAt: null })
|
||||
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`${owned} = 0`))
|
||||
.where(and(isNotNull(wishlistItem.fulfilledAt), sql`not ${ownedForThisList}`))
|
||||
.run();
|
||||
}
|
||||
|
||||
@@ -88,6 +88,22 @@ export const locationSchema = z.object({
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -127,6 +143,7 @@ export const conditionEnum = z.enum(['new_sealed', 'like_new', 'good', 'used', '
|
||||
|
||||
export const collectionItemSchema = z.object({
|
||||
setId: z.string().trim().min(1),
|
||||
collectorId: optionalId,
|
||||
status: collectionStatusEnum.default('owned'),
|
||||
completeness: completenessEnum.default('unknown'),
|
||||
condition: z
|
||||
@@ -171,6 +188,7 @@ export const occasionEnum = z.enum(['christmas', 'birthday', 'easter', 'other'])
|
||||
|
||||
export const wishlistSchema = z.object({
|
||||
title: z.string().trim().min(1, 'Bitte einen Titel angeben.').max(80),
|
||||
collectorId: optionalId,
|
||||
occasion: occasionEnum.default('other'),
|
||||
eventDate: optionalDate,
|
||||
note: optionalText(1000),
|
||||
@@ -205,3 +223,4 @@ export type LocationInput = z.infer<typeof locationSchema>;
|
||||
export type SetInput = z.infer<typeof setSchema>;
|
||||
export type CollectionItemInput = z.infer<typeof collectionItemSchema>;
|
||||
export type WishlistInput = z.infer<typeof wishlistSchema>;
|
||||
export type CollectorInput = z.infer<typeof collectorSchema>;
|
||||
|
||||
@@ -2,8 +2,10 @@ import { describe, expect, it } from 'vitest';
|
||||
import type { CollectionStatus } from '@/db/types';
|
||||
import {
|
||||
countOwnedCopies,
|
||||
countOwnedCopiesFor,
|
||||
isOwningStatus,
|
||||
isSetOwned,
|
||||
isSetOwnedBy,
|
||||
OWNED_STATUSES,
|
||||
parseOwnershipFilter,
|
||||
} from '@/lib/ownership';
|
||||
@@ -59,3 +61,47 @@ describe('Besitz-Filter aus der URL', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user