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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user