diff --git a/.env.example b/.env.example index 82574c6..1efbd5c 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,5 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-string BETTER_AUTH_URL=http://localhost:3000 STAFF_SEED_EMAIL=admin@steakhaus-meppen.de STAFF_SEED_PASSWORD=replace-with-a-strong-password +RESEND_API_KEY=replace-with-your-resend-api-key +RESERVATIONS_FROM_EMAIL=Steakhaus bei Mirko diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml new file mode 100644 index 0000000..5133801 --- /dev/null +++ b/.gitea/workflows/ci.yml @@ -0,0 +1,105 @@ +# Requires a Gitea runner with internet access to fetch `actions/checkout` and +# `actions/setup-node` from GitHub (or a Gitea instance with those actions mirrored +# internally) plus Docker available for the Postgres service container. +name: CI + +on: + push: + branches: ['**'] + pull_request: + branches: ['**'] + +jobs: + build-and-test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: steakhouse + POSTGRES_PASSWORD: steakhouse + POSTGRES_DB: steakhouse + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U steakhouse" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + + env: + DATABASE_URL: postgres://steakhouse:steakhouse@localhost:5432/steakhouse + BETTER_AUTH_SECRET: ci-only-secret-not-used-in-production + BETTER_AUTH_URL: http://localhost:4173 + STAFF_SEED_EMAIL: admin@example.com + STAFF_SEED_PASSWORD: ci-only-password-not-used-in-production + RESEND_API_KEY: re_ci_dummy_key + RESERVATIONS_FROM_EMAIL: 'Steakhaus bei Mirko ' + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + # Gitea auto-provisions this as a repo-scoped token for the runner; without + # it, actions/checkout falls back to an unauthenticated fetch, which fails + # here because the repo requires auth ("could not read Username ... terminal + # prompts disabled"). + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Type check + run: npx tsc --noEmit + + - name: Check that Drizzle migrations are up to date + run: | + npx drizzle-kit generate + if [ -n "$(git status --porcelain drizzle)" ]; then + echo "::error::src/db/schema.ts changed without a matching migration. Run 'npm run db:generate' and commit the result." + git status --porcelain drizzle + git diff drizzle + exit 1 + fi + + - name: Apply migrations + run: npm run db:migrate + + - name: Seed baseline data + run: npm run db:seed + + - name: Build + run: npm run build + + - name: Smoke test the production build + run: | + npx vite preview --port 4173 & + PREVIEW_PID=$! + + for i in $(seq 1 30); do + curl -sf http://localhost:4173/ >/dev/null 2>&1 && break + sleep 1 + done + + check() { + code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4173$1") + if [ "$code" != "$2" ]; then + echo "::error::GET $1 returned $code, expected $2" + kill "$PREVIEW_PID" + exit 1 + fi + echo "GET $1 -> $code" + } + + check / 200 + check /staff/login 200 + check /impressum 200 + + kill "$PREVIEW_PID" diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..7cd46bd --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,12 @@ + + + + + postgresql + true + org.postgresql.Driver + jdbc:postgresql://localhost:5432/postgres + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/jsonCatalog.xml b/.idea/jsonCatalog.xml new file mode 100644 index 0000000..0b181a9 --- /dev/null +++ b/.idea/jsonCatalog.xml @@ -0,0 +1,6 @@ + + + + false + + \ No newline at end of file diff --git a/drizzle/0000_milky_squadron_sinister.sql b/drizzle/0000_milky_squadron_sinister.sql new file mode 100644 index 0000000..cbcdd82 --- /dev/null +++ b/drizzle/0000_milky_squadron_sinister.sql @@ -0,0 +1,73 @@ +CREATE TABLE "account" ( + "id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "provider_id" text NOT NULL, + "user_id" text NOT NULL, + "access_token" text, + "refresh_token" text, + "id_token" text, + "access_token_expires_at" timestamp, + "refresh_token_expires_at" timestamp, + "scope" text, + "password" text, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE TABLE "reservations" ( + "id" serial PRIMARY KEY NOT NULL, + "table_id" integer NOT NULL, + "customer_name" text NOT NULL, + "customer_email" text NOT NULL, + "party_size" integer NOT NULL, + "reservation_time" timestamp with time zone NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "reservations_table_id_reservation_time_unique" UNIQUE("table_id","reservation_time") +); +--> statement-breakpoint +CREATE TABLE "restaurant_tables" ( + "id" serial PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "seats" integer NOT NULL +); +--> statement-breakpoint +CREATE TABLE "session" ( + "id" text PRIMARY KEY NOT NULL, + "expires_at" timestamp NOT NULL, + "token" text NOT NULL, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "ip_address" text, + "user_agent" text, + "user_id" text NOT NULL, + "impersonated_by" text, + CONSTRAINT "session_token_unique" UNIQUE("token") +); +--> statement-breakpoint +CREATE TABLE "user" ( + "id" text PRIMARY KEY NOT NULL, + "name" text NOT NULL, + "email" text NOT NULL, + "email_verified" boolean NOT NULL, + "image" text, + "created_at" timestamp NOT NULL, + "updated_at" timestamp NOT NULL, + "role" text, + "banned" boolean, + "ban_reason" text, + "ban_expires" timestamp, + CONSTRAINT "user_email_unique" UNIQUE("email") +); +--> statement-breakpoint +CREATE TABLE "verification" ( + "id" text PRIMARY KEY NOT NULL, + "identifier" text NOT NULL, + "value" text NOT NULL, + "expires_at" timestamp NOT NULL, + "created_at" timestamp, + "updated_at" timestamp +); +--> statement-breakpoint +ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "reservations" ADD CONSTRAINT "reservations_table_id_restaurant_tables_id_fk" FOREIGN KEY ("table_id") REFERENCES "public"."restaurant_tables"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action; \ No newline at end of file diff --git a/drizzle/0001_melted_supernaut.sql b/drizzle/0001_melted_supernaut.sql new file mode 100644 index 0000000..7771034 --- /dev/null +++ b/drizzle/0001_melted_supernaut.sql @@ -0,0 +1,2 @@ +ALTER TABLE "reservations" ADD COLUMN "cancel_token" text DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint +ALTER TABLE "reservations" ADD CONSTRAINT "reservations_cancel_token_unique" UNIQUE("cancel_token"); \ No newline at end of file diff --git a/drizzle/0002_deep_dorian_gray.sql b/drizzle/0002_deep_dorian_gray.sql new file mode 100644 index 0000000..d680ecf --- /dev/null +++ b/drizzle/0002_deep_dorian_gray.sql @@ -0,0 +1,8 @@ +CREATE TABLE "restaurant_settings" ( + "id" serial PRIMARY KEY NOT NULL, + "reservation_interval_minutes" integer DEFAULT 15 NOT NULL, + "reservation_duration_minutes" integer DEFAULT 90 NOT NULL, + "min_lead_time_minutes" integer DEFAULT 60 NOT NULL +); +--> statement-breakpoint +ALTER TABLE "reservations" ADD COLUMN "duration_minutes" integer DEFAULT 90 NOT NULL; \ No newline at end of file diff --git a/drizzle/0003_big_jack_flag.sql b/drizzle/0003_big_jack_flag.sql new file mode 100644 index 0000000..a51ec26 --- /dev/null +++ b/drizzle/0003_big_jack_flag.sql @@ -0,0 +1 @@ +ALTER TABLE "restaurant_settings" ADD COLUMN "max_lead_time_days" integer DEFAULT 60 NOT NULL; \ No newline at end of file diff --git a/drizzle/meta/0000_snapshot.json b/drizzle/meta/0000_snapshot.json new file mode 100644 index 0000000..5d4d16f --- /dev/null +++ b/drizzle/meta/0000_snapshot.json @@ -0,0 +1,459 @@ +{ + "id": "fc9539d6-3015-4ae3-bbf2-7a1a9f4de482", + "prevId": "00000000-0000-0000-0000-000000000000", + "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 + }, + "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_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_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 + } + }, + "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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0001_snapshot.json b/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..155d02e --- /dev/null +++ b/drizzle/meta/0001_snapshot.json @@ -0,0 +1,473 @@ +{ + "id": "65d581ab-b3c7-4ffb-8a79-afcf26be5af5", + "prevId": "fc9539d6-3015-4ae3-bbf2-7a1a9f4de482", + "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 + }, + "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_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 + } + }, + "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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0002_snapshot.json b/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..98624a7 --- /dev/null +++ b/drizzle/meta/0002_snapshot.json @@ -0,0 +1,520 @@ +{ + "id": "5e900bf2-030d-4eb3-a8ab-3b4c7d85e203", + "prevId": "65d581ab-b3c7-4ffb-8a79-afcf26be5af5", + "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 + } + }, + "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 + } + }, + "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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/0003_snapshot.json b/drizzle/meta/0003_snapshot.json new file mode 100644 index 0000000..7cd9cca --- /dev/null +++ b/drizzle/meta/0003_snapshot.json @@ -0,0 +1,527 @@ +{ + "id": "d03b9103-cb6a-4968-a016-c04270ec176a", + "prevId": "5e900bf2-030d-4eb3-a8ab-3b4c7d85e203", + "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 + } + }, + "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": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json new file mode 100644 index 0000000..3d9aafd --- /dev/null +++ b/drizzle/meta/_journal.json @@ -0,0 +1,34 @@ +{ + "version": "7", + "dialect": "postgresql", + "entries": [ + { + "idx": 0, + "version": "7", + "when": 1787335013744, + "tag": "0000_milky_squadron_sinister", + "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1787338587505, + "tag": "0001_melted_supernaut", + "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1787604850455, + "tag": "0002_deep_dorian_gray", + "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1787646264161, + "tag": "0003_big_jack_flag", + "breakpoints": true + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 081cbc5..57f8066 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "postgres": "^3.4.9", "react": "^19.2.0", "react-dom": "^19.2.0", + "resend": "^6.21.0", "zod": "^4.4.3" }, "devDependencies": { @@ -453,7 +454,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -470,7 +470,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -487,7 +486,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -504,7 +502,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -521,7 +518,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -538,7 +534,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -555,7 +550,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -572,7 +566,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -589,7 +582,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -606,7 +598,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -623,7 +614,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -640,7 +630,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -657,7 +646,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -674,7 +662,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -691,7 +678,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -708,7 +694,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -725,7 +710,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -742,7 +726,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -759,7 +742,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -776,7 +758,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -793,7 +774,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -810,7 +790,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -877,7 +856,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -894,7 +872,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -911,7 +888,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -928,7 +904,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -945,7 +920,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -962,7 +936,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -979,7 +952,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -996,7 +968,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1013,7 +984,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1030,7 +1000,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1047,7 +1016,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1064,7 +1032,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1081,7 +1048,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1098,7 +1064,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1115,7 +1080,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1132,7 +1096,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1149,7 +1112,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1166,7 +1128,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1183,7 +1144,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1200,7 +1160,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1217,7 +1176,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1234,7 +1192,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1251,7 +1208,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1268,7 +1224,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1285,7 +1240,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1302,7 +1256,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1455,7 +1408,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1472,7 +1424,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1489,7 +1440,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1506,7 +1456,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1523,7 +1472,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1540,7 +1488,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1557,7 +1504,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1574,7 +1520,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1591,7 +1536,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1608,7 +1552,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1625,7 +1568,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1642,7 +1584,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1659,7 +1600,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1676,7 +1616,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -1693,6 +1632,12 @@ "devOptional": true, "license": "MIT" }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -2158,7 +2103,7 @@ "version": "22.20.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -2621,7 +2566,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2638,7 +2582,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2655,7 +2598,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2672,7 +2614,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2689,7 +2630,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2706,7 +2646,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2723,7 +2662,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2740,7 +2678,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2757,7 +2694,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2774,7 +2710,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2791,7 +2726,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2808,7 +2742,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2825,7 +2758,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2842,7 +2774,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2859,7 +2790,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2876,7 +2806,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2893,7 +2822,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2910,7 +2838,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2927,7 +2854,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2944,7 +2870,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2961,7 +2886,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2978,7 +2902,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -2995,7 +2918,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3012,7 +2934,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3029,7 +2950,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3046,7 +2966,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -3293,6 +3212,12 @@ "integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==", "license": "MIT" }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -3320,7 +3245,6 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "optional": true, @@ -3826,6 +3750,12 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/postal-mime": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz", + "integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==", + "license": "MIT-0" + }, "node_modules/postcss": { "version": "8.5.26", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", @@ -3927,6 +3857,27 @@ "node": ">=0.10.0" } }, + "node_modules/resend": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/resend/-/resend-6.21.0.tgz", + "integrity": "sha512-XT1eP2iA8rZ51NSO2CJlL1ZE2baBoPpOGMgz2p0o2uluz/YU3XfItpGymXDlL0Sp2Mc+y9+Xxsfdccfi4f2FzQ==", + "license": "MIT", + "dependencies": { + "postal-mime": "2.7.5", + "standardwebhooks": "1.0.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@react-email/render": "*" + }, + "peerDependenciesMeta": { + "@react-email/render": { + "optional": true + } + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -4070,6 +4021,16 @@ "node": ">=20.16.0" } }, + "node_modules/standardwebhooks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", + "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4157,7 +4118,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/unplugin": { diff --git a/package.json b/package.json index 77e8eea..6ab506b 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "#/*": "./src/*" }, "scripts": { - "dev": "vite dev --port 3000", + "dev": "vite dev --port 3000 --host", "generate-routes": "tsr generate", "build": "vite build", "preview": "vite preview", @@ -24,6 +24,7 @@ "postgres": "^3.4.9", "react": "^19.2.0", "react-dom": "^19.2.0", + "resend": "^6.21.0", "zod": "^4.4.3" }, "devDependencies": { diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx new file mode 100644 index 0000000..0c2c285 --- /dev/null +++ b/src/components/app-sidebar.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from 'react' +import { Link, useRouter } from '@tanstack/react-router' + +import { authClient } from '#/lib/auth-client' + +type StaffUser = { name: string; role?: string | null } +type SectionLink = { label: string; hash: string } + +type Props = { + staffUser?: StaffUser | null + sectionLinks?: SectionLink[] +} + +function MenuIcon() { + return ( + + ) +} + +export function AppSidebar({ staffUser, sectionLinks }: Props) { + const router = useRouter() + const [isOpen, setIsOpen] = useState(false) + const navRef = useRef(null) + + useEffect(() => { + if (!isOpen) return + + function handleOutsideClick(event: MouseEvent) { + if (navRef.current && !navRef.current.contains(event.target as Node)) { + setIsOpen(false) + } + } + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') setIsOpen(false) + } + + document.addEventListener('mousedown', handleOutsideClick) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleOutsideClick) + document.removeEventListener('keydown', handleKeyDown) + } + }, [isOpen]) + + async function handleLogout() { + await authClient.signOut() + await router.navigate({ to: '/staff/login' }) + await router.invalidate() + } + + return ( + + ) +} diff --git a/src/components/confirm-dialog.tsx b/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..ca7b82f --- /dev/null +++ b/src/components/confirm-dialog.tsx @@ -0,0 +1,73 @@ +import { useEffect, useRef } from 'react' + +type Props = { + open: boolean + title: string + message: string + confirmLabel?: string + cancelLabel?: string + danger?: boolean + onConfirm: () => void + onCancel: () => void +} + +export function ConfirmDialog({ + open, + title, + message, + confirmLabel = 'Bestätigen', + cancelLabel = 'Abbrechen', + danger, + onConfirm, + onCancel, +}: Props) { + const confirmRef = useRef(null) + + useEffect(() => { + if (!open) return + + confirmRef.current?.focus() + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') onCancel() + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [open, onCancel]) + + if (!open) return null + + return ( +
+
event.stopPropagation()} + > +

+ {title} +

+

+ {message} +

+
+ + +
+
+
+ ) +} diff --git a/src/components/reservation-charts.tsx b/src/components/reservation-charts.tsx new file mode 100644 index 0000000..98d6e1c --- /dev/null +++ b/src/components/reservation-charts.tsx @@ -0,0 +1,321 @@ +import { useState } from 'react' + +import { getOpeningRangesForWeekday, type OpeningRange } from '#/lib/opening-hours' + +type ReservationLike = { + tableId: number + reservationTime: Date +} + +type TableLike = { + id: number + name: string +} + +type Props = { + reservations: ReservationLike[] + tables: TableLike[] +} + +type BarDatum = { + label: string + value: number + tooltip: string +} + +type Granularity = 'today' | 'week' | 'month' +type DateBucketGranularity = 'week' | 'month' + +const GRANULARITY_LABELS: Record = { + today: 'Heute', + week: 'Woche', + month: 'Monat', +} + +// How many buckets — and how far out — each granularity covers: enough weeks/months +// to spot a trend without the axis becoming unreadable. +const BUCKET_COUNTS: Record = { + week: 8, + month: 6, +} + +const WEEK_LABEL_FORMATTER = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' }) +const MONTH_AXIS_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'short' }) +const MONTH_TOOLTIP_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }) + +function reservationsLabel(count: number) { + return count === 1 ? 'Reservierung' : 'Reservierungen' +} + +function addBucketUnit(date: Date, granularity: DateBucketGranularity, amount: number) { + const d = new Date(date) + if (granularity === 'week') d.setDate(d.getDate() + amount * 7) + else d.setMonth(d.getMonth() + amount) + return d +} + +// The start of the first bucket: the Monday of this week, or the 1st of this month — +// so the current, still-in-progress bucket covers the whole period, not just what's +// left of it (the caller is expected to pass in ALL reservations, not just upcoming +// ones, so this week's/month's earlier days are still counted). +function bucketRangeStart(granularity: DateBucketGranularity) { + const now = new Date() + now.setHours(0, 0, 0, 0) + + if (granularity === 'month') { + now.setDate(1) + return now + } + const mondayOffset = (now.getDay() + 6) % 7 + now.setDate(now.getDate() - mondayOffset) + return now +} + +function formatBucketLabel(start: Date, granularity: DateBucketGranularity) { + if (granularity === 'month') return MONTH_AXIS_FORMATTER.format(start) + return WEEK_LABEL_FORMATTER.format(start) +} + +function formatBucketTooltip(start: Date, end: Date, granularity: DateBucketGranularity, count: number) { + const countLabel = `${count} ${reservationsLabel(count)}` + + if (granularity === 'month') return `${MONTH_TOOLTIP_FORMATTER.format(start)}: ${countLabel}` + + const lastDayOfWeek = new Date(end.getTime() - 24 * 60 * 60 * 1000) + return `${WEEK_LABEL_FORMATTER.format(start)}–${WEEK_LABEL_FORMATTER.format(lastDayOfWeek)}: ${countLabel}` +} + +function buildBucketedCounts(reservations: ReservationLike[], granularity: DateBucketGranularity): BarDatum[] { + const rangeStart = bucketRangeStart(granularity) + const bucketCount = BUCKET_COUNTS[granularity] + + return Array.from({ length: bucketCount }, (_, i) => { + const start = addBucketUnit(rangeStart, granularity, i) + const end = addBucketUnit(rangeStart, granularity, i + 1) + const count = reservations.filter((r) => { + const time = new Date(r.reservationTime).getTime() + return time >= start.getTime() && time < end.getTime() + }).length + + return { + label: formatBucketLabel(start, granularity), + value: count, + tooltip: formatBucketTooltip(start, end, granularity, count), + } + }) +} + +// The restaurant can have more than one service window in a day (e.g. lunch + +// dinner) — span the whole gap between them rather than faceting the chart, so a +// quiet hour between services reads as "no bookings then", not a missing bar. +function hourSpanForRanges(ranges: OpeningRange[]) { + const startHour = Math.min(...ranges.map((r) => Number(r.start.split(':')[0]))) + const endHour = Math.max( + ...ranges.map((r) => { + const [hour, minute] = r.end.split(':').map(Number) + return minute > 0 ? hour + 1 : hour + }), + ) + return { startHour, endHour } +} + +function buildHourlyCountsForToday(reservations: ReservationLike[], ranges: OpeningRange[]): BarDatum[] { + const { startHour, endHour } = hourSpanForRanges(ranges) + const dayStart = new Date() + dayStart.setHours(0, 0, 0, 0) + + return Array.from({ length: endHour - startHour }, (_, i) => { + const hour = startHour + i + const start = new Date(dayStart) + start.setHours(hour) + const end = new Date(dayStart) + end.setHours(hour + 1) + + const count = reservations.filter((r) => { + const time = new Date(r.reservationTime).getTime() + return time >= start.getTime() && time < end.getTime() + }).length + + return { + label: `${String(hour).padStart(2, '0')}:00`, + value: count, + tooltip: `${String(hour).padStart(2, '0')}:00–${String(hour + 1).padStart(2, '0')}:00: ${count} ${reservationsLabel(count)}`, + } + }) +} + +function buildTimelineData( + reservations: ReservationLike[], + granularity: Granularity, +): { data: BarDatum[]; emptyMessage: string } { + if (granularity === 'today') { + const ranges = getOpeningRangesForWeekday(new Date().getDay()) + if (ranges.length === 0) { + return { data: [], emptyMessage: 'Heute ist Ruhetag.' } + } + return { + data: buildHourlyCountsForToday(reservations, ranges), + emptyMessage: 'Heute sind noch keine Reservierungen eingetragen.', + } + } + + return { + data: buildBucketedCounts(reservations, granularity), + emptyMessage: 'In diesem Zeitraum sind noch keine Reservierungen eingetragen.', + } +} + +// The full date range the timeline chart currently covers, so the per-table chart +// can be filtered to the same window and the two charts always tell one consistent +// story instead of the table breakdown silently covering a different span. +function getTimelineRange(granularity: Granularity): { start: Date; end: Date } { + if (granularity === 'today') { + const start = new Date() + start.setHours(0, 0, 0, 0) + const end = new Date(start) + end.setDate(end.getDate() + 1) + return { start, end } + } + + const start = bucketRangeStart(granularity) + const end = addBucketUnit(start, granularity, BUCKET_COUNTS[granularity]) + return { start, end } +} + +function buildTableCounts(reservations: ReservationLike[], tables: TableLike[]): BarDatum[] { + return tables.map((table) => { + const count = reservations.filter((r) => r.tableId === table.id).length + return { + label: table.name, + value: count, + tooltip: `${table.name}: ${count} ${reservationsLabel(count)}`, + } + }) +} + +// Produces clean, evenly-stepped tick values (0, 2, 4, 6, …) so the gridlines +// never land on an awkward number like 7 or 13. +function niceTicks(maxValue: number, tickCount = 4): number[] { + if (maxValue <= 0) return [0, 1] + + const rawStep = maxValue / (tickCount - 1) + const magnitude = 10 ** Math.floor(Math.log10(rawStep)) + const residual = rawStep / magnitude + const niceResidual = residual <= 1 ? 1 : residual <= 2 ? 2 : residual <= 5 ? 5 : 10 + // Counts are always whole numbers, so a sub-1 step (e.g. 0.5) would just + // round two distinct ticks down to the same displayed integer. + const step = Math.max(1, niceResidual * magnitude) + const niceMax = Math.ceil(maxValue / step) * step + + const ticks: number[] = [] + for (let v = 0; v <= niceMax + 1e-9; v += step) ticks.push(Math.round(v)) + return ticks +} + +function BarChartBody({ data, showValueLabels }: { data: BarDatum[]; showValueLabels?: boolean }) { + const ticks = niceTicks(Math.max(...data.map((d) => d.value), 0)) + const chartMax = ticks[ticks.length - 1] || 1 + + return ( +
+
+ {ticks.map((tick) => ( +
+ {tick} +
+ ))} +
+ {data.map((d, i) => ( +
+
+ {showValueLabels && d.value > 0 && {d.value}} +
+
{d.tooltip}
+
+ ))} +
+
+
+ {data.map((d, i) => ( + + {d.label} + + ))} +
+
+ ) +} + +function ChartCard({ + title, + headerExtra, + data, + showValueLabels, + emptyMessage, +}: { + title: string + headerExtra?: React.ReactNode + data: BarDatum[] + showValueLabels?: boolean + emptyMessage: string +}) { + const hasData = data.some((d) => d.value > 0) + + return ( +
+
+

{title}

+ {headerExtra} +
+ {hasData ? :

{emptyMessage}

} +
+ ) +} + +function GranularityToggle({ value, onChange }: { value: Granularity; onChange: (g: Granularity) => void }) { + return ( +
+ {(Object.keys(GRANULARITY_LABELS) as Granularity[]).map((g) => ( + + ))} +
+ ) +} + +export function ReservationCharts({ reservations, tables }: Props) { + const [granularity, setGranularity] = useState('today') + + const timeline = buildTimelineData(reservations, granularity) + + const { start, end } = getTimelineRange(granularity) + const reservationsInRange = reservations.filter((r) => { + const time = new Date(r.reservationTime).getTime() + return time >= start.getTime() && time < end.getTime() + }) + const tableCounts = buildTableCounts(reservationsInRange, tables) + + return ( +
+ } + data={timeline.data} + emptyMessage={timeline.emptyMessage} + /> + +
+ ) +} diff --git a/src/components/reservation-datetime-field.tsx b/src/components/reservation-datetime-field.tsx new file mode 100644 index 0000000..3e3da5e --- /dev/null +++ b/src/components/reservation-datetime-field.tsx @@ -0,0 +1,315 @@ +import { useEffect, useRef, useState } from 'react' + +import { formatDateOnly } from '#/lib/datetime' +import { generateTimeOptions, getOpeningRangesForWeekday, RESERVATION_INTERVAL_MINUTES } from '#/lib/opening-hours' + +type Props = { + value: string + onChange: (value: string) => void + // "HH:MM" times already taken (e.g. by another reservation on the same table) + // for the currently selected date — shown disabled rather than removed, so the + // slot doesn't look like it doesn't exist, just that it isn't free. + bookedTimes?: string[] + // How far apart selectable times are, and how far in advance a booking must be + // made — both configurable restaurant settings, falling back to sane defaults. + intervalMinutes?: number + minLeadTimeMinutes?: number + // How far into the future a booking may be made. Undefined leaves the horizon open, + // since not every caller of this field needs the restriction. + maxLeadTimeDays?: number +} + +type DayCell = { day: number; dateStr: string } | null + +function splitValue(value: string) { + const [date = '', time = ''] = value.split('T') + return { date, time } +} + +function getWeekday(dateStr: string) { + return new Date(`${dateStr}T00:00`).getDay() +} + +function pad2(n: number) { + return String(n).padStart(2, '0') +} + +function isoToDisplay(iso: string) { + if (!iso) return '' + const [year, month, day] = iso.split('-') + return `${day}.${month}.${year}` +} + +// Progressively inserts dots as digits are typed, e.g. "22081" -> "22.08.1". +function maskDateInput(raw: string) { + const digits = raw.replace(/\D/g, '').slice(0, 8) + let out = digits.slice(0, 2) + if (digits.length > 2) out += '.' + digits.slice(2, 4) + if (digits.length > 4) out += '.' + digits.slice(4, 8) + return out +} + +// Returns an ISO "YYYY-MM-DD" string, or null if the text isn't a complete, real calendar date. +function parseDisplayDate(raw: string) { + const digits = raw.replace(/\D/g, '') + if (digits.length !== 8) return null + + const day = Number(digits.slice(0, 2)) + const month = Number(digits.slice(2, 4)) + const year = Number(digits.slice(4, 8)) + const parsed = new Date(year, month - 1, day) + + if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) { + return null + } + + return `${digits.slice(4, 8)}-${digits.slice(2, 4)}-${digits.slice(0, 2)}` +} + +// Monday-first weeks covering the given month, with null filler cells for days +// that belong to the adjacent month. +function getMonthGrid(year: number, month: number): DayCell[][] { + const firstOfMonth = new Date(year, month, 1) + const leadingBlanks = (firstOfMonth.getDay() + 6) % 7 + const daysInMonth = new Date(year, month + 1, 0).getDate() + + const cells: DayCell[] = Array(leadingBlanks).fill(null) + for (let day = 1; day <= daysInMonth; day++) { + cells.push({ day, dateStr: `${year}-${pad2(month + 1)}-${pad2(day)}` }) + } + while (cells.length % 7 !== 0) cells.push(null) + + const weeks: DayCell[][] = [] + for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7)) + return weeks +} + +const WEEKDAY_HEADER_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] + +function CalendarIcon() { + return ( + + ) +} + +export function ReservationDateTimeField({ + value, + onChange, + bookedTimes = [], + intervalMinutes = RESERVATION_INTERVAL_MINUTES, + minLeadTimeMinutes = 0, + maxLeadTimeDays, +}: Props) { + const { date, time } = splitValue(value) + const [dateText, setDateText] = useState(() => isoToDisplay(date)) + const [isCalendarOpen, setIsCalendarOpen] = useState(false) + const [view, setView] = useState(() => { + const [year, month] = (date || formatDateOnly(new Date())).split('-') + return { year: Number(year), month: Number(month) - 1 } + }) + const containerRef = useRef(null) + + useEffect(() => { + setDateText(isoToDisplay(date)) + }, [date]) + + useEffect(() => { + if (!isCalendarOpen) return + + const [year, month] = (date || formatDateOnly(new Date())).split('-') + setView({ year: Number(year), month: Number(month) - 1 }) + + function handleOutsideClick(event: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(event.target as Node)) { + setIsCalendarOpen(false) + } + } + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') setIsCalendarOpen(false) + } + + document.addEventListener('mousedown', handleOutsideClick) + document.addEventListener('keydown', handleKeyDown) + return () => { + document.removeEventListener('mousedown', handleOutsideClick) + document.removeEventListener('keydown', handleKeyDown) + } + // Only re-run when the popover opens/closes — reopening should reset to the + // currently selected month, not follow every keystroke in the text field. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isCalendarOpen]) + + const today = formatDateOnly(new Date()) + const isPast = date !== '' && date < today + const earliestAllowedTime = new Date(Date.now() + minLeadTimeMinutes * 60_000) + const latestDate = + maxLeadTimeDays === undefined ? null : formatDateOnly(new Date(Date.now() + maxLeadTimeDays * 86_400_000)) + const isBeyondHorizon = date !== '' && latestDate !== null && date > latestDate + + // Past dates and dates beyond the booking horizon never have slots — shared by both + // the render below and commitDate, so a date committed from anywhere (typed, or + // otherwise) can't slip through with a leftover, still-"valid"-looking time. + function optionsForDate(dateStr: string) { + if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return [] + return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes) + } + + function isSelectableDate(dateStr: string) { + return optionsForDate(dateStr).length > 0 + } + + const allOptions = optionsForDate(date) + // Compares full timestamps (not just "is it today") so a lead time longer than + // what's left of today correctly spills over into tomorrow's early slots too. + const timeOptions = allOptions.filter( + (option) => new Date(`${date}T${option}`).getTime() >= earliestAllowedTime.getTime(), + ) + + const bookedTimesSet = new Set(bookedTimes) + const availableTimeOptions = timeOptions.filter((option) => !bookedTimesSet.has(option)) + + const isClosed = date !== '' && !isPast && !isBeyondHorizon && allOptions.length === 0 + const noSlotsLeft = date !== '' && !isPast && allOptions.length > 0 && availableTimeOptions.length === 0 + const dateIncomplete = parseDisplayDate(dateText) === null + + function commitDate(newDate: string) { + const newOptions = optionsForDate(newDate) + const stillValid = newOptions.includes(time) + onChange(`${newDate}T${stillValid ? time : newOptions[0] ?? ''}`) + } + + function handleTextChange(raw: string) { + const masked = maskDateInput(raw) + setDateText(masked) + + const parsed = parseDisplayDate(masked) + if (parsed) { + commitDate(parsed) + } + } + + function handleDayPick(dateStr: string) { + commitDate(dateStr) + setIsCalendarOpen(false) + } + + function handleTimeChange(newTime: string) { + onChange(`${date}T${newTime}`) + } + + let timePlaceholder = 'Uhrzeit wählen' + if (dateIncomplete) timePlaceholder = 'Datum eingeben' + else if (isPast) timePlaceholder = 'Datum liegt in der Vergangenheit' + else if (isBeyondHorizon) timePlaceholder = 'Datum liegt zu weit in der Zukunft' + else if (isClosed) timePlaceholder = 'An diesem Tag geschlossen' + else if (noSlotsLeft) timePlaceholder = 'Keine Zeiten mehr verfügbar' + + const monthLabel = new Date(view.year, view.month, 1).toLocaleDateString('de-DE', { + month: 'long', + year: 'numeric', + }) + const [todayYear, todayMonthStr] = today.split('-') + const isAtEarliestMonth = view.year === Number(todayYear) && view.month === Number(todayMonthStr) - 1 + const isAtLatestMonth = (() => { + if (latestDate === null) return false + const [latestYear, latestMonthStr] = latestDate.split('-') + return view.year === Number(latestYear) && view.month === Number(latestMonthStr) - 1 + })() + + function goToMonth(offset: number) { + setView((v) => { + const total = v.year * 12 + v.month + offset + return { year: Math.floor(total / 12), month: ((total % 12) + 12) % 12 } + }) + } + + return ( +
+
+ handleTextChange(e.target.value)} + required + /> + + + {isCalendarOpen && ( +
+
+ + {monthLabel} + +
+
+ {WEEKDAY_HEADER_LABELS.map((label) => ( + {label} + ))} +
+
+ {getMonthGrid(view.year, view.month) + .flat() + .map((cell, index) => ( + + ))} +
+
+ )} +
+ + +
+ ) +} diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index a149257..341ef8d 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -3,28 +3,14 @@ import { Link } from '@tanstack/react-router' export function SiteHeader() { return (
- - Steakhaus bei Mirko - - +
+ + Steakhaus bei Mirko + + + Mitarbeiter-Login + +
) } diff --git a/src/components/staff-tabs.tsx b/src/components/staff-tabs.tsx new file mode 100644 index 0000000..c04f599 --- /dev/null +++ b/src/components/staff-tabs.tsx @@ -0,0 +1,41 @@ +import { Link } from '@tanstack/react-router' + +type Props = { + role?: string | null +} + +export function StaffTabs({ role }: Props) { + return ( + + ) +} diff --git a/src/db/schema.ts b/src/db/schema.ts index 1253e74..1e81be9 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -1,3 +1,4 @@ +import { sql } from 'drizzle-orm' import { boolean, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core' export const restaurantTables = pgTable('restaurant_tables', { @@ -17,11 +18,28 @@ export const reservations = pgTable( customerEmail: text('customer_email').notNull(), partySize: integer('party_size').notNull(), 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 + // are considered to occupy their table. + durationMinutes: integer('duration_minutes').notNull().default(90), + cancelToken: text('cancel_token') + .notNull() + .unique() + .default(sql`gen_random_uuid()`), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [unique().on(table.tableId, table.reservationTime)], ) +// Single-row table of restaurant-wide booking rules, editable by admins. +export const restaurantSettings = pgTable('restaurant_settings', { + id: serial('id').primaryKey(), + reservationIntervalMinutes: integer('reservation_interval_minutes').notNull().default(15), + 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), +}) + // Better Auth's standard schema (staff accounts) — see https://www.better-auth.com/docs/concepts/database // `role`/`banned`/`banReason`/`banExpires` and `session.impersonatedBy` are added by the `admin` plugin. export const user = pgTable('user', { @@ -82,3 +100,4 @@ export const verification = pgTable('verification', { export type RestaurantTable = typeof restaurantTables.$inferSelect export type Reservation = typeof reservations.$inferSelect export type NewReservation = typeof reservations.$inferInsert +export type RestaurantSettings = typeof restaurantSettings.$inferSelect diff --git a/src/lib/auth.ts b/src/lib/auth.ts index cb3e248..718bfe3 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,10 +1,15 @@ -import { createServerFn } from '@tanstack/react-start' +import { createServerFn, createServerOnlyFn } from '@tanstack/react-start' import { getRequest } from '@tanstack/react-start/server' -export async function getStaffSession() { +// `staff.index.tsx` and friends import `getCurrentStaffUser` from this module for +// their `beforeLoad` checks, which pulls the whole file into the client bundle too. +// `createServerOnlyFn` tells the compiler this closure (and the server-only +// `getRequest` import it uses) can be stripped from that client bundle instead of +// dragging the Postgres driver into the browser. +export const getStaffSession = createServerOnlyFn(async function getStaffSession() { const { getAuth } = await import('#/lib/auth-instance') return getAuth().api.getSession({ headers: getRequest().headers }) -} +}) export async function requireStaffSession() { const session = await getStaffSession() diff --git a/src/lib/datetime.ts b/src/lib/datetime.ts index ed649c5..855e864 100644 --- a/src/lib/datetime.ts +++ b/src/lib/datetime.ts @@ -1,4 +1,9 @@ +export function formatDateOnly(date: Date) { + const pad = (n: number) => String(n).padStart(2, '0') + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +} + export function formatDateTimeLocal(date: Date) { const pad = (n: number) => String(n).padStart(2, '0') - return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` + return `${formatDateOnly(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}` } diff --git a/src/lib/email.ts b/src/lib/email.ts new file mode 100644 index 0000000..1473955 --- /dev/null +++ b/src/lib/email.ts @@ -0,0 +1,260 @@ +import { Resend } from 'resend' + +import type { RestaurantTable } from '#/db/schema' + +const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', { + dateStyle: 'full', + timeStyle: 'short', +}) + +const COLORS = { + bg: '#17120f', + bgRaised: '#1e1712', + line: '#3a2f26', + text: '#f3ead9', + textMuted: '#c9baa3', + gold: '#c9a15a', + goldBright: '#e0bb78', +} + +function escapeHtml(value: string) { + return value + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", ''') +} + +let resendClient: Resend | undefined + +function getResendClient() { + const apiKey = process.env.RESEND_API_KEY + + if (!apiKey) { + return null + } + + resendClient ??= new Resend(apiKey) + return resendClient +} + +type ReservationEmailInfo = { + customerName: string + customerEmail: string + partySize: number + reservationTime: Date + cancelToken: string + table: Pick +} + +const CONTACT_EMAIL = 'info@steakhaus-meppen.de' + +function getSiteUrl() { + return process.env.BETTER_AUTH_URL ?? 'http://localhost:3000' +} + +function buildCancelUrl(cancelToken: string) { + return `${getSiteUrl()}/stornieren/${cancelToken}` +} + +function renderReservationEmailHtml( + heading: string, + intro: string, + reservation: ReservationEmailInfo, + showActions: boolean, +) { + const formattedTime = dateTimeFormatter.format(reservation.reservationTime) + const name = escapeHtml(reservation.customerName) + const tableName = escapeHtml(reservation.table.name) + const cancelUrl = buildCancelUrl(reservation.cancelToken) + + const row = (label: string, value: string) => ` + + + ${label} + + + ${value} + + ` + + return ` + + + + + + ${heading} + + + + + + +
+ + + + + + + + + + + + + +
+ + Steakhaus bei Mirko + +
+ + + + + + + + + + + + + + + + + ${ + showActions + ? ` + + + + + + ` + : '' + } +
+ +
+ ${heading} +
+ Hallo ${name}, +
+ ${intro} +
+ + ${row('Tisch', tableName)} + ${row('Datum & Uhrzeit', formattedTime)} + ${row('Personen', String(reservation.partySize))} +
+
+ + Reservierung stornieren + +
+ Möchten Sie etwas ändern? Schreiben Sie uns einfach eine E-Mail an + ${CONTACT_EMAIL}. +
+
+ Wir freuen uns auf Sie! +
+ Meppener Straße 8 · 49716 Meppen · 05931 - 12750 +
+
+ +` +} + +function renderReservationEmailText( + heading: string, + intro: string, + reservation: ReservationEmailInfo, + showActions: boolean, +) { + const formattedTime = dateTimeFormatter.format(reservation.reservationTime) + + return [ + 'Steakhaus bei Mirko', + '', + heading, + '', + `Hallo ${reservation.customerName},`, + '', + intro, + '', + `Tisch: ${reservation.table.name}`, + `Datum & Uhrzeit: ${formattedTime}`, + `Personen: ${reservation.partySize}`, + '', + ...(showActions + ? [ + `Reservierung stornieren: ${buildCancelUrl(reservation.cancelToken)}`, + `Möchten Sie etwas ändern? Schreiben Sie uns eine E-Mail an ${CONTACT_EMAIL}.`, + '', + ] + : []), + 'Wir freuen uns auf Sie!', + 'Steakhaus bei Mirko', + 'Meppener Straße 8 · 49716 Meppen · 05931 - 12750', + ].join('\n') +} + +async function sendReservationEmail( + subject: string, + intro: string, + reservation: ReservationEmailInfo, + showActions: boolean, +) { + const client = getResendClient() + + if (!client) { + console.warn('RESEND_API_KEY is not set — skipping reservation email') + return + } + + const fromAddress = process.env.RESERVATIONS_FROM_EMAIL ?? 'Steakhaus bei Mirko ' + + try { + await client.emails.send({ + from: fromAddress, + to: reservation.customerEmail, + subject, + html: renderReservationEmailHtml(subject, intro, reservation, showActions), + text: renderReservationEmailText(subject, intro, reservation, showActions), + }) + } catch (err) { + console.error('Failed to send reservation email', err) + } +} + +export function sendReservationConfirmationEmail(reservation: ReservationEmailInfo) { + return sendReservationEmail( + 'Ihre Reservierung ist bestätigt', + 'vielen Dank für Ihre Reservierung. Hier sind die Details:', + reservation, + true, + ) +} + +export function sendReservationUpdateEmail(reservation: ReservationEmailInfo) { + return sendReservationEmail( + 'Ihre Reservierung wurde geändert', + 'Ihre Reservierung wurde aktualisiert. Hier sind die neuen Details:', + reservation, + true, + ) +} + +export function sendReservationCancellationEmail(reservation: ReservationEmailInfo) { + return sendReservationEmail( + 'Ihre Reservierung wurde storniert', + 'Ihre folgende Reservierung wurde storniert:', + reservation, + false, + ) +} diff --git a/src/lib/opening-hours.ts b/src/lib/opening-hours.ts new file mode 100644 index 0000000..4f0d0c0 --- /dev/null +++ b/src/lib/opening-hours.ts @@ -0,0 +1,132 @@ +import { formatDateOnly } from '#/lib/datetime' + +export const RESTAURANT_TIME_ZONE = 'Europe/Berlin' +export const RESERVATION_INTERVAL_MINUTES = 15 + +export type OpeningRange = { start: string; end: string } + +// Keyed by the JS `Date#getDay()` convention: 0 = Sunday … 6 = Saturday. +const OPENING_HOURS_BY_WEEKDAY: Record = { + 0: [ + { start: '11:30', end: '13:30' }, + { start: '17:00', end: '22:00' }, + ], + 1: [], + 2: [{ start: '17:00', end: '21:30' }], + 3: [{ start: '17:00', end: '21:30' }], + 4: [{ start: '17:00', end: '21:30' }], + 5: [{ start: '17:00', end: '22:00' }], + 6: [{ start: '17:00', end: '22:00' }], +} + +const WEEKDAY_LABELS_MONDAY_FIRST: Array<{ day: string; weekday: number }> = [ + { day: 'Montag', weekday: 1 }, + { day: 'Dienstag', weekday: 2 }, + { day: 'Mittwoch', weekday: 3 }, + { day: 'Donnerstag', weekday: 4 }, + { day: 'Freitag', weekday: 5 }, + { day: 'Samstag', weekday: 6 }, + { day: 'Sonntag', weekday: 0 }, +] + +const WEEKDAY_SHORT_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +const berlinPartsFormatter = new Intl.DateTimeFormat('en-US', { + timeZone: RESTAURANT_TIME_ZONE, + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + hourCycle: 'h23', +}) + +function toMinutes(time: string) { + const [hours, minutes] = time.split(':').map(Number) + return hours * 60 + minutes +} + +function getBerlinTimeParts(date: Date) { + const parts = berlinPartsFormatter.formatToParts(date) + const byType = Object.fromEntries(parts.map((part) => [part.type, part.value])) + return { + weekday: WEEKDAY_SHORT_NAMES.indexOf(byType.weekday), + minutesSinceMidnight: Number(byType.hour) * 60 + Number(byType.minute), + } +} + +export function isOnReservationInterval(date: Date, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES) { + return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0 +} + +export function isWithinOpeningHours(date: Date) { + const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date) + const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? [] + + return ranges.some( + (range) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight < toMinutes(range.end), + ) +} + +export function getOpeningHoursForDisplay() { + return WEEKDAY_LABELS_MONDAY_FIRST.map(({ day, weekday }) => { + const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] + return { + day, + hours: ranges.length ? ranges.map((range) => `${range.start} – ${range.end}`) : null, + } + }) +} + +export function getTodayLabelBerlin() { + return new Intl.DateTimeFormat('de-DE', { timeZone: RESTAURANT_TIME_ZONE, weekday: 'long' }).format(new Date()) +} + +// The functions below drive the customer-facing date/time picker. They work with the +// browser's local time (not RESTAURANT_TIME_ZONE) since visitors are assumed to be +// browsing from the restaurant's own timezone — same assumption the rest of the +// booking form already makes when it builds a Date from an unqualified local string. + +export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] { + return OPENING_HOURS_BY_WEEKDAY[weekday] ?? [] +} + +export function generateTimeOptions( + ranges: OpeningRange[], + intervalMinutes: number = RESERVATION_INTERVAL_MINUTES, +): string[] { + const options: string[] = [] + + for (const range of ranges) { + const startMinutes = toMinutes(range.start) + const endMinutes = toMinutes(range.end) + + for (let minutes = startMinutes; minutes < endMinutes; minutes += intervalMinutes) { + const hours = Math.floor(minutes / 60) + const remainder = minutes % 60 + options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`) + } + } + + return options +} + +export function getNextAvailableSlot( + from: Date, + intervalMinutes: number = RESERVATION_INTERVAL_MINUTES, +): { date: string; time: string } | null { + for (let dayOffset = 0; dayOffset < 14; dayOffset++) { + const day = new Date(from) + day.setDate(from.getDate() + dayOffset) + + const dateStr = formatDateOnly(day) + const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes) + + for (const time of options) { + const slot = new Date(`${dateStr}T${time}`) + if (slot.getTime() >= from.getTime()) { + return { date: dateStr, time } + } + } + } + + return null +} diff --git a/src/lib/reservations.ts b/src/lib/reservations.ts index bfcf1a0..bf1f11b 100644 --- a/src/lib/reservations.ts +++ b/src/lib/reservations.ts @@ -1,10 +1,77 @@ import { createServerFn } from '@tanstack/react-start' -import { and, eq, ne } from 'drizzle-orm' +import { eq } from 'drizzle-orm' import { z } from 'zod' import { db } from '#/db' import { reservations, restaurantTables } from '#/db/schema' import { requireStaffSession } from '#/lib/auth' +import { + sendReservationCancellationEmail, + sendReservationConfirmationEmail, + sendReservationUpdateEmail, +} from '#/lib/email' +import { isOnReservationInterval, isWithinOpeningHours } from '#/lib/opening-hours' +import { loadReservationSettings } from '#/lib/settings' + +function formatLeadTime(minutes: number) { + if (minutes % 60 === 0) { + const hours = minutes / 60 + return `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}` + } + return `${minutes} Minuten` +} + +function assertValidReservationSlot( + reservationTime: Date, + intervalMinutes: number, + minLeadTimeMinutes: number, + maxLeadTimeDays: number, +) { + const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000 + + if (reservationTime.getTime() < earliestAllowed) { + throw new Error( + minLeadTimeMinutes > 0 + ? `Reservierungen müssen mindestens ${formatLeadTime(minLeadTimeMinutes)} im Voraus gebucht werden` + : 'Die Reservierungszeit muss in der Zukunft liegen', + ) + } + + const latestAllowed = Date.now() + maxLeadTimeDays * 24 * 60 * 60_000 + + if (reservationTime.getTime() > latestAllowed) { + throw new Error(`Reservierungen sind höchstens ${maxLeadTimeDays} Tage im Voraus möglich`) + } + + if (!isOnReservationInterval(reservationTime, intervalMinutes)) { + throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`) + } + + if (!isWithinOpeningHours(reservationTime)) { + throw new Error('Zu dieser Zeit haben wir leider geschlossen') + } +} + +// A table is occupied for its own configured duration, not just the exact minute it +// was booked for — so overlapping bookings (not just identical ones) are conflicts. +// Each reservation's own stored duration is used (not the current setting), since an +// older booking keeps whatever duration was in effect when it was made. +async function findOverlappingReservation( + tableId: number, + start: Date, + durationMinutes: number, + excludeId?: number, +) { + const end = start.getTime() + durationMinutes * 60_000 + const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId)) + + return candidates.find((r) => { + if (excludeId !== undefined && r.id === excludeId) return false + const existingStart = r.reservationTime.getTime() + const existingEnd = existingStart + r.durationMinutes * 60_000 + return existingStart < end && start.getTime() < existingEnd + }) +} export const getTables = createServerFn({ method: 'GET' }).handler(async () => { return db.select().from(restaurantTables).orderBy(restaurantTables.name) @@ -22,6 +89,7 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async ( customerEmail: reservations.customerEmail, partySize: reservations.partySize, reservationTime: reservations.reservationTime, + durationMinutes: reservations.durationMinutes, }) .from(reservations) .innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id)) @@ -41,11 +109,15 @@ const createReservationSchema = z.object({ export const createReservation = createServerFn({ method: 'POST' }) .validator(createReservationSchema) .handler(async ({ data }) => { + const settings = await loadReservationSettings() const reservationTime = new Date(data.reservationTime) - if (reservationTime.getTime() < Date.now()) { - throw new Error('Reservation time must be in the future') - } + assertValidReservationSlot( + reservationTime, + settings.reservationIntervalMinutes, + settings.minLeadTimeMinutes, + settings.maxLeadTimeDays, + ) const [table] = await db .select() @@ -60,15 +132,11 @@ export const createReservation = createServerFn({ method: 'POST' }) throw new Error(`${table.name} only seats up to ${table.seats} guests`) } - const [conflict] = await db - .select() - .from(reservations) - .where( - and( - eq(reservations.tableId, data.tableId), - eq(reservations.reservationTime, reservationTime), - ), - ) + const conflict = await findOverlappingReservation( + data.tableId, + reservationTime, + settings.reservationDurationMinutes, + ) if (conflict) { throw new Error('This table is already booked for that time') @@ -80,11 +148,21 @@ export const createReservation = createServerFn({ method: 'POST' }) tableId: data.tableId, customerName: data.customerName, customerEmail: data.customerEmail, + durationMinutes: settings.reservationDurationMinutes, partySize: data.partySize, reservationTime, }) .returning() + await sendReservationConfirmationEmail({ + customerName: reservation.customerName, + customerEmail: reservation.customerEmail, + partySize: reservation.partySize, + reservationTime: reservation.reservationTime, + cancelToken: reservation.cancelToken, + table, + }) + return reservation }) @@ -97,8 +175,16 @@ export const updateReservation = createServerFn({ method: 'POST' }) .handler(async ({ data }) => { await requireStaffSession() + const settings = await loadReservationSettings() const reservationTime = new Date(data.reservationTime) + assertValidReservationSlot( + reservationTime, + settings.reservationIntervalMinutes, + settings.minLeadTimeMinutes, + settings.maxLeadTimeDays, + ) + const [table] = await db .select() .from(restaurantTables) @@ -112,16 +198,12 @@ export const updateReservation = createServerFn({ method: 'POST' }) throw new Error(`${table.name} only seats up to ${table.seats} guests`) } - const [conflict] = await db - .select() - .from(reservations) - .where( - and( - eq(reservations.tableId, data.tableId), - eq(reservations.reservationTime, reservationTime), - ne(reservations.id, data.id), - ), - ) + const conflict = await findOverlappingReservation( + data.tableId, + reservationTime, + settings.reservationDurationMinutes, + data.id, + ) if (conflict) { throw new Error('This table is already booked for that time') @@ -135,6 +217,7 @@ export const updateReservation = createServerFn({ method: 'POST' }) customerEmail: data.customerEmail, partySize: data.partySize, reservationTime, + durationMinutes: settings.reservationDurationMinutes, }) .where(eq(reservations.id, data.id)) .returning() @@ -143,6 +226,15 @@ export const updateReservation = createServerFn({ method: 'POST' }) throw new Error('Reservation not found') } + await sendReservationUpdateEmail({ + customerName: updated.customerName, + customerEmail: updated.customerEmail, + partySize: updated.partySize, + reservationTime: updated.reservationTime, + cancelToken: updated.cancelToken, + table, + }) + return updated }) @@ -161,5 +253,75 @@ export const deleteReservation = createServerFn({ method: 'POST' }) throw new Error('Reservation not found') } + const [table] = await db + .select() + .from(restaurantTables) + .where(eq(restaurantTables.id, deleted.tableId)) + + if (table) { + await sendReservationCancellationEmail({ + customerName: deleted.customerName, + customerEmail: deleted.customerEmail, + partySize: deleted.partySize, + reservationTime: deleted.reservationTime, + cancelToken: deleted.cancelToken, + table, + }) + } + return deleted }) + +const getReservationByCancelTokenSchema = z.object({ + token: z.string().min(1), +}) + +export const getReservationByCancelToken = createServerFn({ method: 'GET' }) + .validator(getReservationByCancelTokenSchema) + .handler(async ({ data }) => { + const [reservation] = await db + .select({ + customerName: reservations.customerName, + partySize: reservations.partySize, + reservationTime: reservations.reservationTime, + tableName: restaurantTables.name, + }) + .from(reservations) + .innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id)) + .where(eq(reservations.cancelToken, data.token)) + + return reservation ?? null + }) + +const cancelReservationByTokenSchema = z.object({ + token: z.string().min(1), +}) + +export const cancelReservationByToken = createServerFn({ method: 'POST' }) + .validator(cancelReservationByTokenSchema) + .handler(async ({ data }) => { + const [deleted] = await db + .delete(reservations) + .where(eq(reservations.cancelToken, data.token)) + .returning() + + if (!deleted) { + throw new Error('Reservation not found') + } + + const [table] = await db + .select() + .from(restaurantTables) + .where(eq(restaurantTables.id, deleted.tableId)) + + if (table) { + await sendReservationCancellationEmail({ + customerName: deleted.customerName, + customerEmail: deleted.customerEmail, + partySize: deleted.partySize, + reservationTime: deleted.reservationTime, + cancelToken: deleted.cancelToken, + table, + }) + } + }) diff --git a/src/lib/settings.ts b/src/lib/settings.ts new file mode 100644 index 0000000..f79449b --- /dev/null +++ b/src/lib/settings.ts @@ -0,0 +1,60 @@ +import { createServerFn, createServerOnlyFn } from '@tanstack/react-start' +import { eq } from 'drizzle-orm' +import { z } from 'zod' + +import { db } from '#/db' +import { restaurantSettings } from '#/db/schema' +import { requireStaffSession } from '#/lib/auth' + +// 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 +// (rather than only reachable through the server fn below) so reservations.ts can +// read the current settings directly while validating a booking. This module is +// imported by client-reachable route files for the createServerFns below, so — +// same issue as `#/lib/auth` — a plain, unwrapped export using `db` directly would +// drag the Postgres driver into the browser bundle; `createServerOnlyFn` keeps it +// server-side only. +export const loadReservationSettings = createServerOnlyFn(async function loadReservationSettings() { + const [existing] = await db.select().from(restaurantSettings).limit(1) + if (existing) return existing + + const [created] = await db.insert(restaurantSettings).values({}).returning() + return created +}) + +export const getReservationSettings = createServerFn({ method: 'GET' }).handler(async () => { + return loadReservationSettings() +}) + +const updateReservationSettingsSchema = z.object({ + reservationIntervalMinutes: z.number().int().min(5).max(120), + reservationDurationMinutes: z.number().int().min(15).max(480), + minLeadTimeMinutes: z.number().int().min(0).max(10080), + maxLeadTimeDays: z.number().int().min(1).max(365), +}) + +export const updateReservationSettings = createServerFn({ method: 'POST' }) + .validator(updateReservationSettingsSchema) + .handler(async ({ data }) => { + console.log('[settings] updateReservationSettings called with', data) + + const session = await requireStaffSession() + console.log('[settings] session role:', session.user.role) + + if (session.user.role !== 'admin') { + throw new Error('Nur Admins dürfen die Einstellungen ändern') + } + + const existing = await loadReservationSettings() + console.log('[settings] existing row before update:', existing) + + const [updated] = await db + .update(restaurantSettings) + .set(data) + .where(eq(restaurantSettings.id, existing.id)) + .returning() + + console.log('[settings] row after update:', updated) + + return updated + }) diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 379ed06..1cadb7a 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -13,7 +13,9 @@ import { Route as IndexRouteImport } from './routes/index' 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 StaffTeamRouteImport } from './routes/staff.team' +import { Route as StornierenTokenRouteImport } from './routes/stornieren.$token' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -35,24 +37,38 @@ const StaffLoginRoute = StaffLoginRouteImport.update({ path: '/staff/login', getParentRoute: () => rootRouteImport, } as any) +const StaffSettingsRoute = StaffSettingsRouteImport.update({ + id: '/staff/settings', + path: '/staff/settings', + getParentRoute: () => rootRouteImport, +} as any) const StaffTeamRoute = StaffTeamRouteImport.update({ id: '/staff/team', path: '/staff/team', getParentRoute: () => rootRouteImport, } as any) +const StornierenTokenRoute = StornierenTokenRouteImport.update({ + id: '/stornieren/$token', + path: '/stornieren/$token', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/impressum': typeof ImpressumRoute '/staff/login': typeof StaffLoginRoute + '/staff/settings': typeof StaffSettingsRoute '/staff/team': typeof StaffTeamRoute + '/stornieren/$token': typeof StornierenTokenRoute '/staff/': typeof StaffIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/impressum': typeof ImpressumRoute '/staff/login': typeof StaffLoginRoute + '/staff/settings': typeof StaffSettingsRoute '/staff/team': typeof StaffTeamRoute + '/stornieren/$token': typeof StornierenTokenRoute '/staff': typeof StaffIndexRoute } export interface FileRoutesById { @@ -60,23 +76,48 @@ export interface FileRoutesById { '/': typeof IndexRoute '/impressum': typeof ImpressumRoute '/staff/login': typeof StaffLoginRoute + '/staff/settings': typeof StaffSettingsRoute '/staff/team': typeof StaffTeamRoute + '/stornieren/$token': typeof StornierenTokenRoute '/staff/': typeof StaffIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff/' + fullPaths: + | '/' + | '/impressum' + | '/staff/login' + | '/staff/settings' + | '/staff/team' + | '/stornieren/$token' + | '/staff/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff' + to: + | '/' + | '/impressum' + | '/staff/login' + | '/staff/settings' + | '/staff/team' + | '/stornieren/$token' + | '/staff' id: - '__root__' | '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff/' + | '__root__' + | '/' + | '/impressum' + | '/staff/login' + | '/staff/settings' + | '/staff/team' + | '/stornieren/$token' + | '/staff/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute ImpressumRoute: typeof ImpressumRoute StaffLoginRoute: typeof StaffLoginRoute + StaffSettingsRoute: typeof StaffSettingsRoute StaffTeamRoute: typeof StaffTeamRoute + StornierenTokenRoute: typeof StornierenTokenRoute StaffIndexRoute: typeof StaffIndexRoute } @@ -110,6 +151,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StaffLoginRouteImport parentRoute: typeof rootRouteImport } + '/staff/settings': { + id: '/staff/settings' + path: '/staff/settings' + fullPath: '/staff/settings' + preLoaderRoute: typeof StaffSettingsRouteImport + parentRoute: typeof rootRouteImport + } '/staff/team': { id: '/staff/team' path: '/staff/team' @@ -117,6 +165,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof StaffTeamRouteImport parentRoute: typeof rootRouteImport } + '/stornieren/$token': { + id: '/stornieren/$token' + path: '/stornieren/$token' + fullPath: '/stornieren/$token' + preLoaderRoute: typeof StornierenTokenRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -124,7 +179,9 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ImpressumRoute: ImpressumRoute, StaffLoginRoute: StaffLoginRoute, + StaffSettingsRoute: StaffSettingsRoute, StaffTeamRoute: StaffTeamRoute, + StornierenTokenRoute: StornierenTokenRoute, StaffIndexRoute: StaffIndexRoute, } export const routeTree = rootRouteImport diff --git a/src/routes/index.tsx b/src/routes/index.tsx index fe38fe8..f4223e9 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,28 +1,22 @@ import { useState } from 'react' import { createFileRoute, useRouter } from '@tanstack/react-router' +import { ReservationDateTimeField } from '#/components/reservation-datetime-field' import { SiteFooter } from '#/components/site-footer' import { SiteHeader } from '#/components/site-header' -import { formatDateTimeLocal } from '#/lib/datetime' +import { getNextAvailableSlot, getOpeningHoursForDisplay, getTodayLabelBerlin } from '#/lib/opening-hours' import { createReservation, getTables } from '#/lib/reservations' +import { getReservationSettings } from '#/lib/settings' export const Route = createFileRoute('/')({ component: Home, loader: async () => { - const tables = await getTables() - return { tables } + const [tables, settings] = await Promise.all([getTables(), getReservationSettings()]) + return { tables, settings } }, }) -const OPENING_HOURS = [ - { day: 'Montag', hours: null }, - { day: 'Dienstag', hours: ['17:00 – 21:30'] }, - { day: 'Mittwoch', hours: ['17:00 – 21:30'] }, - { day: 'Donnerstag', hours: ['17:00 – 21:30'] }, - { day: 'Freitag', hours: ['17:00 – 22:00'] }, - { day: 'Samstag', hours: ['17:00 – 22:00'] }, - { day: 'Sonntag', hours: ['11:30 – 13:30', '17:00 – 22:00'] }, -] +const OPENING_HOURS = getOpeningHoursForDisplay() function FlameIcon() { return ( @@ -39,20 +33,24 @@ function FlameIcon() { function Home() { const router = useRouter() - const { tables } = Route.useLoaderData() + const { tables, settings } = Route.useLoaderData() const [tableId, setTableId] = useState(tables[0]?.id ?? '') const [customerName, setCustomerName] = useState('') const [customerEmail, setCustomerEmail] = useState('') const [partySize, setPartySize] = useState(2) - const [reservationTime, setReservationTime] = useState(() => - formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)), - ) + const [reservationTime, setReservationTime] = useState(() => { + const slot = getNextAvailableSlot( + new Date(Date.now() + settings.minLeadTimeMinutes * 60_000), + settings.reservationIntervalMinutes, + ) + return slot ? `${slot.date}T${slot.time}` : '' + }) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) - const todayName = new Date().toLocaleDateString('de-DE', { weekday: 'long' }) + const todayName = getTodayLabelBerlin() async function handleSubmit(event: React.FormEvent) { event.preventDefault() @@ -64,6 +62,11 @@ function Home() { return } + if (!reservationTime.split('T')[1]) { + setError('Bitte wählen Sie eine gültige Uhrzeit aus') + return + } + setIsSubmitting(true) try { await createReservation({ @@ -182,6 +185,7 @@ function Home() { Anzahl Personen Datum & Uhrzeit - setReservationTime(e.target.value)} - required + onChange={setReservationTime} + intervalMinutes={settings.reservationIntervalMinutes} + minLeadTimeMinutes={settings.minLeadTimeMinutes} + maxLeadTimeDays={settings.maxLeadTimeDays} /> +

+ Verfügbare Uhrzeiten richten sich nach unseren{' '} + Öffnungszeiten. +

+ diff --git a/src/routes/staff.index.tsx b/src/routes/staff.index.tsx index 74ce5ce..d8de304 100644 --- a/src/routes/staff.index.tsx +++ b/src/routes/staff.index.tsx @@ -1,9 +1,14 @@ import { useState } from 'react' import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router' +import { ConfirmDialog } from '#/components/confirm-dialog' +import { ReservationCharts } from '#/components/reservation-charts' +import { ReservationDateTimeField } from '#/components/reservation-datetime-field' +import { StaffTabs } from '#/components/staff-tabs' import { getCurrentStaffUser } from '#/lib/auth' import { authClient } from '#/lib/auth-client' -import { formatDateTimeLocal } from '#/lib/datetime' +import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime' +import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours' import { createReservation, deleteReservation, @@ -11,6 +16,7 @@ import { getTables, updateReservation, } from '#/lib/reservations' +import { getReservationSettings } from '#/lib/settings' export const Route = createFileRoute('/staff/')({ component: StaffDashboard, @@ -22,8 +28,12 @@ export const Route = createFileRoute('/staff/')({ return { staffUser } }, loader: async ({ context }) => { - const [reservations, tables] = await Promise.all([getReservations(), getTables()]) - return { staffUser: context.staffUser, reservations, tables } + const [reservations, tables, settings] = await Promise.all([ + getReservations(), + getTables(), + getReservationSettings(), + ]) + return { staffUser: context.staffUser, reservations, tables, settings } }, }) @@ -48,32 +58,86 @@ type FormState = { reservationTime: string } -function emptyForm(defaultTableId: number | string): FormState { +type SettingsRow = Awaited> + +function emptyForm(defaultTableId: number | string, settings: SettingsRow): FormState { + const slot = getNextAvailableSlot( + new Date(Date.now() + settings.minLeadTimeMinutes * 60_000), + settings.reservationIntervalMinutes, + ) + return { tableId: defaultTableId, customerName: '', customerEmail: '', partySize: 2, - reservationTime: formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)), + reservationTime: slot ? `${slot.date}T${slot.time}` : '', } } function StaffDashboard() { const router = useRouter() - const { staffUser, reservations, tables } = Route.useLoaderData() + const { staffUser, reservations, tables, settings } = Route.useLoaderData() const [editingId, setEditingId] = useState(null) - const [form, setForm] = useState(() => emptyForm(tables[0]?.id ?? '')) + const [form, setForm] = useState(() => emptyForm(tables[0]?.id ?? '', settings)) const [error, setError] = useState(null) const [success, setSuccess] = useState(null) const [isSubmitting, setIsSubmitting] = useState(false) const [deletingId, setDeletingId] = useState(null) + const [pendingDelete, setPendingDelete] = useState(null) + const [searchQuery, setSearchQuery] = useState('') + const [tableFilter, setTableFilter] = useState('all') + const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('all') const now = new Date() const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now)) const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0) const upcoming = reservations.filter((r) => new Date(r.reservationTime).getTime() >= now.getTime()) + const filteredReservations = reservations.filter((r) => { + if (tableFilter !== 'all' && String(r.tableId) !== tableFilter) return false + + const reservationTime = new Date(r.reservationTime) + if (timeFilter === 'today' && !isSameDay(reservationTime, now)) return false + if (timeFilter === 'upcoming' && reservationTime.getTime() < now.getTime()) return false + if (timeFilter === 'past' && reservationTime.getTime() >= now.getTime()) return false + + const query = searchQuery.trim().toLowerCase() + if (query) { + const haystack = `${r.customerName} ${r.customerEmail} ${r.tableName}`.toLowerCase() + if (!haystack.includes(query)) return false + } + + return true + }) + + // Other reservations already sitting on the currently selected table + date, so the + // time picker can block them instead of letting staff pick a slot the server will + // just reject as double-booked. A reservation occupies its whole configured + // duration, not just its exact start time, so every interval-grid slot that falls + // inside that window counts as taken too. + const [selectedDate] = form.reservationTime.split('T') + const otherReservationsOnSelection = reservations.filter( + (r) => + r.id !== editingId && + String(r.tableId) === String(form.tableId) && + formatDateOnly(new Date(r.reservationTime)) === selectedDate, + ) + const bookedTimesForForm = selectedDate + ? generateTimeOptions( + getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()), + settings.reservationIntervalMinutes, + ).filter((option) => { + const slotStart = new Date(`${selectedDate}T${option}`).getTime() + return otherReservationsOnSelection.some((r) => { + const otherStart = new Date(r.reservationTime).getTime() + const otherEnd = otherStart + r.durationMinutes * 60_000 + return slotStart >= otherStart && slotStart < otherEnd + }) + }) + : [] + function startEdit(reservation: ReservationRow) { setEditingId(reservation.id) setForm({ @@ -89,7 +153,7 @@ function StaffDashboard() { function cancelEdit() { setEditingId(null) - setForm(emptyForm(tables[0]?.id ?? '')) + setForm(emptyForm(tables[0]?.id ?? '', settings)) setError(null) } @@ -103,6 +167,11 @@ function StaffDashboard() { return } + if (!form.reservationTime.split('T')[1]) { + setError('Bitte wählen Sie eine gültige Uhrzeit aus') + return + } + setIsSubmitting(true) try { const payload = { @@ -122,7 +191,7 @@ function StaffDashboard() { } setEditingId(null) - setForm(emptyForm(tables[0]?.id ?? '')) + setForm(emptyForm(tables[0]?.id ?? '', settings)) await router.invalidate() } catch (err) { setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') @@ -131,11 +200,11 @@ function StaffDashboard() { } } - async function handleDelete(reservation: ReservationRow) { - if (!window.confirm(`Reservierung von ${reservation.customerName} wirklich löschen?`)) { - return - } + async function confirmDelete() { + const reservation = pendingDelete + if (!reservation) return + setPendingDelete(null) setDeletingId(reservation.id) setError(null) setSuccess(null) @@ -162,22 +231,23 @@ function StaffDashboard() { return (
-
- - Steakhaus bei Mirko - -
- {staffUser.role === 'admin' && ( - - Mitarbeiter - - )} - Angemeldet als {staffUser.name} - +
+
+ + Steakhaus bei Mirko + +
+ Angemeldet als {staffUser.name} + +
+
+ +
+
-
+

Reservierungen

@@ -201,6 +271,8 @@ function StaffDashboard() { + +

{editingId ? 'Reservierung bearbeiten' : 'Neue Reservierung'} @@ -252,6 +324,7 @@ function StaffDashboard() { Anzahl Personen Datum & Uhrzeit - setForm((f) => ({ ...f, reservationTime: e.target.value }))} - required + onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))} + bookedTimes={bookedTimesForForm} + intervalMinutes={settings.reservationIntervalMinutes} + minLeadTimeMinutes={settings.minLeadTimeMinutes} + maxLeadTimeDays={settings.maxLeadTimeDays} /> @@ -288,8 +363,43 @@ function StaffDashboard() {

- {reservations.length === 0 ? ( -

Noch keine Reservierungen vorhanden.

+ {reservations.length > 0 && ( +
+ setSearchQuery(e.target.value)} + aria-label="Reservierungen durchsuchen" + /> + + +
+ )} + + {filteredReservations.length === 0 ? ( +

+ {reservations.length === 0 + ? 'Noch keine Reservierungen vorhanden.' + : 'Keine Reservierungen für diese Filter gefunden.'} +

) : (
@@ -303,7 +413,7 @@ function StaffDashboard() { - {reservations.map((reservation) => ( + {filteredReservations.map((reservation) => ( @@ -313,18 +423,20 @@ function StaffDashboard() { {reservation.customerEmail} - ))} @@ -334,6 +446,20 @@ function StaffDashboard() { )} + + setPendingDelete(null)} + /> ) } diff --git a/src/routes/staff.login.tsx b/src/routes/staff.login.tsx index c106083..262add5 100644 --- a/src/routes/staff.login.tsx +++ b/src/routes/staff.login.tsx @@ -1,9 +1,23 @@ import { useState } from 'react' -import { createFileRoute, redirect, useRouter } from '@tanstack/react-router' +import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router' import { getCurrentStaffUser } from '#/lib/auth' import { authClient } from '#/lib/auth-client' +function ArrowLeftIcon() { + return ( + + ) +} + export const Route = createFileRoute('/staff/login')({ component: StaffLogin, beforeLoad: async () => { @@ -41,6 +55,11 @@ function StaffLogin() { return (
+ + + Zurück zur Startseite + +
Mitarbeiterbereich

Anmelden

diff --git a/src/routes/staff.settings.tsx b/src/routes/staff.settings.tsx new file mode 100644 index 0000000..fb3d409 --- /dev/null +++ b/src/routes/staff.settings.tsx @@ -0,0 +1,175 @@ +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 { getReservationSettings, updateReservationSettings } from '#/lib/settings' + +export const Route = createFileRoute('/staff/settings')({ + component: StaffSettings, + 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 settings = await getReservationSettings() + return { staffUser: context.staffUser, settings } + }, +}) + +type FormState = { + reservationIntervalMinutes: number + reservationDurationMinutes: number + minLeadTimeMinutes: number + maxLeadTimeDays: number +} + +function StaffSettings() { + const router = useRouter() + const { staffUser, settings } = Route.useLoaderData() + + const [form, setForm] = useState({ + reservationIntervalMinutes: settings.reservationIntervalMinutes, + reservationDurationMinutes: settings.reservationDurationMinutes, + minLeadTimeMinutes: settings.minLeadTimeMinutes, + maxLeadTimeDays: settings.maxLeadTimeDays, + }) + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [isSubmitting, setIsSubmitting] = useState(false) + + async function handleLogout() { + await authClient.signOut() + await router.navigate({ to: '/staff/login' }) + await router.invalidate() + } + + async function handleSubmit(event: React.FormEvent) { + event.preventDefault() + setError(null) + setSuccess(null) + setIsSubmitting(true) + + try { + await updateReservationSettings({ data: form }) + setSuccess('Einstellungen wurden gespeichert.') + await router.invalidate() + } catch (err) { + setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') + } finally { + setIsSubmitting(false) + } + } + + return ( +
+
+
+ + Steakhaus bei Mirko + +
+ Angemeldet als {staffUser.name} + +
+
+ +
+ +
+
+ +
+

Einstellungen

+ +
+

Reservierungsregeln

+ + + +

Abstand zwischen den wählbaren Uhrzeiten im Buchungsformular.

+ + +

+ Wie lange ein Tisch nach einer Reservierung als belegt gilt — verhindert überlappende Buchungen. +

+ + +

+ Wie kurzfristig frühestens reserviert werden kann, z. B. 60 für „mindestens eine Stunde im Voraus“. +

+ + +

+ Wie weit im Voraus reserviert werden kann, z. B. 60 für „höchstens 60 Tage im Voraus“. +

+ + + + {error &&

{error}

} + {success &&

{success}

} + +
+
+
+ ) +} diff --git a/src/routes/staff.team.tsx b/src/routes/staff.team.tsx index 7956f9f..743406f 100644 --- a/src/routes/staff.team.tsx +++ b/src/routes/staff.team.tsx @@ -1,6 +1,7 @@ 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 { listStaffMembers } from '#/lib/staff' @@ -129,6 +130,12 @@ function StaffTeam() { setBusyId(null) } + async function handleLogout() { + await authClient.signOut() + await router.navigate({ to: '/staff/login' }) + await router.invalidate() + } + async function handleSetPassword(member: StaffMember) { const newPassword = window.prompt(`Neues Passwort für ${member.name} (mind. 8 Zeichen):`) @@ -156,17 +163,23 @@ function StaffTeam() { return (
-
- - Steakhaus bei Mirko - -
- - Zu den Reservierungen +
+
+ + Steakhaus bei Mirko - Angemeldet als {staffUser.name} +
+ Angemeldet als {staffUser.name} + +
+
+ +
+
-
+

Mitarbeiter

@@ -249,39 +262,41 @@ function StaffTeam() {
- ))} diff --git a/src/routes/stornieren.$token.tsx b/src/routes/stornieren.$token.tsx new file mode 100644 index 0000000..a292027 --- /dev/null +++ b/src/routes/stornieren.$token.tsx @@ -0,0 +1,96 @@ +import { useState } from 'react' +import { createFileRoute, Link } from '@tanstack/react-router' + +import { SiteFooter } from '#/components/site-footer' +import { SiteHeader } from '#/components/site-header' +import { cancelReservationByToken, getReservationByCancelToken } from '#/lib/reservations' + +export const Route = createFileRoute('/stornieren/$token')({ + component: CancelReservationPage, + loader: async ({ params }) => { + const reservation = await getReservationByCancelToken({ data: { token: params.token } }) + return { reservation } + }, +}) + +const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', { + dateStyle: 'full', + timeStyle: 'short', +}) + +function CancelReservationPage() { + const { token } = Route.useParams() + const { reservation } = Route.useLoaderData() + + const [isCancelled, setIsCancelled] = useState(false) + const [isSubmitting, setIsSubmitting] = useState(false) + const [error, setError] = useState(null) + + async function handleCancel() { + setError(null) + setIsSubmitting(true) + + try { + await cancelReservationByToken({ data: { token } }) + setIsCancelled(true) + } catch (err) { + setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') + } finally { + setIsSubmitting(false) + } + } + + return ( + <> + + +
+
+
+
+ Reservierung verwalten +

Reservierung stornieren

+
+ + {!reservation ? ( +

+ Diese Reservierung wurde nicht gefunden oder ist bereits storniert.{' '} + Zurück zur Startseite +

+ ) : isCancelled ? ( +

+ Ihre Reservierung wurde storniert. Wir haben Ihnen eine Bestätigung per E-Mail + gesendet. Zurück zur Startseite +

+ ) : ( +
+
    +
  • + Tisch + {reservation.tableName} +
  • +
  • + Datum & Uhrzeit + {dateTimeFormatter.format(new Date(reservation.reservationTime))} +
  • +
  • + Personen + {reservation.partySize} +
  • +
+ + + + {error &&

{error}

} +
+ )} +
+
+
+ + + + ) +} diff --git a/src/styles.css b/src/styles.css index 966388a..92515cc 100644 --- a/src/styles.css +++ b/src/styles.css @@ -19,9 +19,12 @@ html { scroll-behavior: smooth; } +/* `clip`, not `hidden` — `hidden` on both axes-owners forces the browser to also + compute `overflow-y: auto` on each, which creates two nested scroll containers + and breaks `position: sticky` for everything on the page. */ html, body { - overflow-x: hidden; + overflow-x: clip; } body { @@ -42,7 +45,7 @@ h3 { } a { - color: var(--gold-bright); + color: var(--text); } .page { @@ -54,6 +57,17 @@ a { /* Header */ .site-header { + position: sticky; + top: 0; + z-index: 50; + background: var(--bg); + border-bottom: 1px solid var(--line); + display: flex; + align-content: center; + justify-content: center; +} + +.site-header-inner { display: flex; align-items: center; justify-content: space-between; @@ -61,12 +75,13 @@ a { row-gap: 0.5rem; gap: 1rem; padding: 1.25rem 1.5rem; - max-width: 68rem; - margin: 0 auto; + min-height: 5rem; + width: 100%; + max-width: 120rem } @media (max-width: 30rem) { - .site-header { + .site-header-inner { justify-content: center; text-align: center; } @@ -101,7 +116,19 @@ a { } .site-nav a:hover { - color: var(--gold-bright); + color: var(--text); +} + +.staff-login-link { + color: var(--text-muted); + font-size: 0.8rem; + text-decoration: none; + opacity: 0.75; +} + +.staff-login-link:hover { + color: var(--text); + opacity: 1; } /* Hero */ @@ -134,7 +161,7 @@ a { } .hero h1 span { - color: var(--gold-bright); + color: var(--text); } .hero p { @@ -179,8 +206,8 @@ a { } .btn-secondary:hover { - border-color: var(--gold); - color: var(--gold-bright); + border-color: var(--text-muted); + color: var(--text); } /* Sections */ @@ -204,7 +231,7 @@ a { font-size: 0.8rem; letter-spacing: 0.14em; text-transform: uppercase; - color: var(--gold); + color: var(--text-muted); margin-bottom: 0.75rem; } @@ -293,11 +320,173 @@ a { color: var(--text-muted); } +.datetime-field { + display: flex; + flex-wrap: wrap; + gap: 0.6rem +} + +.date-input-group { + position: relative; + flex: 1 1 10rem; + min-width: 10rem; +} + +.date-input-group input[type='text'] { + width: 100%; + padding-right: 2.5rem; +} + +.datetime-field select { + flex: 1 1 6.5rem; + min-width: 6.5rem; +} + +.datetime-field select:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.date-picker-trigger { + 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; + cursor: pointer; +} + +.date-picker-trigger svg { + flex-shrink: 0; + width: 18px; + height: 18px; + color: var(--text); +} + +.date-picker-trigger:hover, +.date-picker-trigger[aria-expanded='true'] { + anchor-name: --anchor-date-time; +} + +.date-popover { + position: absolute; + position-anchor: --anchor-date-time; + left: calc(anchor(center) - 100%); + z-index: 20; + padding: 0.6rem; + background: var(--bg-raised); + border: 1px solid var(--line); + border-radius: 0.4rem; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35); + overflow: hidden; +} + +.date-popover-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.4rem; + color: var(--text); + font-size: 0.8rem; + font-weight: 500; +} + +.date-popover-header button { + width: 1.5rem; + height: 1.5rem; + padding: 0; + margin: 0; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 0.25rem; + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 0.9rem; + cursor: pointer; +} + +.date-popover-header button:hover:not(:disabled) { + color: var(--text); +} + +.date-popover-header button:disabled { + opacity: 0.35; + cursor: not-allowed; +} + +.date-popover-weekdays, +.date-popover-grid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 0.1rem; + + span { + width: 1.8rem; + } + + button { + display: flex; + align-items: center; + justify-content: center; + } +} + +.date-popover-weekdays { + margin-bottom: 0.15rem; + font-size: 0.68rem; + color: var(--text-muted); + text-align: center; +} + +.date-popover-grid button { + aspect-ratio: 1; + min-width: 0; + min-height: 0; + padding: 0; + margin: 0; + align-self: auto; + border: none; + border-radius: 50%; + background: transparent; + color: var(--text); + font: inherit; + font-size: 0.78rem; + cursor: pointer; +} + +.date-popover-grid button:hover:not(:disabled) { + background: var(--line); +} + +.date-popover-grid button:disabled { + color: var(--text-muted); + opacity: 0.35; + cursor: not-allowed; +} + +.date-popover-grid button.is-selected { + background: var(--text); + color: var(--bg); + font-weight: 600; +} + .reservation-form input, .reservation-form select { width: 100%; min-width: 0; - font: inherit; + font-family: inherit; + font-size: 1rem; padding: 0.6rem 0.7rem; border: 1px solid var(--line); border-radius: 0.3rem; @@ -312,7 +501,14 @@ a { box-shadow: 0 0 0 2px rgba(201, 161, 90, 0.25); } -.reservation-form button { +/* `: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)` 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; @@ -324,15 +520,26 @@ a { cursor: pointer; } -.reservation-form button: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:disabled { +.reservation-form button:not(.btn):not(.date-popover *):not(.date-picker-trigger):disabled { opacity: 0.6; cursor: not-allowed; } +.field-hint { + margin: -0.4rem 0 0; + font-size: 0.82rem; + color: var(--text-muted); +} + +.field-hint a { + color: inherit; + text-decoration: underline; +} + .message { margin: 0; padding: 0.65rem 0.9rem; @@ -370,7 +577,7 @@ a { } .contact-list .label { - color: var(--gold); + color: var(--text-muted); min-width: 6rem; font-size: 0.85rem; text-transform: uppercase; @@ -405,7 +612,7 @@ a { } .footer-nav a:hover { - color: var(--gold-bright); + color: var(--text); } /* Staff dashboard */ @@ -414,6 +621,16 @@ a { min-height: 100vh; } +/* Sticks the topbar and the tabs below it together as one unit — sticking them + separately would need the tabs' `top` hardcoded to the topbar's height, which + shifts whenever the topbar wraps onto two lines on narrow screens. */ +.staff-nav { + position: sticky; + top: 0; + z-index: 50; + background: var(--bg); +} + .staff-topbar { display: flex; align-items: center; @@ -421,6 +638,10 @@ a { flex-wrap: wrap; gap: 1rem; padding: 1.25rem 1.5rem; + /* Matches `.site-header-inner`'s floor so both bars read as the same height — + this bar's own content is already taller, so the min-height is a no-op here + and never shrinks it. */ + min-height: 5rem; border-bottom: 1px solid var(--line); } @@ -436,6 +657,61 @@ a { padding: 0.5rem 1rem; } +.staff-tabs-bar { + border-bottom: 1px solid var(--line); +} + +.staff-tabs { + display: flex; + gap: 1.5rem; + max-width: 68rem; + margin: 0 auto; + padding: 0 1.5rem; +} + +.staff-tab { + display: inline-flex; + align-items: center; + padding: 0.9rem 0.1rem; + margin-bottom: -1px; + color: var(--text-muted); + font-size: 0.9rem; + font-weight: 500; + text-decoration: none; + border-bottom: 2px solid transparent; +} + +.staff-tab:hover { + color: var(--text); +} + +.staff-tab.is-active { + color: var(--gold-bright); + border-bottom-color: var(--gold-bright); + view-transition-name: staff-tab-active; +} + +::view-transition-group(staff-tab-active) { + animation-duration: 0.22s; + animation-timing-function: ease; +} + +@media (prefers-reduced-motion: reduce) { + ::view-transition-group(*), + ::view-transition-old(*), + ::view-transition-new(*) { + animation: none !important; + } + + .bar-chart-bar { + transition: none; + } + + .confirm-dialog { + animation: none; + } +} + .staff-main { padding: 3rem 1.5rem 4rem; } @@ -471,7 +747,7 @@ a { .stat-value { font-family: 'Fraunces', Georgia, serif; font-size: 1.75rem; - color: var(--gold-bright); + color: var(--text); } .stat-label { @@ -481,6 +757,184 @@ a { color: var(--text-muted); } +.charts-grid { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 1.5rem; + margin-top: 1.5rem; +} + +@media (max-width: 48rem) { + .charts-grid { + grid-template-columns: 1fr; + } +} + +.chart-card { + padding: 1.25rem 1.25rem 1rem; + background: var(--bg-raised); + border: 1px solid var(--line); + border-radius: 0.5rem; +} + +.chart-card-header { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.chart-title { + font-family: 'Inter', system-ui, sans-serif; + font-size: 0.9rem; + font-weight: 600; + margin: 0; +} + +.chart-toggle { + display: inline-flex; + gap: 0.15rem; + padding: 0.15rem; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 0.4rem; +} + +.chart-toggle button { + padding: 0.3rem 0.65rem; + border: none; + border-radius: 0.3rem; + background: transparent; + color: var(--text-muted); + font: inherit; + font-size: 0.8rem; + cursor: pointer; +} + +.chart-toggle button:hover { + color: var(--text); +} + +.chart-toggle button.is-active { + background: var(--gold); + color: var(--bg); +} + +.bar-chart-plot { + position: relative; + height: 10rem; +} + +.bar-chart-gridline { + position: absolute; + left: 0; + right: 0; + border-top: 1px solid var(--line); +} + +.bar-chart-tick { + position: absolute; + left: 0; + bottom: 0; + transform: translateY(50%); + font-size: 0.7rem; + color: var(--text-muted); +} + +.bar-chart-bars { + position: absolute; + inset: 0; + display: flex; + align-items: flex-end; + gap: 0.4rem; + padding-left: 1.5rem; +} + +.bar-chart-col { + position: relative; + flex: 1 1 0; + height: 100%; + min-width: 0; + display: flex; + align-items: flex-end; + justify-content: center; + outline: none; +} + +.bar-chart-bar { + position: relative; + width: 100%; + max-width: 1.5rem; + margin-top: auto; + min-height: 2px; + background: var(--gold); + border-radius: 4px 4px 0 0; + transition: background-color 0.15s ease, height 0.2s ease; +} + +.bar-chart-col:hover .bar-chart-bar, +.bar-chart-col:focus-visible .bar-chart-bar { + background: var(--gold-bright); +} + +.bar-chart-col:focus-visible .bar-chart-bar { + outline: 2px solid var(--gold-bright); + outline-offset: 2px; +} + +.bar-chart-value { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + margin-bottom: 0.3rem; + font-size: 0.75rem; + color: var(--text-muted); + white-space: nowrap; +} + +.bar-chart-tooltip { + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%) translateY(-0.4rem); + margin-bottom: 0.5rem; + padding: 0.4rem 0.6rem; + background: var(--bg); + border: 1px solid var(--line); + border-radius: 0.35rem; + box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35); + color: var(--text); + font-size: 0.75rem; + white-space: nowrap; + opacity: 0; + pointer-events: none; + transition: opacity 0.12s ease; + z-index: 1; +} + +.bar-chart-col:hover .bar-chart-tooltip, +.bar-chart-col:focus-visible .bar-chart-tooltip { + opacity: 1; +} + +.bar-chart-axis { + display: flex; + gap: 0.4rem; + padding-left: 1.5rem; + margin-top: 0.5rem; +} + +.bar-chart-axis-label { + flex: 1 1 0; + min-width: 0; + text-align: center; + font-size: 0.7rem; + color: var(--text-muted); +} + .staff-form-section { max-width: 36rem; } @@ -500,6 +954,36 @@ a { margin-top: 2.5rem; } +.table-filters { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1.25rem; +} + +.table-filters input, +.table-filters select { + font-family: inherit; + font-size: 0.9rem; + padding: 0.55rem 0.7rem; + border: 1px solid var(--line); + border-radius: 0.3rem; + background: var(--bg); + color: var(--text); +} + +.table-filters input:focus, +.table-filters select:focus { + outline: none; + border-color: var(--gold); + box-shadow: 0 0 0 2px rgba(201, 161, 90, 0.25); +} + +.table-search { + flex: 1 1 16rem; + min-width: 0; +} + .table-scroll { overflow-x: auto; border: 1px solid var(--line); @@ -521,7 +1005,7 @@ a { } .reservations-table th { - color: var(--gold); + color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.06em; @@ -533,7 +1017,7 @@ a { } .reservations-table tr.is-editing { - background: rgba(201, 161, 90, 0.08); + background: rgba(243, 234, 217, 0.06); } .reservations-table .muted { @@ -567,6 +1051,60 @@ a { cursor: not-allowed; } +.confirm-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + background: rgba(0, 0, 0, 0.6); +} + +.confirm-dialog { + width: 100%; + max-width: 24rem; + padding: 1.5rem; + background: var(--bg-raised); + border: 1px solid var(--line); + border-radius: 0.5rem; + box-shadow: 0 20px 45px rgba(0, 0, 0, 0.45); + animation: confirm-dialog-fade-in 0.15s ease-out; +} + +@keyframes confirm-dialog-fade-in { + from { + opacity: 0; + transform: translateY(-8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.confirm-dialog-title { + font-size: 1.15rem; + margin: 0 0 0.75rem; +} + +.confirm-dialog-message { + margin: 0 0 1.5rem; + color: var(--text-muted); +} + +.confirm-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 0.75rem; +} + +.confirm-dialog-actions .btn { + padding: 0.6rem 1.25rem; + font-size: 0.9rem; +} + .empty-state { color: var(--text-muted); font-style: italic; @@ -593,6 +1131,28 @@ a { font-size: 1.5rem; } +.back-link { + display: inline-flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 1.75rem; + color: var(--text-muted); + font-size: 0.85rem; + text-decoration: none; +} + +.back-link svg { + transition: transform 0.15s ease; +} + +.back-link:hover { + color: var(--text); +} + +.back-link:hover svg { + transform: translateX(-3px); +} + /* Legal page */ .legal-page { @@ -606,7 +1166,7 @@ a { .legal-page h2 { font-size: 1.25rem; - color: var(--gold-bright); + color: var(--text); margin: 2.25rem 0 0.75rem; }
{dateTimeFormatter.format(new Date(reservation.reservationTime))} {reservation.tableName} {reservation.partySize} - - + +
+ + +
{member.email} {member.role === 'admin' ? 'Admin' : 'Mitarbeiter'} {member.banned ? 'Gesperrt' : 'Aktiv'} - - - - + +
+ + + + +