diff --git a/drizzle/0001_lumpy_bill_hollister.sql b/drizzle/0001_lumpy_bill_hollister.sql new file mode 100644 index 0000000..e271c15 --- /dev/null +++ b/drizzle/0001_lumpy_bill_hollister.sql @@ -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; diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..849fc1a --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,1992 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "db83b244-734e-48d4-9656-f335650ba9c7", + "prevId": "426c9bf5-ccbd-473a-a819-11f8b9252494", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_user_idx": { + "name": "account_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "invite": { + "name": "invite", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "used_at": { + "name": "used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "used_by_user_id": { + "name": "used_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "invite_token_hash_unique": { + "name": "invite_token_hash_unique", + "columns": [ + "token_hash" + ], + "isUnique": true + } + }, + "foreignKeys": { + "invite_created_by_user_id_user_id_fk": { + "name": "invite_created_by_user_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invite_used_by_user_id_user_id_fk": { + "name": "invite_used_by_user_id_user_id_fk", + "tableFrom": "invite", + "tableTo": "user", + "columnsFrom": [ + "used_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "passkey": { + "name": "passkey", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "backed_up": { + "name": "backed_up", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "passkey_credential_id_unique": { + "name": "passkey_credential_id_unique", + "columns": [ + "credential_id" + ], + "isUnique": true + }, + "passkey_user_idx": { + "name": "passkey_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "rate_limit": { + "name": "rate_limit", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_request": { + "name": "last_request", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_user_idx": { + "name": "session_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'member'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "instruction": { + "name": "instruction", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "page_count": { + "name": "page_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_size_bytes": { + "name": "file_size_bytes", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "instruction_set_idx": { + "name": "instruction_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "instruction_set_id_set_id_fk": { + "name": "instruction_set_id_set_id_fk", + "tableFrom": "instruction", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "instruction_source": { + "name": "instruction_source", + "value": "(\"instruction\".\"kind\" = 'pdf_upload' AND \"instruction\".\"file_path\" IS NOT NULL AND \"instruction\".\"url\" IS NULL)\n OR (\"instruction\".\"kind\" = 'external_url' AND \"instruction\".\"url\" IS NOT NULL AND \"instruction\".\"file_path\" IS NULL)" + } + } + }, + "manufacturer": { + "name": "manufacturer", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "website": { + "name": "website", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "manufacturer_slug_unique": { + "name": "manufacturer_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "set": { + "name": "set", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "manufacturer_id": { + "name": "manufacturer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "theme_id": { + "name": "theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "set_number": { + "name": "set_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "year": { + "name": "year", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "piece_count": { + "name": "piece_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "minifig_count": { + "name": "minifig_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ean": { + "name": "ean", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "age_range": { + "name": "age_range", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_url": { + "name": "image_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "image_path": { + "name": "image_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retail_price_cents": { + "name": "retail_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "external_ids": { + "name": "external_ids", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "set_manufacturer_number_unique": { + "name": "set_manufacturer_number_unique", + "columns": [ + "manufacturer_id", + "set_number" + ], + "isUnique": true + }, + "set_name_idx": { + "name": "set_name_idx", + "columns": [ + "name" + ], + "isUnique": false + }, + "set_ean_idx": { + "name": "set_ean_idx", + "columns": [ + "ean" + ], + "isUnique": false + }, + "set_theme_idx": { + "name": "set_theme_idx", + "columns": [ + "theme_id" + ], + "isUnique": false + }, + "set_year_idx": { + "name": "set_year_idx", + "columns": [ + "year" + ], + "isUnique": false + } + }, + "foreignKeys": { + "set_manufacturer_id_manufacturer_id_fk": { + "name": "set_manufacturer_id_manufacturer_id_fk", + "tableFrom": "set", + "tableTo": "manufacturer", + "columnsFrom": [ + "manufacturer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "set_theme_id_theme_id_fk": { + "name": "set_theme_id_theme_id_fk", + "tableFrom": "set", + "tableTo": "theme", + "columnsFrom": [ + "theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "set_tag": { + "name": "set_tag", + "columns": { + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "tag_id": { + "name": "tag_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "set_tag_set_id_set_id_fk": { + "name": "set_tag_set_id_set_id_fk", + "tableFrom": "set_tag", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "set_tag_tag_id_tag_id_fk": { + "name": "set_tag_tag_id_tag_id_fk", + "tableFrom": "set_tag", + "tableTo": "tag", + "columnsFrom": [ + "tag_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "set_tag_set_id_tag_id_pk": { + "columns": [ + "set_id", + "tag_id" + ], + "name": "set_tag_set_id_tag_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tag": { + "name": "tag", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "tag_slug_unique": { + "name": "tag_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "theme": { + "name": "theme", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "manufacturer_id": { + "name": "manufacturer_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_theme_id": { + "name": "parent_theme_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "theme_manufacturer_idx": { + "name": "theme_manufacturer_idx", + "columns": [ + "manufacturer_id" + ], + "isUnique": false + }, + "theme_unique_name": { + "name": "theme_unique_name", + "columns": [ + "manufacturer_id", + "name" + ], + "isUnique": true + } + }, + "foreignKeys": { + "theme_manufacturer_id_manufacturer_id_fk": { + "name": "theme_manufacturer_id_manufacturer_id_fk", + "tableFrom": "theme", + "tableTo": "manufacturer", + "columnsFrom": [ + "manufacturer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "theme_parent_theme_id_theme_id_fk": { + "name": "theme_parent_theme_id_theme_id_fk", + "tableFrom": "theme", + "tableTo": "theme", + "columnsFrom": [ + "parent_theme_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collector": { + "name": "collector", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'blue'" + }, + "birthday": { + "name": "birthday", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collector_sort_idx": { + "name": "collector_sort_idx", + "columns": [ + "sort_order", + "name" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "collection_item": { + "name": "collection_item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'owned'" + }, + "completeness": { + "name": "completeness", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unknown'" + }, + "condition": { + "name": "condition", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_at": { + "name": "acquired_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_price_cents": { + "name": "acquired_price_cents", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "acquired_from": { + "name": "acquired_from", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "gifted_by": { + "name": "gifted_by", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lent_out_to": { + "name": "lent_out_to", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "collection_item_set_idx": { + "name": "collection_item_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + }, + "collection_item_collector_idx": { + "name": "collection_item_collector_idx", + "columns": [ + "collector_id" + ], + "isUnique": false + }, + "collection_item_status_idx": { + "name": "collection_item_status_idx", + "columns": [ + "status" + ], + "isUnique": false + }, + "collection_item_location_idx": { + "name": "collection_item_location_idx", + "columns": [ + "location_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "collection_item_set_id_set_id_fk": { + "name": "collection_item_set_id_set_id_fk", + "tableFrom": "collection_item", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_item_collector_id_collector_id_fk": { + "name": "collection_item_collector_id_collector_id_fk", + "tableFrom": "collection_item", + "tableTo": "collector", + "columnsFrom": [ + "collector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_item_location_id_location_id_fk": { + "name": "collection_item_location_id_location_id_fk", + "tableFrom": "collection_item", + "tableTo": "location", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "location": { + "name": "location", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parent_location_id": { + "name": "parent_location_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "location_parent_location_id_location_id_fk": { + "name": "location_parent_location_id_location_id_fk", + "tableFrom": "location", + "tableTo": "location", + "columnsFrom": [ + "parent_location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "missing_part": { + "name": "missing_part", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "collection_item_id": { + "name": "collection_item_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "part_number": { + "name": "part_number", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "color_name": { + "name": "color_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "quantity": { + "name": "quantity", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "resolved": { + "name": "resolved", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "missing_part_item_idx": { + "name": "missing_part_item_idx", + "columns": [ + "collection_item_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "missing_part_collection_item_id_collection_item_id_fk": { + "name": "missing_part_collection_item_id_collection_item_id_fk", + "tableFrom": "missing_part", + "tableTo": "collection_item", + "columnsFrom": [ + "collection_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "photo": { + "name": "photo", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "collection_item_id": { + "name": "collection_item_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "thumbnail_path": { + "name": "thumbnail_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "caption": { + "name": "caption", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "photo_set_idx": { + "name": "photo_set_idx", + "columns": [ + "set_id" + ], + "isUnique": false + }, + "photo_item_idx": { + "name": "photo_item_idx", + "columns": [ + "collection_item_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "photo_set_id_set_id_fk": { + "name": "photo_set_id_set_id_fk", + "tableFrom": "photo", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "photo_collection_item_id_collection_item_id_fk": { + "name": "photo_collection_item_id_collection_item_id_fk", + "tableFrom": "photo", + "tableTo": "collection_item", + "columnsFrom": [ + "collection_item_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "photo_single_owner": { + "name": "photo_single_owner", + "value": "(\"photo\".\"set_id\" IS NOT NULL) <> (\"photo\".\"collection_item_id\" IS NOT NULL)" + } + } + }, + "wishlist": { + "name": "wishlist", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner_user_id": { + "name": "owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "collector_id": { + "name": "collector_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "occasion": { + "name": "occasion", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'other'" + }, + "event_date": { + "name": "event_date", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_public": { + "name": "is_public", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "surprise_mode": { + "name": "surprise_mode", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "wishlist_share_token_unique": { + "name": "wishlist_share_token_unique", + "columns": [ + "share_token" + ], + "isUnique": true + }, + "wishlist_owner_idx": { + "name": "wishlist_owner_idx", + "columns": [ + "owner_user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wishlist_owner_user_id_user_id_fk": { + "name": "wishlist_owner_user_id_user_id_fk", + "tableFrom": "wishlist", + "tableTo": "user", + "columnsFrom": [ + "owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wishlist_collector_id_collector_id_fk": { + "name": "wishlist_collector_id_collector_id_fk", + "tableFrom": "wishlist", + "tableTo": "collector", + "columnsFrom": [ + "collector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "wishlist_item": { + "name": "wishlist_item", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "wishlist_id": { + "name": "wishlist_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "set_id": { + "name": "set_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "priority": { + "name": "priority", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 2 + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "reserved_by_name": { + "name": "reserved_by_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reserved_at": { + "name": "reserved_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "reservation_token_hash": { + "name": "reservation_token_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "fulfilled_at": { + "name": "fulfilled_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "wishlist_item_unique": { + "name": "wishlist_item_unique", + "columns": [ + "wishlist_id", + "set_id" + ], + "isUnique": true + }, + "wishlist_item_list_idx": { + "name": "wishlist_item_list_idx", + "columns": [ + "wishlist_id", + "sort_order" + ], + "isUnique": false + } + }, + "foreignKeys": { + "wishlist_item_wishlist_id_wishlist_id_fk": { + "name": "wishlist_item_wishlist_id_wishlist_id_fk", + "tableFrom": "wishlist_item", + "tableTo": "wishlist", + "columnsFrom": [ + "wishlist_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "wishlist_item_set_id_set_id_fk": { + "name": "wishlist_item_set_id_set_id_fk", + "tableFrom": "wishlist_item", + "tableTo": "set", + "columnsFrom": [ + "set_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": { + "wishlist_item_priority": { + "name": "wishlist_item_priority", + "value": "\"wishlist_item\".\"priority\" BETWEEN 1 AND 3" + } + } + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 19c4a53..12651b7 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/scripts/seed.ts b/scripts/seed.ts index fa10c3b..b533787 100644 --- a/scripts/seed.ts +++ b/scripts/seed.ts @@ -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) { return byKey; } +function seedCollectors() { + const byName = new Map(); + + 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(); @@ -270,6 +347,7 @@ function seedSets( manufacturers: Map, themes: Map, locations: Map, + collectors: Map, ) { const setIds = new Map(); @@ -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) { +function seedWishlist(setIds: Map, collectors: Map) { 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) { .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(); diff --git a/src/app/(app)/katalog/[id]/page.tsx b/src/app/(app)/katalog/[id]/page.tsx index ac2add6..3215a56 100644 --- a/src/app/(app)/katalog/[id]/page.tsx +++ b/src/app/(app)/katalog/[id]/page.tsx @@ -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>; +}; -export async function generateMetadata({ params }: Props): Promise { +export async function generateMetadata({ params }: Pick): Promise { 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 }))} />
@@ -144,7 +161,11 @@ export default async function SetDetailPage({ params }: Props) { In der Sammlung - + ({ id: row.id, name: row.name }))} + /> diff --git a/src/app/(app)/katalog/page.tsx b/src/app/(app)/katalog/page.tsx index b3f7f4d..e76a3d0 100644 --- a/src/app/(app)/katalog/page.tsx +++ b/src/app/(app)/katalog/page.tsx @@ -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

{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.'}

@@ -72,6 +80,11 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear + ({ id: row.id, name: row.name, color: row.color }))} + allLabel="Alle zusammen" + /> +

@@ -79,7 +92,7 @@ export default async function KatalogPage({ searchParams }: { searchParams: Sear {ownership !== 'alle' ? ` · ${OWNERSHIP_FILTER_LABELS[ownership]}` : ''}

- + diff --git a/src/app/(app)/sammlung/actions.ts b/src/app/(app)/sammlung/actions.ts index a0b1ca3..2de91bb 100644 --- a/src/app/(app)/sammlung/actions.ts +++ b/src/app/(app)/sammlung/actions.ts @@ -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> { +export async function addSetToCollection( + setId: string, + collectorId?: string, +): Promise> { 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, diff --git a/src/app/(app)/sammlung/page.tsx b/src/app/(app)/sammlung/page.tsx index fe47006..e55f12c 100644 --- a/src/app/(app)/sammlung/page.tsx +++ b/src/app/(app)/sammlung/page.tsx @@ -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

Sammlung

-

Alles, was tatsächlich da ist.

+

+ {activeCollector + ? `Alles, was ${activeCollector.name} tatsächlich hat.` + : 'Alles, was tatsächlich da ist.'} +

+ ({ id: row.id, name: row.name, color: row.color }))} + allLabel="Alle zusammen" + /> +
{tiles.map((tile) => ( @@ -134,6 +151,11 @@ export default async function SammlungPage({ searchParams }: { searchParams: Sea {item.completeness !== 'unknown' ? ( {completenessLabel(item.completeness)} ) : null} + {collectors.length > 1 && item.collectorName ? ( + + {item.collectorName} + + ) : null} {openParts.get(item.id) ? ( {openParts.get(item.id)} Fehlteile ) : null} diff --git a/src/app/(app)/verwaltung/sammler/actions.ts b/src/app/(app)/verwaltung/sammler/actions.ts new file mode 100644 index 0000000..a4eed88 --- /dev/null +++ b/src/app/(app)/verwaltung/sammler/actions.ts @@ -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 { + await requireUser(); + + const parsed = parseForm(collectorSchema, input); + if (!parsed.ok) return parsed; + + const [row] = db + .select({ next: sql`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 { + 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 { + 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 { + 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 { + 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> { + await requireUser(); + + return db + .select({ id: collector.id, name: collector.name }) + .from(collector) + .orderBy(asc(collector.sortOrder), asc(collector.name)) + .all(); +} diff --git a/src/app/(app)/verwaltung/sammler/page.tsx b/src/app/(app)/verwaltung/sammler/page.tsx new file mode 100644 index 0000000..4c76522 --- /dev/null +++ b/src/app/(app)/verwaltung/sammler/page.tsx @@ -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 ( +
+

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

+ +
+ ); +} diff --git a/src/app/(app)/wunschlisten/[id]/page.tsx b/src/app/(app)/wunschlisten/[id]/page.tsx index fc433ff..515e3fa 100644 --- a/src/app/(app)/wunschlisten/[id]/page.tsx +++ b/src/app/(app)/wunschlisten/[id]/page.tsx @@ -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 {occasionLabel(list.occasion)} {list.eventDate ? {formatDate(list.eventDate)} : null} {list.surpriseMode ? Überraschungsmodus : null} + {collectors.length > 1 && activeCollector ? ( + + für {activeCollector.name} + + ) : null}

{list.title}

@@ -70,8 +80,10 @@ export default async function WunschlisteDetailPage({ params, searchParams }: Pr

({ 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 Aus fehlenden Sets hinzufügen - 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. @@ -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." /> diff --git a/src/app/(app)/wunschlisten/actions.ts b/src/app/(app)/wunschlisten/actions.ts index f71d08f..242945b 100644 --- a/src/app/(app)/wunschlisten/actions.ts +++ b/src/app/(app)/wunschlisten/actions.ts @@ -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 @@ -24,7 +27,9 @@ export default async function WunschlistenPage() { Zum Teilen mit Familie und Freunden – ganz ohne Konto für die anderen.

- + ({ id: row.id, name: row.name }))} + /> {lists.length === 0 ? ( @@ -54,6 +59,11 @@ export default async function WunschlistenPage() { + {collectors.length > 1 && list.collectorName ? ( + + {list.collectorName} + + ) : null} {pluralize(list.itemCount, 'Wunsch', 'Wünsche')} {list.openCount > 0 ? ( {list.openCount} noch offen diff --git a/src/app/w/[shareToken]/page.tsx b/src/app/w/[shareToken]/page.tsx index 0e1639c..2ebc158 100644 --- a/src/app/w/[shareToken]/page.tsx +++ b/src/app/w/[shareToken]/page.tsx @@ -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. diff --git a/src/components/katalog/catalog-grid.tsx b/src/components/katalog/catalog-grid.tsx index 7c4fcfb..9bbfbb6 100644 --- a/src/components/katalog/catalog-grid.tsx +++ b/src/components/katalog/catalog-grid.tsx @@ -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>(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); diff --git a/src/components/katalog/set-actions.tsx b/src/components/katalog/set-actions.tsx index 3ec9f6d..1a0b777 100644 --- a/src/components/katalog/set-actions.tsx +++ b/src/components/katalog/set-actions.tsx @@ -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 (
- @@ -138,6 +153,39 @@ export function SetActions({

) : null} + + + + Wer bekommt das Set? + {setName} + + + + setCollectorId(event.target.value)} + > + {collectors.map((entry) => ( + + ))} + + + + + + + + + + diff --git a/src/components/layout/collector-filter.tsx b/src/components/layout/collector-filter.tsx new file mode 100644 index 0000000..bcd284c --- /dev/null +++ b/src/components/layout/collector-filter.tsx @@ -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 ( + + ); +} diff --git a/src/components/sammlung/collection-items-section.tsx b/src/components/sammlung/collection-items-section.tsx index 5128f31..adc9af7 100644 --- a/src/components/sammlung/collection-items-section.tsx +++ b/src/components/sammlung/collection-items-section.tsx @@ -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({
  • + {collectors.length > 1 && item.collectorName ? ( + + {item.collectorName} + + ) : null} {statusLabel(item.status)} @@ -194,6 +205,22 @@ export function CollectionItemsSection({
    + {collectors.length > 1 ? ( + + + {collectors.map((entry) => ( + + ))} + + + ) : null} + {Object.entries(STATUS_LABELS).map(([value, label]) => ( diff --git a/src/components/verwaltung/collector-list.tsx b/src/components/verwaltung/collector-list.tsx new file mode 100644 index 0000000..5127f13 --- /dev/null +++ b/src/components/verwaltung/collector-list.tsx @@ -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 ( + + 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) => ( + <> + + + + + + + {Object.entries(COLLECTOR_COLOR_LABELS).map(([value, label]) => ( + + ))} + + + + + + + + +