Compare commits

..
1 Commits
Author SHA1 Message Date
lukas bb18170b3f wip
CI / build-and-test (pull_request) Failing after 51s
2026-08-25 12:09:17 +02:00
42 changed files with 132 additions and 6790 deletions
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "restaurant_tables" ADD COLUMN "image_url" text;
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE "restaurant_settings" ADD COLUMN "table_display_mode" text DEFAULT 'individual' NOT NULL;--> statement-breakpoint
ALTER TABLE "restaurant_settings" ADD COLUMN "table_overview_image_url" text;
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE "restaurant_tables" ADD COLUMN "marker_x" double precision;--> statement-breakpoint
ALTER TABLE "restaurant_tables" ADD COLUMN "marker_y" double precision;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "restaurant_tables" ADD COLUMN "table_number" integer;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "reservations" ADD COLUMN "message" text;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "reservations" ADD COLUMN "customer_phone" text;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "user" ADD COLUMN "phone" text;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "reservations" ADD COLUMN "status" text DEFAULT 'confirmed' NOT NULL;
-533
View File
@@ -1,533 +0,0 @@
{
"id": "05eac8a1-b172-4e4d-90d2-a409ecde610a",
"prevId": "d03b9103-cb6a-4968-a016-c04270ec176a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"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": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"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": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-546
View File
@@ -1,546 +0,0 @@
{
"id": "71bc6bc2-516c-4af1-8c61-3df7814e9976",
"prevId": "05eac8a1-b172-4e4d-90d2-a409ecde610a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"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": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"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": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-558
View File
@@ -1,558 +0,0 @@
{
"id": "cc3da65b-b162-4866-ac44-b7d177588316",
"prevId": "71bc6bc2-516c-4af1-8c61-3df7814e9976",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"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": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"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": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-564
View File
@@ -1,564 +0,0 @@
{
"id": "0914058c-53fc-4e93-b703-a907e0ae72a8",
"prevId": "cc3da65b-b162-4866-ac44-b7d177588316",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"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": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"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": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-570
View File
@@ -1,570 +0,0 @@
{
"id": "c95ecb29-7866-4794-9aad-8a6fba8e7bc4",
"prevId": "0914058c-53fc-4e93-b703-a907e0ae72a8",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"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": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"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": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-576
View File
@@ -1,576 +0,0 @@
{
"id": "69162a80-7a1b-480b-85c2-7f11e2f1725c",
"prevId": "c95ecb29-7866-4794-9aad-8a6fba8e7bc4",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"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": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_phone": {
"name": "customer_phone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"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": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-582
View File
@@ -1,582 +0,0 @@
{
"id": "b5488545-3b1f-4145-a565-f4fcdc5b3748",
"prevId": "69162a80-7a1b-480b-85c2-7f11e2f1725c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"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": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_phone": {
"name": "customer_phone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"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": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-589
View File
@@ -1,589 +0,0 @@
{
"id": "b91348a4-3a6f-4c6b-a7a0-c60c06eeb9f9",
"prevId": "b5488545-3b1f-4145-a565-f4fcdc5b3748",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"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": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_phone": {
"name": "customer_phone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'confirmed'"
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"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": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-56
View File
@@ -29,62 +29,6 @@
"when": 1787646264161,
"tag": "0003_big_jack_flag",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1787683584543,
"tag": "0004_wide_thunderbolt",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1787684215918,
"tag": "0005_nice_stark_industries",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1787684576667,
"tag": "0006_uneven_captain_cross",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1787685540508,
"tag": "0007_dusty_payback",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1787686033197,
"tag": "0008_even_toxin",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1787686357931,
"tag": "0009_nosy_alice",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1787688761615,
"tag": "0010_common_masque",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1787690760361,
"tag": "0011_cloudy_gwen_stacy",
"breakpoints": true
}
]
}
-108
View File
@@ -1,108 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
type Props = {
label?: string
children: React.ReactNode
}
function DotsIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="5" r="1.8" />
<circle cx="12" cy="12" r="1.8" />
<circle cx="12" cy="19" r="1.8" />
</svg>
)
}
type Position = { top: number; right: number }
// A small "⋮" menu that reveals its children (typically a stack of `.btn`s) in a
// dropdown — used to tuck row actions away instead of listing every button inline.
// The panel is portaled to <body> and positioned with `position: fixed` from a
// measured rect rather than anchored with `position: absolute` in place, since
// rows live inside `.table-scroll` (`overflow-x: auto`, which per the CSS spec
// forces `overflow-y` to clip too) — an in-place dropdown on the last row or two
// would otherwise get cut off at the table's bottom edge.
export function ActionMenu({ label = 'Aktionen', children }: Props) {
const [position, setPosition] = useState<Position | null>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const open = position !== null
function close() {
setPosition(null)
}
function toggle() {
if (open) {
close()
return
}
const rect = triggerRef.current?.getBoundingClientRect()
if (!rect) return
setPosition({ top: rect.bottom + 4, right: window.innerWidth - rect.right })
}
useEffect(() => {
if (!open) return
function handleOutsideClick(event: MouseEvent) {
const target = event.target as Node
if (!triggerRef.current?.contains(target) && !panelRef.current?.contains(target)) {
close()
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') close()
}
// The menu's position is measured once, on open — rather than track scroll to
// keep it glued to the trigger, just close it, matching how most native and
// web dropdowns behave when the page moves under them.
function handleScroll() {
close()
}
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleKeyDown)
window.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('scroll', handleScroll, true)
}
}, [open])
return (
<>
<button
ref={triggerRef}
type="button"
className="action-menu-trigger"
aria-label={label}
aria-haspopup="true"
aria-expanded={open}
onClick={toggle}
>
<DotsIcon />
</button>
{open &&
position &&
createPortal(
<div
ref={panelRef}
className="action-menu-panel"
role="menu"
style={{ top: position.top, right: position.right }}
onClick={close}
>
{children}
</div>,
document.body,
)}
</>
)
}
+1 -27
View File
@@ -3,11 +3,7 @@ import { useEffect, useRef } from 'react'
type Props = {
open: boolean
title: string
message: React.ReactNode
// A final, visually distinct callout for what irreversible actions this
// confirms — kept separate from `message` so callers don't have to hand-roll
// the emphasis every time (e.g. "This cannot be undone.").
warning?: React.ReactNode
message: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
@@ -15,26 +11,10 @@ type Props = {
onCancel: () => void
}
function WarningIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M12 3.5 21.5 20h-19L12 3.5Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M12 9.5v4.2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="12" cy="16.7" r="1" fill="currentColor" />
</svg>
)
}
export function ConfirmDialog({
open,
title,
message,
warning,
confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen',
danger,
@@ -74,12 +54,6 @@ export function ConfirmDialog({
<p id="confirm-dialog-message" className="confirm-dialog-message">
{message}
</p>
{warning && (
<div className="confirm-dialog-warning">
<WarningIcon />
<span>{warning}</span>
</div>
)}
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onCancel}>
{cancelLabel}
+38 -81
View File
@@ -133,44 +133,9 @@ export function ReservationDateTimeField({
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleKeyDown)
// Only locks the background on mobile, where the popover becomes a fixed,
// full-screen overlay that would otherwise let the page behind it scroll —
// on desktop the popover is just a small anchored dropdown, and pinning the
// body there only removes the scrollbar and shifts the whole page sideways.
// Matches the `@media (max-width: 30rem)` breakpoint the popover-to-modal
// CSS switches on.
const isMobile = window.matchMedia('(max-width: 30rem)').matches
if (!isMobile) {
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
}
}
// `overflow: hidden` alone doesn't stop touch-driven rubber-band scrolling on
// iOS Safari, so the body is pinned in place instead and the scroll position
// restored after.
const scrollY = window.scrollY
const body = document.body.style
const previousStyle = { position: body.position, top: body.top, left: body.left, right: body.right }
body.position = 'fixed'
body.top = `-${scrollY}px`
body.left = '0'
body.right = '0'
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
body.position = previousStyle.position
body.top = previousStyle.top
body.left = previousStyle.left
body.right = previousStyle.right
// `behavior: 'instant'` is required, not just the default — the page sets
// `scroll-behavior: smooth` globally, which would otherwise animate this
// restore from the top of the page instead of snapping back in place.
window.scrollTo({ top: scrollY, left: 0, behavior: 'instant' })
}
// Only re-run when the popover opens/closes — reopening should reset to the
// currently selected month, not follow every keystroke in the text field.
@@ -285,53 +250,45 @@ export function ReservationDateTimeField({
</button>
{isCalendarOpen && (
<div className="date-popover-backdrop" onClick={() => setIsCalendarOpen(false)}>
<div
className="date-popover"
role="dialog"
aria-modal="true"
aria-label="Datum auswählen"
onClick={(event) => event.stopPropagation()}
>
<div className="date-popover-header">
<button
type="button"
aria-label="Vorheriger Monat"
disabled={isAtEarliestMonth}
onClick={() => goToMonth(-1)}
>
‹
</button>
<span>{monthLabel}</span>
<button
type="button"
aria-label="Nächster Monat"
disabled={isAtLatestMonth}
onClick={() => goToMonth(1)}
>
›
</button>
</div>
<div className="date-popover-weekdays">
{WEEKDAY_HEADER_LABELS.map((label) => (
<span key={label}>{label}</span>
<div className="date-popover" role="dialog" aria-label="Datum auswählen">
<div className="date-popover-header">
<button
type="button"
aria-label="Vorheriger Monat"
disabled={isAtEarliestMonth}
onClick={() => goToMonth(-1)}
>
‹
</button>
<span>{monthLabel}</span>
<button
type="button"
aria-label="Nächster Monat"
disabled={isAtLatestMonth}
onClick={() => goToMonth(1)}
>
›
</button>
</div>
<div className="date-popover-weekdays">
{WEEKDAY_HEADER_LABELS.map((label) => (
<span key={label}>{label}</span>
))}
</div>
<div className="date-popover-grid">
{getMonthGrid(view.year, view.month)
.flat()
.map((cell, index) => (
<button
key={cell?.dateStr ?? `blank-${index}`}
type="button"
className={cell?.dateStr === date ? 'is-selected' : undefined}
disabled={!cell || !isSelectableDate(cell.dateStr)}
onClick={() => cell && handleDayPick(cell.dateStr)}
>
{cell?.day ?? ''}
</button>
))}
</div>
<div className="date-popover-grid">
{getMonthGrid(view.year, view.month)
.flat()
.map((cell, index) => (
<button
key={cell?.dateStr ?? `blank-${index}`}
type="button"
className={cell?.dateStr === date ? 'is-selected' : undefined}
disabled={!cell || !isSelectableDate(cell.dateStr)}
onClick={() => cell && handleDayPick(cell.dateStr)}
>
{cell?.day ?? ''}
</button>
))}
</div>
</div>
</div>
)}
@@ -1,136 +0,0 @@
import { useEffect } from 'react'
import { ActionMenu } from '#/components/action-menu'
import { RESERVATION_STATUS_LABELS, type ReservationStatus, type getReservations } from '#/lib/reservations'
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
type Props = {
reservation: ReservationRow | null
onClose: () => void
onStatusChange: (status: ReservationStatus) => void
isUpdatingStatus?: boolean
onEdit: () => void
onDelete: () => void
isDeleting?: boolean
}
const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[]
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
timeStyle: 'short',
})
export function ReservationDetailDialog({
reservation,
onClose,
onStatusChange,
isUpdatingStatus,
onEdit,
onDelete,
isDeleting,
}: Props) {
useEffect(() => {
if (!reservation) return
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [reservation, onClose])
if (!reservation) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-detail-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="reservation-detail-title"
onClick={(event) => event.stopPropagation()}
>
<div className="confirm-dialog-header">
<h2 id="reservation-detail-title" className="confirm-dialog-title">
Reservierung von {reservation.customerName}
</h2>
<ActionMenu label={`Aktionen für Reservierung von ${reservation.customerName}`}>
<button type="button" className="btn btn-secondary" onClick={onEdit}>
Bearbeiten
</button>
<button type="button" className="btn btn-danger" disabled={isDeleting} onClick={onDelete}>
{isDeleting ? 'Löschen …' : 'Löschen'}
</button>
</ActionMenu>
</div>
<div className="status-options">
{STATUS_OPTIONS.map((status) => (
<button
key={status}
type="button"
className={`status-badge status-option status-${status}${
reservation.status === status ? ' is-active' : ''
}`}
disabled={isUpdatingStatus}
onClick={() => onStatusChange(status)}
>
{RESERVATION_STATUS_LABELS[status]}
</button>
))}
</div>
<dl className="detail-list">
{reservation.tableNumber !== null && (
<div>
<dt>Tischnummer</dt>
<dd>{reservation.tableNumber}</dd>
</div>
)}
<div>
<dt>Tisch</dt>
<dd>{reservation.tableName}</dd>
</div>
<div>
<dt>Datum &amp; Uhrzeit</dt>
<dd>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</dd>
</div>
<div>
<dt>Personen</dt>
<dd>{reservation.partySize}</dd>
</div>
<div>
<dt>E-Mail</dt>
<dd>
<a href={`mailto:${reservation.customerEmail}`}>{reservation.customerEmail}</a>
</dd>
</div>
{reservation.customerPhone && (
<div>
<dt>Telefon</dt>
<dd>
<a href={`tel:${reservation.customerPhone}`}>{reservation.customerPhone}</a>
</dd>
</div>
)}
</dl>
{reservation.message && (
<div className="detail-message">
<span className="detail-message-label">Nachricht</span>
<p>{reservation.message}</p>
</div>
)}
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
)
}
@@ -1,83 +0,0 @@
import { useEffect } from 'react'
import type { listStaffMembers } from '#/lib/staff'
type StaffMember = Awaited<ReturnType<typeof listStaffMembers>>[number]
type Props = {
member: StaffMember | null
onClose: () => void
}
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
})
export function StaffMemberDetailDialog({ member, onClose }: Props) {
useEffect(() => {
if (!member) return
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [member, onClose])
if (!member) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-detail-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="staff-member-detail-title"
onClick={(event) => event.stopPropagation()}
>
<h2 id="staff-member-detail-title" className="confirm-dialog-title">
{member.name}
</h2>
<dl className="detail-list">
<div>
<dt>E-Mail</dt>
<dd>
<a href={`mailto:${member.email}`}>{member.email}</a>
</dd>
</div>
<div>
<dt>Telefon</dt>
<dd>{member.phone ? <a href={`tel:${member.phone}`}>{member.phone}</a> : '—'}</dd>
</div>
<div>
<dt>Rolle</dt>
<dd>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{member.banned ? 'Gesperrt' : 'Aktiv'}</dd>
</div>
{member.banned && member.banReason && (
<div>
<dt>Sperrgrund</dt>
<dd>{member.banReason}</dd>
</div>
)}
<div>
<dt>Angelegt am</dt>
<dd>{dateFormatter.format(new Date(member.createdAt))}</dd>
</div>
</dl>
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
)
}
-10
View File
@@ -16,16 +16,6 @@ export function StaffTabs({ role }: Props) {
>
Reservierungen
</Link>
{role === 'admin' && (
<Link
className="staff-tab"
to="/staff/tables"
activeProps={{ className: 'is-active' }}
viewTransition
>
Tische
</Link>
)}
{role === 'admin' && (
<Link
className="staff-tab"
-5
View File
@@ -1,5 +0,0 @@
import { RESERVATION_STATUS_LABELS, type ReservationStatus } from '#/lib/reservations'
export function StatusBadge({ status }: { status: ReservationStatus }) {
return <span className={`status-badge status-${status}`}>{RESERVATION_STATUS_LABELS[status]}</span>
}
-68
View File
@@ -1,68 +0,0 @@
import { useEffect } from 'react'
import type { getTables } from '#/lib/reservations'
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
type Props = {
table: TableRow | null
onClose: () => void
}
export function TableDetailDialog({ table, onClose }: Props) {
useEffect(() => {
if (!table) return
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [table, onClose])
if (!table) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-detail-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="table-detail-title"
onClick={(event) => event.stopPropagation()}
>
<h2 id="table-detail-title" className="confirm-dialog-title">
{table.name}
</h2>
{table.imageUrl && <img className="table-preview" src={table.imageUrl} alt="" />}
<dl className="detail-list">
<div>
<dt>Tischnummer</dt>
<dd>{table.tableNumber ?? '—'}</dd>
</div>
<div>
<dt>Name</dt>
<dd>{table.name}</dd>
</div>
<div>
<dt>Plätze</dt>
<dd>{table.seats}</dd>
</div>
<div>
<dt>Marker</dt>
<dd>{table.markerX !== null && table.markerY !== null ? 'Gesetzt' : 'Nicht gesetzt'}</dd>
</div>
</dl>
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
)
}
+1 -36
View File
@@ -1,24 +1,10 @@
import { sql } from 'drizzle-orm'
import { boolean, doublePrecision, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
import { boolean, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
export const restaurantTables = pgTable('restaurant_tables', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
seats: integer('seats').notNull(),
// The number shown to guests (on the table's marker, etc.) — distinct from
// `name`, which can be a description like "Eck Tisch". Nullable so existing
// rows created before this field don't need a value until staff set one.
tableNumber: integer('table_number'),
// A data: URL of the uploaded photo, stored inline rather than as a file on disk —
// there's no separate object storage in this app, and this keeps the image tied
// to the row with no extra infra. Null until staff upload one.
imageUrl: text('image_url'),
// Where this table's pin sits on the settings overview photo, as a percentage
// (0-100) of that image's width/height — resolution-independent, so the same
// marker lines up whether it's rendered in the admin editor or on the public
// booking page. Both null until staff place one; always set together.
markerX: doublePrecision('marker_x'),
markerY: doublePrecision('marker_y'),
})
export const reservations = pgTable(
@@ -30,20 +16,7 @@ export const reservations = pgTable(
.references(() => restaurantTables.id, { onDelete: 'cascade' }),
customerName: text('customer_name').notNull(),
customerEmail: text('customer_email').notNull(),
// Nullable so existing rows created before this field don't need a value —
// required going forward by the create/update zod schema instead.
customerPhone: text('customer_phone'),
// An optional free-text note from whoever booked — allergies, a birthday, a
// seating preference. Null rather than '' when nothing was entered.
message: text('message'),
partySize: integer('party_size').notNull(),
// 'confirmed' | 'no_show' | 'completed' — an app-level enum (validated by the
// zod schema in lib/reservations.ts, not a pg enum), matching the pattern
// already used for restaurant_settings.table_display_mode. Every reservation
// is confirmed at booking time, so that's the default; staff mark the other
// two afterward. Cancelling still deletes the row (and emails the guest) —
// this doesn't introduce a "cancelled" status.
status: text('status').notNull().default('confirmed'),
reservationTime: timestamp('reservation_time', { withTimezone: true }).notNull(),
// Captured from the reservation-duration setting at booking time, so a later
// change to that setting doesn't retroactively change how long past bookings
@@ -65,11 +38,6 @@ export const restaurantSettings = pgTable('restaurant_settings', {
reservationDurationMinutes: integer('reservation_duration_minutes').notNull().default(90),
minLeadTimeMinutes: integer('min_lead_time_minutes').notNull().default(60),
maxLeadTimeDays: integer('max_lead_time_days').notNull().default(60),
// Whether the booking form shows a photo of the selected table ('individual') or
// a single always-visible photo of the whole room ('overview') — an app-level
// enum, validated by the zod schema in lib/settings.ts rather than a pg enum.
tableDisplayMode: text('table_display_mode').notNull().default('individual'),
tableOverviewImageUrl: text('table_overview_image_url'),
})
// Better Auth's standard schema (staff accounts) — see https://www.better-auth.com/docs/concepts/database
@@ -86,9 +54,6 @@ export const user = pgTable('user', {
banned: boolean('banned'),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires'),
// Registered with Better Auth as a `user.additionalFields` entry in
// auth-instance.ts — not part of its base schema, same as `role`/`banned` above.
phone: text('phone'),
})
export const session = pgTable('session', {
-13
View File
@@ -24,19 +24,6 @@ function createAuth() {
// Staff accounts are provisioned via the seed script, not public self sign-up.
disableSignUp: true,
},
user: {
additionalFields: {
// `required` here only governs Better Auth's own self-service sign-up/
// update-user flows, which this app doesn't use for staff — accounts go
// through admin.createUser, whose `data` field isn't schema-validated by
// Better Auth, so the actual "required" enforcement lives in the staff
// team UI (staff.team.tsx) instead.
phone: {
type: 'string',
required: true,
},
},
},
session: {
expiresIn: 60 * 60 * 12,
},
-15
View File
@@ -42,7 +42,6 @@ function getResendClient() {
type ReservationEmailInfo = {
customerName: string
customerEmail: string
message?: string | null
partySize: number
reservationTime: Date
cancelToken: string
@@ -132,19 +131,6 @@ function renderReservationEmailHtml(
</table>
</td>
</tr>
${
reservation.message
? `
<tr>
<td style="padding-top:16px;">
<div style="color:${COLORS.textMuted};font:400 12px/1.4 'Helvetica Neue',Arial,sans-serif;letter-spacing:0.03em;text-transform:uppercase;padding-bottom:4px;">
Nachricht
</div>
<div style="color:${COLORS.text};font-size:14px;">${escapeHtml(reservation.message)}</div>
</td>
</tr>`
: ''
}
${
showActions
? `
@@ -204,7 +190,6 @@ function renderReservationEmailText(
`Tisch: ${reservation.table.name}`,
`Datum & Uhrzeit: ${formattedTime}`,
`Personen: ${reservation.partySize}`,
...(reservation.message ? [`Nachricht: ${reservation.message}`] : []),
'',
...(showActions
? [
-15
View File
@@ -1,15 +0,0 @@
import { z } from 'zod'
// Shared between the customer reservation form (lib/reservations.ts) and the
// staff phone field (routes/staff.team.tsx), so "a valid phone number" means
// the same thing everywhere in the app.
export const phoneSchema = z
.string()
.trim()
.max(30, 'Enter a valid phone number')
// Optional leading `+` plus digits and common separators (spaces, dashes,
// dots, parentheses) — permissive about international formats, but the
// character class alone would still accept e.g. "()---" or "+", so the
// refine below requires it to actually contain a real number of digits.
.regex(/^\+?[0-9()\-.\s]+$/, 'Enter a valid phone number')
.refine((value) => (value.match(/\d/g)?.length ?? 0) >= 6, 'Enter a valid phone number')
+5 -172
View File
@@ -11,10 +11,7 @@ import {
sendReservationUpdateEmail,
} from '#/lib/email'
import { isOnReservationInterval, isWithinOpeningHours } from '#/lib/opening-hours'
import { phoneSchema } from '#/lib/phone'
import { zodValidator } from '#/lib/server-validator'
import { loadReservationSettings } from '#/lib/settings'
import { dataUrlImageSchema } from '#/lib/table-image'
function formatLeadTime(minutes: number) {
if (minutes % 60 === 0) {
@@ -80,120 +77,6 @@ export const getTables = createServerFn({ method: 'GET' }).handler(async () => {
return db.select().from(restaurantTables).orderBy(restaurantTables.name)
})
const tableSchema = z.object({
name: z.string().trim().min(1, 'Name ist erforderlich').max(120),
tableNumber: z.number().int().positive('Tischnummer muss positiv sein'),
seats: z.number().int().min(1, 'Mindestens 1 Platz').max(50),
imageUrl: dataUrlImageSchema,
})
export const createTable = createServerFn({ method: 'POST' })
.validator(zodValidator(tableSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
const [table] = await db.insert(restaurantTables).values(data).returning()
return table
})
const updateTableSchema = tableSchema.extend({
id: z.number().int().positive(),
})
export const updateTable = createServerFn({ method: 'POST' })
.validator(zodValidator(updateTableSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
const [updated] = await db
.update(restaurantTables)
.set({ name: data.name, tableNumber: data.tableNumber, seats: data.seats, imageUrl: data.imageUrl })
.where(eq(restaurantTables.id, data.id))
.returning()
if (!updated) {
throw new Error('Tisch wurde nicht gefunden')
}
return updated
})
const deleteTableSchema = z.object({
id: z.number().int().positive(),
})
export const deleteTable = createServerFn({ method: 'POST' })
.validator(zodValidator(deleteTableSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
// The FK from reservations to this table cascades on delete, which would
// silently wipe out (and never notify) any guest booked on it. Deleting
// reservations individually first goes through deleteReservation, which
// does send a cancellation email.
const [existingReservation] = await db
.select({ id: reservations.id })
.from(reservations)
.where(eq(reservations.tableId, data.id))
.limit(1)
if (existingReservation) {
throw new Error(
'Tisch kann nicht gelöscht werden, solange Reservierungen dafür bestehen — bitte zuerst verschieben oder löschen',
)
}
const [deleted] = await db.delete(restaurantTables).where(eq(restaurantTables.id, data.id)).returning()
if (!deleted) {
throw new Error('Tisch wurde nicht gefunden')
}
return deleted
})
const setTableMarkerSchema = z.object({
id: z.number().int().positive(),
// Both coordinates set together (a marker) or both cleared (none) — there's no
// meaningful state for only one of them being present.
marker: z
.object({
x: z.number().min(0).max(100),
y: z.number().min(0).max(100),
})
.nullable(),
})
export const setTableMarker = createServerFn({ method: 'POST' })
.validator(zodValidator(setTableMarkerSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
const [updated] = await db
.update(restaurantTables)
.set({ markerX: data.marker?.x ?? null, markerY: data.marker?.y ?? null })
.where(eq(restaurantTables.id, data.id))
.returning()
if (!updated) {
throw new Error('Tisch wurde nicht gefunden')
}
return updated
})
export const getReservations = createServerFn({ method: 'GET' }).handler(async () => {
await requireStaffSession()
@@ -202,13 +85,9 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
id: reservations.id,
tableId: reservations.tableId,
tableName: restaurantTables.name,
tableNumber: restaurantTables.tableNumber,
customerName: reservations.customerName,
customerEmail: reservations.customerEmail,
customerPhone: reservations.customerPhone,
message: reservations.message,
partySize: reservations.partySize,
status: reservations.status,
reservationTime: reservations.reservationTime,
durationMinutes: reservations.durationMinutes,
})
@@ -221,13 +100,6 @@ const createReservationSchema = z.object({
tableId: z.number().int().positive(),
customerName: z.string().trim().min(1, 'Name is required').max(120),
customerEmail: z.string().trim().email('Enter a valid email'),
customerPhone: phoneSchema,
message: z
.string()
.trim()
.max(500, 'Message must be at most 500 characters')
.optional()
.transform((value) => value || null),
partySize: z.number().int().min(1, 'At least 1 guest').max(20),
reservationTime: z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Enter a valid date and time',
@@ -235,7 +107,7 @@ const createReservationSchema = z.object({
})
export const createReservation = createServerFn({ method: 'POST' })
.validator(zodValidator(createReservationSchema))
.validator(createReservationSchema)
.handler(async ({ data }) => {
const settings = await loadReservationSettings()
const reservationTime = new Date(data.reservationTime)
@@ -276,8 +148,6 @@ export const createReservation = createServerFn({ method: 'POST' })
tableId: data.tableId,
customerName: data.customerName,
customerEmail: data.customerEmail,
customerPhone: data.customerPhone,
message: data.message,
durationMinutes: settings.reservationDurationMinutes,
partySize: data.partySize,
reservationTime,
@@ -287,7 +157,6 @@ export const createReservation = createServerFn({ method: 'POST' })
await sendReservationConfirmationEmail({
customerName: reservation.customerName,
customerEmail: reservation.customerEmail,
message: reservation.message,
partySize: reservation.partySize,
reservationTime: reservation.reservationTime,
cancelToken: reservation.cancelToken,
@@ -302,7 +171,7 @@ const updateReservationSchema = createReservationSchema.extend({
})
export const updateReservation = createServerFn({ method: 'POST' })
.validator(zodValidator(updateReservationSchema))
.validator(updateReservationSchema)
.handler(async ({ data }) => {
await requireStaffSession()
@@ -346,8 +215,6 @@ export const updateReservation = createServerFn({ method: 'POST' })
tableId: data.tableId,
customerName: data.customerName,
customerEmail: data.customerEmail,
customerPhone: data.customerPhone,
message: data.message,
partySize: data.partySize,
reservationTime,
durationMinutes: settings.reservationDurationMinutes,
@@ -362,7 +229,6 @@ export const updateReservation = createServerFn({ method: 'POST' })
await sendReservationUpdateEmail({
customerName: updated.customerName,
customerEmail: updated.customerEmail,
message: updated.message,
partySize: updated.partySize,
reservationTime: updated.reservationTime,
cancelToken: updated.cancelToken,
@@ -372,45 +238,12 @@ export const updateReservation = createServerFn({ method: 'POST' })
return updated
})
export const reservationStatusSchema = z.enum(['confirmed', 'no_show', 'completed'])
export type ReservationStatus = z.infer<typeof reservationStatusSchema>
export const RESERVATION_STATUS_LABELS: Record<ReservationStatus, string> = {
confirmed: 'Bestätigt',
no_show: 'Nicht erschienen',
completed: 'Abgeschlossen',
}
const updateReservationStatusSchema = z.object({
id: z.number().int().positive(),
status: reservationStatusSchema,
})
export const updateReservationStatus = createServerFn({ method: 'POST' })
.validator(zodValidator(updateReservationStatusSchema))
.handler(async ({ data }) => {
await requireStaffSession()
const [updated] = await db
.update(reservations)
.set({ status: data.status })
.where(eq(reservations.id, data.id))
.returning()
if (!updated) {
throw new Error('Reservation not found')
}
return updated
})
const deleteReservationSchema = z.object({
id: z.number().int().positive(),
})
export const deleteReservation = createServerFn({ method: 'POST' })
.validator(zodValidator(deleteReservationSchema))
.validator(deleteReservationSchema)
.handler(async ({ data }) => {
await requireStaffSession()
@@ -444,7 +277,7 @@ const getReservationByCancelTokenSchema = z.object({
})
export const getReservationByCancelToken = createServerFn({ method: 'GET' })
.validator(zodValidator(getReservationByCancelTokenSchema))
.validator(getReservationByCancelTokenSchema)
.handler(async ({ data }) => {
const [reservation] = await db
.select({
@@ -465,7 +298,7 @@ const cancelReservationByTokenSchema = z.object({
})
export const cancelReservationByToken = createServerFn({ method: 'POST' })
.validator(zodValidator(cancelReservationByTokenSchema))
.validator(cancelReservationByTokenSchema)
.handler(async ({ data }) => {
const [deleted] = await db
.delete(reservations)
-17
View File
@@ -1,17 +0,0 @@
import type { z, ZodType } from 'zod'
// `createServerFn().validator(schema)` passes a zod schema straight through to
// TanStack Start, which on failure throws `new Error(JSON.stringify(issues))` —
// the raw issue array, not a message meant for a user to read. Wrapping the
// schema in a plain function (`.validator(zodValidator(schema))`) routes
// validation through this instead, so a rejected field surfaces its own zod
// `.message` (e.g. "Enter a valid phone number") rather than a JSON blob.
export function zodValidator<Schema extends ZodType>(schema: Schema) {
return (input: unknown): z.infer<Schema> => {
const result = schema.safeParse(input)
if (!result.success) {
throw new Error(result.error.issues[0]?.message ?? 'Invalid input')
}
return result.data
}
}
+1 -5
View File
@@ -5,8 +5,6 @@ import { z } from 'zod'
import { db } from '#/db'
import { restaurantSettings } from '#/db/schema'
import { requireStaffSession } from '#/lib/auth'
import { zodValidator } from '#/lib/server-validator'
import { dataUrlImageSchema } from '#/lib/table-image'
// Reads never require a session — the public booking form needs the interval and
// lead time to generate its own time-slot grid, just like getTables(). Exported
@@ -33,12 +31,10 @@ const updateReservationSettingsSchema = z.object({
reservationDurationMinutes: z.number().int().min(15).max(480),
minLeadTimeMinutes: z.number().int().min(0).max(10080),
maxLeadTimeDays: z.number().int().min(1).max(365),
tableDisplayMode: z.enum(['individual', 'overview']),
tableOverviewImageUrl: dataUrlImageSchema,
})
export const updateReservationSettings = createServerFn({ method: 'POST' })
.validator(zodValidator(updateReservationSettingsSchema))
.validator(updateReservationSettingsSchema)
.handler(async ({ data }) => {
console.log('[settings] updateReservationSettings called with', data)
+1 -5
View File
@@ -11,9 +11,5 @@ export const listStaffMembers = createServerFn({ method: 'GET' }).handler(async
query: { sortBy: 'createdAt', sortDirection: 'asc', limit: 100 },
})
// `listUsers`'s return type doesn't pick up `user.additionalFields` from the
// auth config the way the actual response body does — `phone` is present at
// runtime (Better Auth includes every configured additional field on every
// user-returning endpoint) but missing from admin.d.ts's `UserWithRole` type.
return users as Array<(typeof users)[number] & { phone: string | null }>
return users
})
-24
View File
@@ -1,24 +0,0 @@
import { z } from 'zod'
// Generous enough for a photo, small enough to keep rows (and the customer-facing
// page that loads them up front) from bloating — these are data: URLs stored
// inline, not uploads to disk-backed storage, which this app doesn't have.
export const MAX_TABLE_IMAGE_BYTES = 2_000_000
export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp']
export const dataUrlImageSchema = z
.string()
.trim()
.max(Math.ceil((MAX_TABLE_IMAGE_BYTES * 4) / 3) + 100)
.regex(/^data:image\/(png|jpeg|jpg|webp);base64,/, 'Bild muss als PNG, JPEG oder WebP hochgeladen werden')
.nullable()
export function readImageAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error ?? new Error('Datei konnte nicht gelesen werden'))
reader.readAsDataURL(file)
})
}
-21
View File
@@ -14,7 +14,6 @@ import { Route as ImpressumRouteImport } from './routes/impressum'
import { Route as StaffIndexRouteImport } from './routes/staff.index'
import { Route as StaffLoginRouteImport } from './routes/staff.login'
import { Route as StaffSettingsRouteImport } from './routes/staff.settings'
import { Route as StaffTablesRouteImport } from './routes/staff.tables'
import { Route as StaffTeamRouteImport } from './routes/staff.team'
import { Route as StornierenTokenRouteImport } from './routes/stornieren.$token'
@@ -43,11 +42,6 @@ const StaffSettingsRoute = StaffSettingsRouteImport.update({
path: '/staff/settings',
getParentRoute: () => rootRouteImport,
} as any)
const StaffTablesRoute = StaffTablesRouteImport.update({
id: '/staff/tables',
path: '/staff/tables',
getParentRoute: () => rootRouteImport,
} as any)
const StaffTeamRoute = StaffTeamRouteImport.update({
id: '/staff/team',
path: '/staff/team',
@@ -64,7 +58,6 @@ export interface FileRoutesByFullPath {
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute
'/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute
'/staff/': typeof StaffIndexRoute
@@ -74,7 +67,6 @@ export interface FileRoutesByTo {
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute
'/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute
'/staff': typeof StaffIndexRoute
@@ -85,7 +77,6 @@ export interface FileRoutesById {
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute
'/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute
'/staff/': typeof StaffIndexRoute
@@ -97,7 +88,6 @@ export interface FileRouteTypes {
| '/impressum'
| '/staff/login'
| '/staff/settings'
| '/staff/tables'
| '/staff/team'
| '/stornieren/$token'
| '/staff/'
@@ -107,7 +97,6 @@ export interface FileRouteTypes {
| '/impressum'
| '/staff/login'
| '/staff/settings'
| '/staff/tables'
| '/staff/team'
| '/stornieren/$token'
| '/staff'
@@ -117,7 +106,6 @@ export interface FileRouteTypes {
| '/impressum'
| '/staff/login'
| '/staff/settings'
| '/staff/tables'
| '/staff/team'
| '/stornieren/$token'
| '/staff/'
@@ -128,7 +116,6 @@ export interface RootRouteChildren {
ImpressumRoute: typeof ImpressumRoute
StaffLoginRoute: typeof StaffLoginRoute
StaffSettingsRoute: typeof StaffSettingsRoute
StaffTablesRoute: typeof StaffTablesRoute
StaffTeamRoute: typeof StaffTeamRoute
StornierenTokenRoute: typeof StornierenTokenRoute
StaffIndexRoute: typeof StaffIndexRoute
@@ -171,13 +158,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StaffSettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/staff/tables': {
id: '/staff/tables'
path: '/staff/tables'
fullPath: '/staff/tables'
preLoaderRoute: typeof StaffTablesRouteImport
parentRoute: typeof rootRouteImport
}
'/staff/team': {
id: '/staff/team'
path: '/staff/team'
@@ -200,7 +180,6 @@ const rootRouteChildren: RootRouteChildren = {
ImpressumRoute: ImpressumRoute,
StaffLoginRoute: StaffLoginRoute,
StaffSettingsRoute: StaffSettingsRoute,
StaffTablesRoute: StaffTablesRoute,
StaffTeamRoute: StaffTeamRoute,
StornierenTokenRoute: StornierenTokenRoute,
StaffIndexRoute: StaffIndexRoute,
+14 -75
View File
@@ -38,8 +38,6 @@ function Home() {
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
const [customerName, setCustomerName] = useState('')
const [customerEmail, setCustomerEmail] = useState('')
const [customerPhone, setCustomerPhone] = useState('')
const [message, setMessage] = useState('')
const [partySize, setPartySize] = useState(2)
const [reservationTime, setReservationTime] = useState(() => {
const slot = getNextAvailableSlot(
@@ -53,7 +51,6 @@ function Home() {
const [isSubmitting, setIsSubmitting] = useState(false)
const todayName = getTodayLabelBerlin()
const selectedTable = tables.find((table) => String(table.id) === String(tableId))
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
@@ -77,8 +74,6 @@ function Home() {
tableId: Number(tableId),
customerName,
customerEmail,
customerPhone,
message,
partySize: Number(partySize),
reservationTime: new Date(reservationTime).toISOString(),
},
@@ -87,8 +82,6 @@ function Home() {
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
setCustomerName('')
setCustomerEmail('')
setCustomerPhone('')
setMessage('')
setPartySize(2)
await router.invalidate()
} catch (err) {
@@ -105,11 +98,11 @@ function Home() {
<section className="hero" id="top">
<FlameIcon />
<h1>
Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
Steakhaus <span>bei Mirko</span>
</h1>
<p>
Dicke Steaks, knisterndes Holzkohlefeuer und Gelächter am Nachbartisch – so schmeckt ein
guter Abend mitten in Meppen. Reservieren Sie Ihren Tisch, wir legen schon auf.
Steaks vom Grill, gute Gesellschaft und ein gemütlicher Abend mitten in Meppen. Reservieren
Sie Ihren Tisch – wir freuen uns auf Sie.
</p>
<div className="hero-actions">
<a className="btn btn-primary" href="#reservieren">
@@ -165,36 +158,6 @@ function Home() {
</select>
</label>
{settings.tableDisplayMode === 'overview' ? (
settings.tableOverviewImageUrl && (
<div className="table-overview-wrap">
<img className="table-overview-image" src={settings.tableOverviewImageUrl} alt="Übersicht unserer Tische" />
{tables
.filter((table) => table.markerX !== null && table.markerY !== null)
.map((table) => (
<button
key={table.id}
type="button"
className={`table-marker${String(table.id) === String(tableId) ? ' is-selected' : ''}`}
style={{ left: `${table.markerX}%`, top: `${table.markerY}%` }}
onClick={() => setTableId(table.id)}
aria-label={`${table.name} auswählen`}
>
<span className="table-marker-label">{table.tableNumber ?? '?'}</span>
</button>
))}
</div>
)
) : (
selectedTable?.imageUrl && (
<img
className="table-preview"
src={selectedTable.imageUrl}
alt={`Ansicht von ${selectedTable.name}`}
/>
)
)}
<div className="form-row">
<label>
Name
@@ -218,19 +181,6 @@ function Home() {
</div>
<div className="form-row">
<label>
Telefon
<input
type="tel"
inputMode="tel"
pattern="[+]?[0-9()\-.\s]{6,30}"
title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)}
required
/>
</label>
<label>
Anzahl Personen
<input
@@ -243,30 +193,19 @@ function Home() {
required
/>
</label>
<label>
Datum &amp; Uhrzeit
<ReservationDateTimeField
value={reservationTime}
onChange={setReservationTime}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
</div>
<label>
Datum &amp; Uhrzeit
<ReservationDateTimeField
value={reservationTime}
onChange={setReservationTime}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
<label>
Nachricht (optional)
<textarea
rows={3}
maxLength={500}
placeholder="Allergien, ein besonderer Anlass, Sitzwünsche …"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</label>
<p className="field-hint">
Verfügbare Uhrzeiten richten sich nach unseren{' '}
<a href="#oeffnungszeiten">Öffnungszeiten</a>.
+33 -126
View File
@@ -4,22 +4,17 @@ import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-rout
import { ConfirmDialog } from '#/components/confirm-dialog'
import { ReservationCharts } from '#/components/reservation-charts'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { ReservationDetailDialog } from '#/components/reservation-detail-dialog'
import { StaffTabs } from '#/components/staff-tabs'
import { StatusBadge } from '#/components/status-badge'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
import {
RESERVATION_STATUS_LABELS,
createReservation,
deleteReservation,
getReservations,
getTables,
updateReservation,
updateReservationStatus,
type ReservationStatus,
} from '#/lib/reservations'
import { getReservationSettings } from '#/lib/settings'
@@ -59,8 +54,6 @@ type FormState = {
tableId: number | string
customerName: string
customerEmail: string
customerPhone: string
message: string
partySize: number
reservationTime: string
}
@@ -77,8 +70,6 @@ function emptyForm(defaultTableId: number | string, settings: SettingsRow): Form
tableId: defaultTableId,
customerName: '',
customerEmail: '',
customerPhone: '',
message: '',
partySize: 2,
reservationTime: slot ? `${slot.date}T${slot.time}` : '',
}
@@ -95,12 +86,9 @@ function StaffDashboard() {
const [isSubmitting, setIsSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null)
const [viewingReservation, setViewingReservation] = useState<ReservationRow | null>(null)
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
const [searchQuery, setSearchQuery] = useState('')
const [tableFilter, setTableFilter] = useState('all')
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('all')
const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all')
const now = new Date()
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
@@ -109,7 +97,6 @@ function StaffDashboard() {
const filteredReservations = reservations.filter((r) => {
if (tableFilter !== 'all' && String(r.tableId) !== tableFilter) return false
if (statusFilter !== 'all' && r.status !== statusFilter) return false
const reservationTime = new Date(r.reservationTime)
if (timeFilter === 'today' && !isSameDay(reservationTime, now)) return false
@@ -118,7 +105,7 @@ function StaffDashboard() {
const query = searchQuery.trim().toLowerCase()
if (query) {
const haystack = `${r.customerName} ${r.customerEmail} ${r.customerPhone ?? ''} ${r.tableName} ${r.tableNumber ?? ''}`.toLowerCase()
const haystack = `${r.customerName} ${r.customerEmail} ${r.tableName}`.toLowerCase()
if (!haystack.includes(query)) return false
}
@@ -157,8 +144,6 @@ function StaffDashboard() {
tableId: reservation.tableId,
customerName: reservation.customerName,
customerEmail: reservation.customerEmail,
customerPhone: reservation.customerPhone ?? '',
message: reservation.message ?? '',
partySize: reservation.partySize,
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
})
@@ -193,8 +178,6 @@ function StaffDashboard() {
tableId: Number(form.tableId),
customerName: form.customerName,
customerEmail: form.customerEmail,
customerPhone: form.customerPhone,
message: form.message,
partySize: Number(form.partySize),
reservationTime: new Date(form.reservationTime).toISOString(),
}
@@ -240,23 +223,6 @@ function StaffDashboard() {
}
}
async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) {
setIsUpdatingStatus(true)
setError(null)
setSuccess(null)
try {
await updateReservationStatus({ data: { id: reservation.id, status } })
setViewingReservation((current) => (current ? { ...current, status } : current))
setSuccess(`Status wurde auf „${RESERVATION_STATUS_LABELS[status]}“ geändert.`)
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsUpdatingStatus(false)
}
}
async function handleLogout() {
await authClient.signOut()
await router.navigate({ to: '/staff/login' })
@@ -354,19 +320,6 @@ function StaffDashboard() {
</div>
<div className="form-row">
<label>
Telefon
<input
type="tel"
inputMode="tel"
pattern="[+]?[0-9()\-.\s]{6,30}"
title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={form.customerPhone}
onChange={(e) => setForm((f) => ({ ...f, customerPhone: e.target.value }))}
required
/>
</label>
<label>
Anzahl Personen
<input
@@ -379,30 +332,20 @@ function StaffDashboard() {
required
/>
</label>
<label>
Datum &amp; Uhrzeit
<ReservationDateTimeField
value={form.reservationTime}
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
</div>
<label>
Datum &amp; Uhrzeit
<ReservationDateTimeField
value={form.reservationTime}
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
<label>
Nachricht (optional)
<textarea
rows={2}
maxLength={500}
value={form.message}
onChange={(e) => setForm((f) => ({ ...f, message: e.target.value }))}
/>
</label>
<div className="form-actions">
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gespeichert …' : editingId ? 'Speichern' : 'Anlegen'}
@@ -434,7 +377,7 @@ function StaffDashboard() {
<option value="all">Alle Tische</option>
{tables.map((table) => (
<option key={table.id} value={String(table.id)}>
{table.tableNumber === null ? table.name : `${table.tableNumber} — ${table.name}`}
{table.name}
</option>
))}
</select>
@@ -448,18 +391,6 @@ function StaffDashboard() {
<option value="upcoming">Anstehend</option>
<option value="past">Vergangen</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
aria-label="Nach Status filtern"
>
<option value="all">Alle Status</option>
{Object.entries(RESERVATION_STATUS_LABELS).map(([status, label]) => (
<option key={status} value={status}>
{label}
</option>
))}
</select>
</div>
)}
@@ -471,44 +402,42 @@ function StaffDashboard() {
</p>
) : (
<div className="table-scroll">
<table className="reservations-table reservations-table-clickable">
<table className="reservations-table">
<thead>
<tr>
<th>Datum &amp; Uhrzeit</th>
<th>Nr.</th>
<th>Tisch</th>
<th>Kunde</th>
<th>Personen</th>
<th>Status</th>
<th>Nachricht</th>
<th></th>
</tr>
</thead>
<tbody>
{filteredReservations.map((reservation) => (
<tr
key={reservation.id}
className={editingId === reservation.id ? 'is-editing' : undefined}
onClick={() => setViewingReservation(reservation)}
>
<tr key={reservation.id} className={editingId === reservation.id ? 'is-editing' : undefined}>
<td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td>
<td>{reservation.tableNumber ?? '—'}</td>
<td>{reservation.tableName}</td>
<td>
{reservation.customerName}
<br />
<span className="muted">{reservation.customerEmail}</span>
{reservation.customerPhone && (
<>
<br />
<span className="muted">{reservation.customerPhone}</span>
</>
)}
</td>
<td>{reservation.partySize}</td>
<td>
<StatusBadge status={reservation.status as ReservationStatus} />
<div className="table-actions">
<button type="button" className="btn btn-secondary" onClick={() => startEdit(reservation)}>
Bearbeiten
</button>
<button
type="button"
className="btn btn-danger"
disabled={deletingId === reservation.id}
onClick={() => setPendingDelete(reservation)}
>
{deletingId === reservation.id ? 'Löschen …' : 'Löschen'}
</button>
</div>
</td>
<td className="message-cell">{reservation.message || <span className="muted">—</span>}</td>
</tr>
))}
</tbody>
@@ -522,37 +451,15 @@ function StaffDashboard() {
open={pendingDelete !== null}
title="Reservierung löschen"
message={
pendingDelete && (
<>
Reservierung von <strong>{pendingDelete.customerName}</strong> wirklich löschen? Der Gast
erhält eine E-Mail zur Bestätigung der Stornierung.
</>
)
pendingDelete
? `Reservierung von ${pendingDelete.customerName} wirklich löschen? Der Gast erhält eine E-Mail zur Bestätigung der Stornierung. Dies kann nicht rückgängig gemacht werden.`
: ''
}
warning="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen"
danger
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
<ReservationDetailDialog
reservation={viewingReservation}
onClose={() => setViewingReservation(null)}
onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)}
isUpdatingStatus={isUpdatingStatus}
onEdit={() => {
if (!viewingReservation) return
startEdit(viewingReservation)
setViewingReservation(null)
}}
onDelete={() => {
if (!viewingReservation) return
setPendingDelete(viewingReservation)
setViewingReservation(null)
}}
isDeleting={viewingReservation !== null && deletingId === viewingReservation.id}
/>
</div>
)
}
+6 -47
View File
@@ -18,35 +18,6 @@ function ArrowLeftIcon() {
)
}
function EyeIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
</svg>
)
}
function EyeOffIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 4l16 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export const Route = createFileRoute('/staff/login')({
component: StaffLogin,
beforeLoad: async () => {
@@ -62,7 +33,6 @@ function StaffLogin() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -108,23 +78,12 @@ function StaffLogin() {
<label>
Passwort
<div className="password-field">
<input
type={showPassword ? 'text' : 'password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button
type="button"
className="password-toggle"
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
aria-pressed={showPassword}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
<button type="submit" disabled={isSubmitting}>
+4 -202
View File
@@ -1,12 +1,10 @@
import { useRef, useState } from 'react'
import { useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { StaffTabs } from '#/components/staff-tabs'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { getTables, setTableMarker } from '#/lib/reservations'
import { getReservationSettings, updateReservationSettings } from '#/lib/settings'
import { ACCEPTED_IMAGE_TYPES, MAX_TABLE_IMAGE_BYTES, readImageAsDataUrl } from '#/lib/table-image'
export const Route = createFileRoute('/staff/settings')({
component: StaffSettings,
@@ -21,8 +19,8 @@ export const Route = createFileRoute('/staff/settings')({
return { staffUser }
},
loader: async ({ context }) => {
const [settings, tables] = await Promise.all([getReservationSettings(), getTables()])
return { staffUser: context.staffUser, settings, tables }
const settings = await getReservationSettings()
return { staffUser: context.staffUser, settings }
},
})
@@ -31,35 +29,21 @@ type FormState = {
reservationDurationMinutes: number
minLeadTimeMinutes: number
maxLeadTimeDays: number
tableDisplayMode: 'individual' | 'overview'
tableOverviewImageUrl: string | null
}
const MAX_IMAGE_MB = Math.floor(MAX_TABLE_IMAGE_BYTES / 1_000_000)
function StaffSettings() {
const router = useRouter()
const { staffUser, settings, tables } = Route.useLoaderData()
const { staffUser, settings } = Route.useLoaderData()
const [form, setForm] = useState<FormState>({
reservationIntervalMinutes: settings.reservationIntervalMinutes,
reservationDurationMinutes: settings.reservationDurationMinutes,
minLeadTimeMinutes: settings.minLeadTimeMinutes,
maxLeadTimeDays: settings.maxLeadTimeDays,
tableDisplayMode: settings.tableDisplayMode === 'overview' ? 'overview' : 'individual',
tableOverviewImageUrl: settings.tableOverviewImageUrl,
})
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const overviewFileInputRef = useRef<HTMLInputElement>(null)
const [markerTableId, setMarkerTableId] = useState<string>('')
const [isPlacingMarker, setIsPlacingMarker] = useState(false)
const [markerError, setMarkerError] = useState<string | null>(null)
const [markerSuccess, setMarkerSuccess] = useState<string | null>(null)
const markerTable = tables.find((table) => String(table.id) === markerTableId)
const tablesWithoutMarker = tables.filter((table) => table.markerX === null || table.markerY === null)
async function handleLogout() {
await authClient.signOut()
@@ -67,67 +51,6 @@ function StaffSettings() {
await router.invalidate()
}
async function handleOverviewImageChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
setError('Bild muss PNG, JPEG oder WebP sein')
return
}
if (file.size > MAX_TABLE_IMAGE_BYTES) {
setError(`Bild ist zu groß (max. ${MAX_IMAGE_MB} MB)`)
return
}
setError(null)
const dataUrl = await readImageAsDataUrl(file)
setForm((f) => ({ ...f, tableOverviewImageUrl: dataUrl }))
}
async function handlePlaceMarker(event: React.MouseEvent<HTMLDivElement>) {
if (!markerTable) {
setMarkerError('Bitte zuerst einen Tisch für den Marker auswählen')
return
}
const rect = event.currentTarget.getBoundingClientRect()
const x = Math.min(100, Math.max(0, ((event.clientX - rect.left) / rect.width) * 100))
const y = Math.min(100, Math.max(0, ((event.clientY - rect.top) / rect.height) * 100))
setMarkerError(null)
setMarkerSuccess(null)
setIsPlacingMarker(true)
try {
await setTableMarker({ data: { id: markerTable.id, marker: { x, y } } })
setMarkerSuccess(`Marker für ${markerTable.name} wurde gesetzt.`)
await router.invalidate()
} catch (err) {
setMarkerError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsPlacingMarker(false)
}
}
async function handleClearMarker() {
if (!markerTable) return
setMarkerError(null)
setMarkerSuccess(null)
setIsPlacingMarker(true)
try {
await setTableMarker({ data: { id: markerTable.id, marker: null } })
setMarkerSuccess(`Marker für ${markerTable.name} wurde entfernt.`)
await router.invalidate()
} catch (err) {
setMarkerError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsPlacingMarker(false)
}
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
@@ -238,62 +161,6 @@ function StaffSettings() {
Wie weit im Voraus reserviert werden kann, z. B. 60 für „höchstens 60 Tage im Voraus“.
</p>
<fieldset className="table-display-fieldset">
<legend>Tischfotos im Buchungsformular</legend>
<label className="radio-option">
<input
type="radio"
name="tableDisplayMode"
checked={form.tableDisplayMode === 'individual'}
onChange={() => setForm((f) => ({ ...f, tableDisplayMode: 'individual' }))}
/>
Foto des gewählten Tisches
</label>
<label className="radio-option">
<input
type="radio"
name="tableDisplayMode"
checked={form.tableDisplayMode === 'overview'}
onChange={() => setForm((f) => ({ ...f, tableDisplayMode: 'overview' }))}
/>
Ein Übersichtsfoto für alle Tische
</label>
</fieldset>
<p className="field-hint">
„Foto des gewählten Tisches“ zeigt das jeweilige Foto aus der Tischverwaltung, sobald ein
Tisch ausgewählt wird. „Übersichtsfoto“ zeigt stattdessen immer dasselbe Bild, z. B. eine
Aufnahme des ganzen Raums.
</p>
{form.tableDisplayMode === 'overview' && (
<>
<label>
Übersichtsfoto
<div className="table-image-field">
{form.tableOverviewImageUrl && (
<img className="table-image-preview" src={form.tableOverviewImageUrl} alt="" />
)}
<input
ref={overviewFileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleOverviewImageChange}
/>
{form.tableOverviewImageUrl && (
<button
type="button"
className="btn btn-secondary"
onClick={() => setForm((f) => ({ ...f, tableOverviewImageUrl: null }))}
>
Bild entfernen
</button>
)}
</div>
</label>
<p className="field-hint">PNG, JPEG oder WebP, max. {MAX_IMAGE_MB} MB.</p>
</>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gespeichert …' : 'Speichern'}
</button>
@@ -302,71 +169,6 @@ function StaffSettings() {
{success && <p className="message message-success">{success}</p>}
</form>
</section>
{settings.tableDisplayMode === 'overview' && settings.tableOverviewImageUrl && (
<section className="staff-form-section">
<h2 className="section-heading staff-subheading">Tisch-Marker</h2>
<p className="field-hint">
Wählen Sie einen Tisch aus und klicken Sie dann auf das gespeicherte Übersichtsfoto, um
seinen Marker zu setzen oder zu verschieben.
</p>
<div className="reservation-form">
<label>
Marker setzen für
<select value={markerTableId} onChange={(e) => setMarkerTableId(e.target.value)}>
<option value="" disabled>
Tisch auswählen
</option>
{tables.map((table) => (
<option key={table.id} value={table.id}>
{table.tableNumber === null ? table.name : `${table.tableNumber} — ${table.name}`}
{table.markerX === null || table.markerY === null ? '' : ' ✓'}
</option>
))}
</select>
</label>
<div
className={`table-overview-wrap${isPlacingMarker ? ' is-busy' : ''}`}
onClick={handlePlaceMarker}
>
<img className="table-overview-image" src={settings.tableOverviewImageUrl} alt="" />
{tables
.filter((table) => table.markerX !== null && table.markerY !== null)
.map((table) => (
<span
key={table.id}
className={`table-marker${String(table.id) === markerTableId ? ' is-active' : ''}`}
style={{ left: `${table.markerX}%`, top: `${table.markerY}%` }}
>
<span className="table-marker-label">{table.tableNumber ?? '?'}</span>
</span>
))}
</div>
{markerTable && (markerTable.markerX !== null || markerTable.markerY !== null) && (
<button
type="button"
className="btn btn-secondary"
disabled={isPlacingMarker}
onClick={handleClearMarker}
>
Marker für {markerTable.name} entfernen
</button>
)}
{tablesWithoutMarker.length > 0 && (
<p className="field-hint">
Noch ohne Marker: {tablesWithoutMarker.map((table) => table.name).join(', ')}
</p>
)}
{markerError && <p className="message message-error">{markerError}</p>}
{markerSuccess && <p className="message message-success">{markerSuccess}</p>}
</div>
</section>
)}
</main>
</div>
)
-338
View File
@@ -1,338 +0,0 @@
import { useRef, useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { ConfirmDialog } from '#/components/confirm-dialog'
import { StaffTabs } from '#/components/staff-tabs'
import { TableDetailDialog } from '#/components/table-detail-dialog'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { createTable, deleteTable, getTables, updateTable } from '#/lib/reservations'
import { ACCEPTED_IMAGE_TYPES, MAX_TABLE_IMAGE_BYTES, readImageAsDataUrl } from '#/lib/table-image'
export const Route = createFileRoute('/staff/tables')({
component: StaffTables,
beforeLoad: async () => {
const staffUser = await getCurrentStaffUser()
if (!staffUser) {
throw redirect({ to: '/staff/login' })
}
if (staffUser.role !== 'admin') {
throw redirect({ to: '/staff' })
}
return { staffUser }
},
loader: async ({ context }) => {
const tables = await getTables()
return { staffUser: context.staffUser, tables }
},
})
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
type FormState = {
name: string
tableNumber: number
seats: number
imageUrl: string | null
}
function emptyForm(): FormState {
return { name: '', tableNumber: 1, seats: 2, imageUrl: null }
}
const MAX_IMAGE_MB = Math.floor(MAX_TABLE_IMAGE_BYTES / 1_000_000)
function StaffTables() {
const router = useRouter()
const { staffUser, tables } = Route.useLoaderData()
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [pendingDelete, setPendingDelete] = useState<TableRow | null>(null)
const [viewingTable, setViewingTable] = useState<TableRow | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
function startEdit(table: TableRow) {
setEditingId(table.id)
setForm({
name: table.name,
tableNumber: table.tableNumber ?? 0,
seats: table.seats,
imageUrl: table.imageUrl,
})
setError(null)
setSuccess(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
function cancelEdit() {
setEditingId(null)
setForm(emptyForm())
setError(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
async function handleImageChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
setError('Bild muss PNG, JPEG oder WebP sein')
return
}
if (file.size > MAX_TABLE_IMAGE_BYTES) {
setError(`Bild ist zu groß (max. ${MAX_IMAGE_MB} MB)`)
return
}
setError(null)
const dataUrl = await readImageAsDataUrl(file)
setForm((f) => ({ ...f, imageUrl: dataUrl }))
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
setSuccess(null)
setIsSubmitting(true)
try {
const payload = {
name: form.name,
tableNumber: Number(form.tableNumber),
seats: Number(form.seats),
imageUrl: form.imageUrl,
}
if (editingId) {
await updateTable({ data: { id: editingId, ...payload } })
setSuccess('Tisch wurde aktualisiert.')
} else {
await createTable({ data: payload })
setSuccess('Tisch wurde angelegt.')
}
cancelEdit()
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
}
async function confirmDelete() {
const table = pendingDelete
if (!table) return
setPendingDelete(null)
setDeletingId(table.id)
setError(null)
setSuccess(null)
try {
await deleteTable({ data: { id: table.id } })
if (editingId === table.id) cancelEdit()
setSuccess('Tisch wurde gelöscht.')
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setDeletingId(null)
}
}
async function handleLogout() {
await authClient.signOut()
await router.navigate({ to: '/staff/login' })
await router.invalidate()
}
return (
<div className="staff-page">
<div className="staff-nav">
<header className="staff-topbar">
<Link className="wordmark" to="/">
Steakhaus <span>bei Mirko</span>
</Link>
<div className="staff-topbar-right">
<span>Angemeldet als {staffUser.name}</span>
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
Abmelden
</button>
</div>
</header>
<div className="staff-tabs-bar">
<StaffTabs role={staffUser.role} />
</div>
</div>
<main className="page staff-main">
<h1 className="section-heading">Tische</h1>
<section className="staff-form-section">
<h2 className="section-heading staff-subheading">
{editingId ? 'Tisch bearbeiten' : 'Neuer Tisch'}
</h2>
<form className="reservation-form" onSubmit={handleSubmit}>
<div className="form-row">
<label>
Tischnummer
<input
type="number"
inputMode="numeric"
min={1}
value={form.tableNumber}
onChange={(e) => setForm((f) => ({ ...f, tableNumber: Number(e.target.value) }))}
required
/>
</label>
<label>
Plätze
<input
type="number"
inputMode="numeric"
min={1}
max={50}
value={form.seats}
onChange={(e) => setForm((f) => ({ ...f, seats: Number(e.target.value) }))}
required
/>
</label>
</div>
<label>
Name
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</label>
<p className="field-hint">
Die Tischnummer wird auf dem Marker angezeigt; der Name ist nur für die Verwaltung hier.
</p>
<label>
Foto
<div className="table-image-field">
{form.imageUrl && <img className="table-image-preview" src={form.imageUrl} alt="" />}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleImageChange}
/>
{form.imageUrl && (
<button
type="button"
className="btn btn-secondary"
onClick={() => setForm((f) => ({ ...f, imageUrl: null }))}
>
Bild entfernen
</button>
)}
</div>
</label>
<p className="field-hint">
Zeigt Gästen im Buchungsformular, wie der Tisch aussieht. PNG, JPEG oder WebP, max.{' '}
{MAX_IMAGE_MB} MB.
</p>
<div className="form-actions">
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gespeichert …' : editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" className="btn btn-secondary" onClick={cancelEdit}>
Abbrechen
</button>
)}
</div>
{error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form>
</section>
<section className="staff-table-section">
{tables.length === 0 ? (
<p className="empty-state">Noch keine Tische vorhanden.</p>
) : (
<div className="table-scroll">
<table className="reservations-table reservations-table-clickable">
<thead>
<tr>
<th></th>
<th>Nr.</th>
<th>Name</th>
<th>Plätze</th>
<th></th>
</tr>
</thead>
<tbody>
{tables.map((table) => (
<tr
key={table.id}
className={editingId === table.id ? 'is-editing' : undefined}
onClick={() => setViewingTable(table)}
>
<td>
{table.imageUrl ? (
<img className="table-thumb" src={table.imageUrl} alt="" />
) : (
<span className="table-thumb table-thumb-empty" aria-hidden="true" />
)}
</td>
<td>{table.tableNumber ?? '—'}</td>
<td>{table.name}</td>
<td>{table.seats}</td>
<td onClick={(event) => event.stopPropagation()}>
<div className="table-actions">
<button type="button" className="btn btn-secondary" onClick={() => startEdit(table)}>
Bearbeiten
</button>
<button
type="button"
className="btn btn-danger"
disabled={deletingId === table.id}
onClick={() => setPendingDelete(table)}
>
{deletingId === table.id ? 'Löschen …' : 'Löschen'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
<ConfirmDialog
open={pendingDelete !== null}
title="Tisch löschen"
message={pendingDelete && <>Tisch <strong>{pendingDelete.name}</strong> wirklich löschen?</>}
warning="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen"
danger
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
<TableDetailDialog table={viewingTable} onClose={() => setViewingTable(null)} />
</div>
)
}
+9 -91
View File
@@ -1,12 +1,9 @@
import { useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { ConfirmDialog } from '#/components/confirm-dialog'
import { StaffMemberDetailDialog } from '#/components/staff-member-detail-dialog'
import { StaffTabs } from '#/components/staff-tabs'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { phoneSchema } from '#/lib/phone'
import { listStaffMembers } from '#/lib/staff'
export const Route = createFileRoute('/staff/team')({
@@ -32,13 +29,12 @@ type StaffMember = Awaited<ReturnType<typeof listStaffMembers>>[number]
type FormState = {
name: string
email: string
phone: string
password: string
role: 'admin' | 'user'
}
function emptyForm(): FormState {
return { name: '', email: '', phone: '', password: '', role: 'user' }
return { name: '', email: '', password: '', role: 'user' }
}
function StaffTeam() {
@@ -50,20 +46,11 @@ function StaffTeam() {
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
const [viewingMember, setViewingMember] = useState<StaffMember | null>(null)
const [pendingRemove, setPendingRemove] = useState<StaffMember | null>(null)
async function handleCreate(event: React.FormEvent) {
event.preventDefault()
setError(null)
setSuccess(null)
const phoneResult = phoneSchema.safeParse(form.phone)
if (!phoneResult.success) {
setError('Bitte geben Sie eine gültige Telefonnummer an')
return
}
setIsSubmitting(true)
const { error: createError } = await authClient.admin.createUser({
@@ -71,7 +58,6 @@ function StaffTeam() {
email: form.email,
password: form.password,
role: form.role,
data: { phone: phoneResult.data },
})
if (createError) {
@@ -86,11 +72,11 @@ function StaffTeam() {
await router.invalidate()
}
async function confirmRemove() {
const member = pendingRemove
if (!member) return
async function handleRemove(member: StaffMember) {
if (!window.confirm(`Zugang für ${member.name} wirklich entfernen?`)) {
return
}
setPendingRemove(null)
setBusyId(member.id)
setError(null)
setSuccess(null)
@@ -150,38 +136,6 @@ function StaffTeam() {
await router.invalidate()
}
async function handleSetPhone(member: StaffMember) {
const newPhone = window.prompt(`Telefonnummer für ${member.name}:`, member.phone ?? '')
if (newPhone === null) {
return
}
const phoneResult = phoneSchema.safeParse(newPhone)
if (!phoneResult.success) {
setError('Bitte geben Sie eine gültige Telefonnummer an')
return
}
setBusyId(member.id)
setError(null)
setSuccess(null)
const { error: phoneError } = await authClient.admin.updateUser({
userId: member.id,
data: { phone: phoneResult.data },
})
if (phoneError) {
setError(phoneError.message ?? 'Etwas ist schiefgelaufen')
} else {
setSuccess(`Telefonnummer für ${member.name} wurde aktualisiert.`)
await router.invalidate()
}
setBusyId(null)
}
async function handleSetPassword(member: StaffMember) {
const newPassword = window.prompt(`Neues Passwort für ${member.name} (mind. 8 Zeichen):`)
@@ -256,19 +210,6 @@ function StaffTeam() {
</label>
</div>
<label>
Telefon
<input
type="tel"
inputMode="tel"
pattern="[+]?[0-9()\-.\s]{6,30}"
title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
required
/>
</label>
<div className="form-row">
<label>
Passwort
@@ -304,12 +245,11 @@ function StaffTeam() {
<section className="staff-table-section">
<div className="table-scroll">
<table className="reservations-table reservations-table-clickable">
<table className="reservations-table">
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Telefon</th>
<th>Rolle</th>
<th>Status</th>
<th></th>
@@ -317,13 +257,12 @@ function StaffTeam() {
</thead>
<tbody>
{members.map((member) => (
<tr key={member.id} onClick={() => setViewingMember(member)}>
<tr key={member.id}>
<td>{member.name}</td>
<td>{member.email}</td>
<td>{member.phone || <span className="muted">—</span>}</td>
<td>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</td>
<td>{member.banned ? 'Gesperrt' : 'Aktiv'}</td>
<td onClick={(event) => event.stopPropagation()}>
<td>
<div className="table-actions">
<button
type="button"
@@ -333,14 +272,6 @@ function StaffTeam() {
>
{member.role === 'admin' ? 'Zu Mitarbeiter machen' : 'Zu Admin machen'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busyId === member.id}
onClick={() => handleSetPhone(member)}
>
Telefon bearbeiten
</button>
<button
type="button"
className="btn btn-secondary"
@@ -361,7 +292,7 @@ function StaffTeam() {
type="button"
className="btn btn-danger"
disabled={busyId === member.id || member.id === staffUser.id}
onClick={() => setPendingRemove(member)}
onClick={() => handleRemove(member)}
>
Entfernen
</button>
@@ -374,19 +305,6 @@ function StaffTeam() {
</div>
</section>
</main>
<StaffMemberDetailDialog member={viewingMember} onClose={() => setViewingMember(null)} />
<ConfirmDialog
open={pendingRemove !== null}
title="Zugang entfernen"
message={pendingRemove && <>Zugang für <strong>{pendingRemove.name}</strong> wirklich entfernen?</>}
warning="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Entfernen"
danger
onConfirm={confirmRemove}
onCancel={() => setPendingRemove(null)}
/>
</div>
)
}
+19 -486
View File
@@ -80,6 +80,13 @@ a {
max-width: 120rem
}
@media (max-width: 30rem) {
.site-header-inner {
justify-content: center;
text-align: center;
}
}
.wordmark {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.15rem;
@@ -142,8 +149,8 @@ a {
}
.hero-flame {
width: 4rem;
height: 4rem;
width: 2.5rem;
height: 2.5rem;
margin: 0 auto 1.5rem;
color: var(--ember);
}
@@ -369,46 +376,6 @@ a {
anchor-name: --anchor-date-time;
}
.password-field {
position: relative;
}
.password-field input {
padding-right: 2.5rem;
}
.password-toggle {
position: absolute;
top: 50%;
right: 0.3rem;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
padding: 0;
border: none;
border-radius: 0.25rem;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.password-toggle:hover {
color: var(--text);
}
.password-toggle svg {
flex-shrink: 0;
width: 18px;
height: 18px;
}
.date-popover-backdrop {
display: contents;
}
.date-popover {
position: absolute;
position-anchor: --anchor-date-time;
@@ -422,52 +389,6 @@ a {
overflow: hidden;
}
@media (max-width: 30rem) {
.date-popover-backdrop {
display: flex;
position: fixed;
inset: 0;
z-index: 100;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.6);
}
.date-popover {
position: static;
width: 100%;
padding: 1rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.45);
animation: confirm-dialog-fade-in 0.15s ease-out;
}
.date-popover-header {
margin-bottom: 0.6rem;
font-size: 1rem;
}
.date-popover-header button {
width: 3rem;
height: 3rem;
font-size: 1.8rem;
}
.date-popover-weekdays span,
.date-popover-grid button {
font-size: 0.95rem;
display: flex;
align-items: center;
justify-content: center;
width: 100% !important;
}
.date-popover-grid button {
aspect-ratio: unset;
padding: 0.5rem 0;
}
}
.date-popover-header {
display: flex;
align-items: center;
@@ -491,7 +412,7 @@ a {
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 1.2rem;
font-size: 0.9rem;
cursor: pointer;
}
@@ -561,8 +482,7 @@ a {
}
.reservation-form input,
.reservation-form select,
.reservation-form textarea {
.reservation-form select {
width: 100%;
min-width: 0;
font-family: inherit;
@@ -574,68 +494,21 @@ a {
color: var(--text);
}
.reservation-form textarea {
resize: vertical;
min-height: 4.5rem;
}
.reservation-form input:focus,
.reservation-form select:focus,
.reservation-form textarea:focus {
.reservation-form select:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 2px rgba(201, 161, 90, 0.25);
}
/* Overrides the generic `.reservation-form input` box styling above — that rule
assumes text-like inputs and would otherwise stretch radios/checkboxes into
full-width bordered rectangles instead of native controls. */
.reservation-form input[type='radio'],
.reservation-form input[type='checkbox'] {
width: 1rem;
height: 1rem;
padding: 0;
border: none;
background: none;
accent-color: var(--gold);
}
.table-display-fieldset {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
border: none;
}
.table-display-fieldset legend {
padding: 0;
margin-bottom: 0.35rem;
font-weight: 500;
font-size: 0.9rem;
color: var(--text-muted);
}
.reservation-form .radio-option {
flex-direction: row;
align-items: center;
gap: 0.5rem;
font-weight: 400;
font-size: 0.95rem;
color: var(--text);
}
/* `:not(.btn)` keeps this scoped to the plain submit buttons — anything already
using the `.btn` component (Abbrechen, Bearbeiten, Löschen, …) styles itself
and shouldn't be overridden here (a previous version of this rule beat
`.btn-secondary` on specificity and left its hover text unreadable).
`:not(.date-popover *):not(.date-picker-trigger):not(.table-marker):not(.password-toggle)`
keeps it off the calendar's own buttons, the overview-photo markers, and the
show-password toggle — they live inside this form too, and without this
exclusion this rule's specificity beats their own button styles. */
.reservation-form
button:not(.btn):not(.date-popover *):not(.date-picker-trigger):not(.table-marker):not(.password-toggle) {
`:not(.date-popover *):not(.date-picker-trigger)` keeps it off the
calendar's own buttons — they live inside this form too, and without this
exclusion this rule's specificity beats the calendar's button styles. */
.reservation-form button:not(.btn):not(.date-popover *):not(.date-picker-trigger) {
align-self: flex-start;
font: inherit;
font-weight: 600;
@@ -647,15 +520,11 @@ a {
cursor: pointer;
}
.reservation-form
button:not(.btn):not(.date-popover *):not(.date-picker-trigger):not(.table-marker):not(.password-toggle):hover:not(
:disabled
) {
.reservation-form button:not(.btn):not(.date-popover *):not(.date-picker-trigger):hover:not(:disabled) {
background: var(--gold-bright);
}
.reservation-form
button:not(.btn):not(.date-popover *):not(.date-picker-trigger):not(.table-marker):not(.password-toggle):disabled {
.reservation-form button:not(.btn):not(.date-popover *):not(.date-picker-trigger):disabled {
opacity: 0.6;
cursor: not-allowed;
}
@@ -798,11 +667,6 @@ a {
max-width: 68rem;
margin: 0 auto;
padding: 0 1.5rem;
overflow: scroll hidden;
&::-webkit-scrollbar {
display: none;
}
}
.staff-tab {
@@ -1124,10 +988,6 @@ a {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 0.5rem;
&::-webkit-scrollbar {
display: none;
}
}
.reservations-table {
@@ -1160,84 +1020,11 @@ a {
background: rgba(243, 234, 217, 0.06);
}
.reservations-table-clickable tbody tr:hover {
background: rgba(243, 234, 217, 0.05);
cursor: pointer;
}
.reservations-table .muted {
color: var(--text-muted);
font-size: 0.8rem;
}
.reservations-table .message-cell {
max-width: 16rem;
overflow: hidden;
text-overflow: ellipsis;
}
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 9.5rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font: inherit;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.status-badge.status-confirmed {
background: rgba(201, 161, 90, 0.15);
color: var(--gold-bright);
border: 1px solid rgba(201, 161, 90, 0.35);
}
.status-badge.status-no_show {
background: #3a1414;
color: #f3a9a9;
border: 1px solid #5c1f1f;
}
.status-badge.status-completed {
background: #17301c;
color: #a9d9b4;
border: 1px solid #23502c;
}
.status-options {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin: 0 0 1.5rem;
}
.status-option {
cursor: pointer;
padding: 0.4rem 0.75rem;
opacity: 0.55;
}
/* `.status-badge.status-option` — matches `.status-badge.status-confirmed` (etc.)
on specificity so this actually wins and overrides their `border`, rather than
losing to them by cascade order. */
.status-badge.status-option {
border: none;
}
.status-option:hover {
opacity: 0.85;
}
.status-option.is-active {
opacity: 1;
/* `currentColor` picks up each status's own `color` (gold/red/green) instead
of one fixed ring color that wouldn't match every status. */
box-shadow: 0 0 0 2px currentColor;
}
.table-actions {
display: flex;
gap: 0.5rem;
@@ -1249,167 +1036,6 @@ a {
font-size: 0.8rem;
}
.action-menu-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
border-radius: 0.3rem;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.action-menu-trigger:hover,
.action-menu-trigger[aria-expanded='true'] {
background: var(--line);
color: var(--text);
}
/* `top`/`right` are set inline per instance (src/components/action-menu.tsx) —
the panel is portaled to <body> and positioned from a measured rect so it
isn't clipped by `.table-scroll`'s (forced) `overflow-y: auto`. `z-index` is
above `.confirm-dialog-backdrop` (100) since the menu can now be opened from
inside the reservation detail modal, not just from a table row. */
.action-menu-panel {
position: fixed;
z-index: 110;
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 11rem;
padding: 0.5rem;
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 0.4rem;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
}
.action-menu-panel .btn {
display: block;
width: 100%;
text-align: left;
font: inherit;
padding: 0.5rem 0.7rem;
font-size: 0.85rem;
}
.table-thumb {
width: 3rem;
height: 3rem;
border-radius: 0.3rem;
object-fit: cover;
display: block;
}
.table-thumb-empty {
background: var(--bg);
border: 1px dashed var(--line);
}
.table-image-field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.table-image-field input[type='file'] {
flex: 1 1 12rem;
min-width: 0;
padding: 0.4rem;
font-size: 0.85rem;
}
.table-image-preview {
width: 4.5rem;
height: 4.5rem;
border-radius: 0.3rem;
object-fit: cover;
flex-shrink: 0;
}
.table-preview {
width: 100%;
max-height: 14rem;
object-fit: cover;
border-radius: 0.4rem;
border: 1px solid var(--line);
}
/* A fixed aspect ratio (rather than sizing to the photo's own dimensions) so a
marker's percentage position lines up with the same spot on the image whether
it's rendered here, in the admin editor, or at a different screen width —
`object-fit: contain` (not `cover`) keeps the whole photo, and thus every
marker coordinate, on-screen instead of letting some get cropped away. */
.table-overview-wrap {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 0.4rem;
border: 1px solid var(--line);
background: var(--bg);
overflow: hidden;
cursor: pointer;
}
.table-overview-wrap.is-busy {
cursor: wait;
opacity: 0.7;
}
.table-overview-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
/* A classic map-pin: a square with three rounded corners (50% 50% 50% 0 leaves
the bottom-left one sharp), rotated -45deg around that exact sharp corner so
the corner itself — not the box center — lands on the `left`/`top` coordinate
and becomes the pin's tip, with the round "head" swinging up above it. */
.table-marker {
position: absolute;
width: 1.6rem;
height: 1.6rem;
transform: translate(0, -100%) rotate(-45deg);
transform-origin: 0 100%;
border: none;
border-radius: 50% 50% 50% 0;
padding: 0;
background: var(--bg-raised);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.45), inset 0 0 0 1.5px var(--gold);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.table-marker-label {
transform: rotate(45deg);
font: inherit;
font-size: 0.62rem;
font-weight: 700;
color: var(--text);
}
.table-marker.is-selected,
.table-marker.is-active {
background: var(--gold);
transform: translate(0, -100%) rotate(-45deg) scale(1.2);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
}
.table-marker.is-selected .table-marker-label,
.table-marker.is-active .table-marker-label {
color: #201709;
}
.btn-danger {
background: transparent;
border-color: #5c1f1f;
@@ -1463,50 +1089,9 @@ a {
margin: 0 0 0.75rem;
}
.confirm-dialog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.confirm-dialog-header .confirm-dialog-title {
margin: 0;
}
.confirm-dialog-message {
margin: 0 0 1.25rem;
color: var(--text-muted);
}
.confirm-dialog-message strong {
color: var(--text);
font-weight: 700;
}
.confirm-dialog-warning {
display: flex;
align-items: flex-start;
gap: 0.6rem;
margin: 0 0 1.5rem;
padding: 0.75rem 0.9rem;
border: 1px solid #5c1f1f;
border-radius: 0.4rem;
background: rgba(168, 64, 44, 0.14);
color: #f3a9a9;
}
.confirm-dialog-warning svg {
flex-shrink: 0;
margin-top: 0.1rem;
color: #f3a9a9;
}
.confirm-dialog-warning span {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.4;
color: var(--text-muted);
}
.confirm-dialog-actions {
@@ -1520,58 +1105,6 @@ a {
font-size: 0.9rem;
}
.reservation-detail-dialog {
max-width: 28rem;
}
.reservation-detail-dialog .table-preview {
margin-bottom: 1.25rem;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin: 0 0 1.25rem;
}
.detail-list > div {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--line);
}
.detail-list dt {
color: var(--text-muted);
font-size: 0.8rem;
}
.detail-list dd {
margin: 0;
text-align: right;
font-weight: 500;
}
.detail-message {
margin: 0 0 1.5rem;
padding-top: 0.6rem;
border-top: 1px solid var(--line);
}
.detail-message-label {
display: block;
margin-bottom: 0.35rem;
color: var(--text-muted);
font-size: 0.8rem;
}
.detail-message p {
margin: 0;
white-space: pre-wrap;
}
.empty-state {
color: var(--text-muted);
font-style: italic;