Compare commits

..
3 Commits
Author SHA1 Message Date
lukas e19461e41d ✨ add bull logo
CI / test (push) Successful in 5m3s
CI / build (push) Successful in 2m42s
CI / deploy (push) Failing after 13s
2026-08-28 12:24:19 +02:00
lukas 23fddd543f ✨ add menu pdf & pdf lib
CI / test (push) Successful in 5m10s
CI / build (push) Successful in 4m36s
CI / deploy (push) Successful in 40s
2026-08-28 11:26:49 +02:00
lukas ecbc42846b ✨ first version
CI / test (push) Successful in 5m16s
CI / deploy (push) Successful in 32s
CI / build (push) Successful in 4m2s
2026-08-28 10:24:15 +02:00
58 changed files with 8552 additions and 439 deletions
+20
View File
@@ -0,0 +1,20 @@
node_modules
dist
dist-ssr
.git
.gitea
.github
.idea
.vscode
.env
.env.*
!.env.example
*.local
.nitro
.tanstack
.wrangler
.output
.vinxi
__unconfig*
todos.json
README.md
+130 -64
View File
@@ -1,42 +1,29 @@
name: CI name: CI
on: on:
pull_request: push:
branches: ['**'] branches:
- main
- proof
workflow_dispatch:
env:
REGISTRY: git.lukas-harat.com
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
jobs: jobs:
build-and-test: test:
runs-on: ubuntu-latest 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 <onboarding@resend.dev>'
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup Node.js - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
@@ -45,50 +32,129 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Type check - name: Typecheck
run: npx tsc --noEmit run: npx tsc --noEmit
- name: Check that Drizzle migrations are up to date build:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ secrets.USERNAME }}
password: ${{ secrets.TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy to TrueNAS
env:
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
STAFF_SEED_EMAIL: ${{ secrets.STAFF_SEED_EMAIL }}
STAFF_SEED_PASSWORD: ${{ secrets.STAFF_SEED_PASSWORD }}
RESERVATIONS_FROM_EMAIL: ${{ secrets.RESERVATIONS_FROM_EMAIL }}
IMAGE_TAG: ${{ github.sha }}
run: | run: |
npx drizzle-kit generate set -euo pipefail
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 mkdir -p ~/.ssh
run: npm run db:migrate
- name: Seed baseline data printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
run: npm run db:seed chmod 600 ~/.ssh/deploy_key
- name: Build ssh-keyscan \
run: npm run build -p "${DEPLOY_PORT:-22}" \
-H "$DEPLOY_HOST" \
>> ~/.ssh/known_hosts
- name: Smoke test the production build echo "Creating deployment directory if needed..."
run: |
npx vite preview --port 4173 &
PREVIEW_PID=$!
for i in $(seq 1 30); do ssh \
curl -sf http://localhost:4173/ >/dev/null 2>&1 && break -i ~/.ssh/deploy_key \
sleep 1 -p "${DEPLOY_PORT:-22}" \
done "$DEPLOY_USER@$DEPLOY_HOST" \
"mkdir -p '$DEPLOY_PATH'"
check() { echo "Uploading compose file..."
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 scp \
check /staff/login 200 -i ~/.ssh/deploy_key \
check /impressum 200 -P "${DEPLOY_PORT:-22}" \
docker-compose.prod.yml \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
kill "$PREVIEW_PID" echo "Deploying..."
ssh \
-i ~/.ssh/deploy_key \
-p "${DEPLOY_PORT:-22}" \
"$DEPLOY_USER@$DEPLOY_HOST" \
"POSTGRES_PASSWORD=$(printf '%q' "$POSTGRES_PASSWORD") \
BETTER_AUTH_SECRET=$(printf '%q' "$BETTER_AUTH_SECRET") \
BETTER_AUTH_URL=$(printf '%q' "$BETTER_AUTH_URL") \
RESEND_API_KEY=$(printf '%q' "$RESEND_API_KEY") \
RESERVATIONS_FROM_EMAIL=$(printf '%q' "$RESERVATIONS_FROM_EMAIL") \
STAFF_SEED_EMAIL=$(printf '%q' "$STAFF_SEED_EMAIL") \
STAFF_SEED_PASSWORD=$(printf '%q' "$STAFF_SEED_PASSWORD") \
IMAGE_TAG=$(printf '%q' "$IMAGE_TAG") \
bash -c '
set -euo pipefail
cd \"$DEPLOY_PATH\"
echo \"Pulling images...\"
docker compose pull
echo \"Starting PostgreSQL...\"
docker compose up -d postgres
echo "Running database migrations..."
docker compose run --rm app npx drizzle-kit migrate
echo "Seeding database..."
docker compose run --rm app npm run db:seed
echo "Starting application..."
docker compose up -d
echo \"Cleaning unused images...\"
docker image prune -f
echo \"Deployment complete.\"
'"
+31
View File
@@ -0,0 +1,31 @@
# syntax=docker/dockerfile:1
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
# Dummy values so the build-time env checks in src/db/index.ts and
# src/lib/auth-instance.ts don't throw while bundling. The real values are
# supplied at container runtime.
ENV DATABASE_URL=postgres://build:build@localhost:5432/build \
BETTER_AUTH_SECRET=build-time-placeholder \
BETTER_AUTH_URL=http://localhost:3000
RUN npm run build
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# This build has no standalone Node server entrypoint — dist/server/server.js
# only exports a fetch handler. `vite preview` (via the tanstackStart plugin)
# is the supported way to serve it, and it re-resolves the app's router entry
# from src/ at startup, so the full source tree (not just dist/) has to ship.
COPY --from=builder /app ./
EXPOSE 3000
CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "3000"]
+35
View File
@@ -0,0 +1,35 @@
services:
app:
image: git.lukas-harat.com/lukas/promo_steakhouse:${IMAGE_TAG:-latest}
restart: unless-stopped
ports:
- '3003:3000'
environment:
DATABASE_URL: postgres://steakhouse:${POSTGRES_PASSWORD}@postgres:5432/steakhouse
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
BETTER_AUTH_URL: ${BETTER_AUTH_URL}
STAFF_SEED_EMAIL: ${STAFF_SEED_EMAIL}
STAFF_SEED_PASSWORD: ${STAFF_SEED_PASSWORD}
RESEND_API_KEY: ${RESEND_API_KEY}
RESERVATIONS_FROM_EMAIL: ${RESERVATIONS_FROM_EMAIL}
depends_on:
postgres:
condition: service_healthy
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_USER: steakhouse
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: steakhouse
volumes:
- steakhouse-postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U steakhouse -d steakhouse"]
interval: 5s
timeout: 5s
retries: 10
volumes:
steakhouse-postgres-data:
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "restaurant_tables" ADD COLUMN "image_url" text;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "restaurant_settings" ADD COLUMN "table_display_mode" text DEFAULT 'individual' NOT NULL;--> statement-breakpoint
ALTER TABLE "restaurant_settings" ADD COLUMN "table_overview_image_url" text;
+2
View File
@@ -0,0 +1,2 @@
ALTER TABLE "restaurant_tables" ADD COLUMN "marker_x" double precision;--> statement-breakpoint
ALTER TABLE "restaurant_tables" ADD COLUMN "marker_y" double precision;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "restaurant_tables" ADD COLUMN "table_number" integer;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "reservations" ADD COLUMN "message" text;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "reservations" ADD COLUMN "customer_phone" text;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "user" ADD COLUMN "phone" text;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "reservations" ADD COLUMN "status" text DEFAULT 'confirmed' NOT NULL;
+533
View File
@@ -0,0 +1,533 @@
{
"id": "05eac8a1-b172-4e4d-90d2-a409ecde610a",
"prevId": "d03b9103-cb6a-4968-a016-c04270ec176a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+546
View File
@@ -0,0 +1,546 @@
{
"id": "71bc6bc2-516c-4af1-8c61-3df7814e9976",
"prevId": "05eac8a1-b172-4e4d-90d2-a409ecde610a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+558
View File
@@ -0,0 +1,558 @@
{
"id": "cc3da65b-b162-4866-ac44-b7d177588316",
"prevId": "71bc6bc2-516c-4af1-8c61-3df7814e9976",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+564
View File
@@ -0,0 +1,564 @@
{
"id": "0914058c-53fc-4e93-b703-a907e0ae72a8",
"prevId": "cc3da65b-b162-4866-ac44-b7d177588316",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+570
View File
@@ -0,0 +1,570 @@
{
"id": "c95ecb29-7866-4794-9aad-8a6fba8e7bc4",
"prevId": "0914058c-53fc-4e93-b703-a907e0ae72a8",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+576
View File
@@ -0,0 +1,576 @@
{
"id": "69162a80-7a1b-480b-85c2-7f11e2f1725c",
"prevId": "c95ecb29-7866-4794-9aad-8a6fba8e7bc4",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_phone": {
"name": "customer_phone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+582
View File
@@ -0,0 +1,582 @@
{
"id": "b5488545-3b1f-4145-a565-f4fcdc5b3748",
"prevId": "69162a80-7a1b-480b-85c2-7f11e2f1725c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_phone": {
"name": "customer_phone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+589
View File
@@ -0,0 +1,589 @@
{
"id": "b91348a4-3a6f-4c6b-a7a0-c60c06eeb9f9",
"prevId": "b5488545-3b1f-4145-a565-f4fcdc5b3748",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_phone": {
"name": "customer_phone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'confirmed'"
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+56
View File
@@ -29,6 +29,62 @@
"when": 1787646264161, "when": 1787646264161,
"tag": "0003_big_jack_flag", "tag": "0003_big_jack_flag",
"breakpoints": true "breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1787683584543,
"tag": "0004_wide_thunderbolt",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1787684215918,
"tag": "0005_nice_stark_industries",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1787684576667,
"tag": "0006_uneven_captain_cross",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1787685540508,
"tag": "0007_dusty_payback",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1787686033197,
"tag": "0008_even_toxin",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1787686357931,
"tag": "0009_nosy_alice",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1787688761615,
"tag": "0010_common_masque",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1787690760361,
"tag": "0011_cloudy_gwen_stacy",
"breakpoints": true
} }
] ]
} }
+278
View File
@@ -10,6 +10,7 @@
"@tanstack/react-start": "latest", "@tanstack/react-start": "latest",
"better-auth": "^1.6.29", "better-auth": "^1.6.29",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"pdfjs-dist": "^6.2.108",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
@@ -1310,6 +1311,271 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@napi-rs/canvas": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.8.tgz",
"integrity": "sha512-/SaLcvlqGWdm0HSCWMgHu7cjJiQXfP8/mOY+6dUyV9flQz7sPBBZ+ed2zYtoukojPmxOaL7bm+d/G4GeWWoN7g==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.8",
"@napi-rs/canvas-darwin-arm64": "1.0.8",
"@napi-rs/canvas-darwin-x64": "1.0.8",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.8",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.8",
"@napi-rs/canvas-linux-arm64-musl": "1.0.8",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.8",
"@napi-rs/canvas-linux-x64-gnu": "1.0.8",
"@napi-rs/canvas-linux-x64-musl": "1.0.8",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.8",
"@napi-rs/canvas-win32-x64-msvc": "1.0.8"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.8.tgz",
"integrity": "sha512-5+nkh8i3gt6lqS/d2jTZ1xAn6tdgtB4Lf1mW6T0Qm5/rXNwBuV1sAEyLEWan5o9gJPU/GuvHR3rvSeZ+FaGrbw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.8.tgz",
"integrity": "sha512-7jQ47gi+fZ7KJmfc/5rNyy1CYw/cu4kZ0KPIYbo9UUgSdW0bKQJpt+WihEor6s4Lyp7+xc3a+3HeyXmAEbbnPg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.8.tgz",
"integrity": "sha512-rRjDMZs9pIRKGxgijwezplKc1RnJsqUokrA9h88bbTkqQ+7ePj0ZN4ZnZDy8Vu0tXs7KRlI2tQLaK4mx9QlxHg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.8.tgz",
"integrity": "sha512-jGcCd+8ra6Q61xKqZeiItujTpp9a9eRLcQ0jW6qYNku+WpupqOPFPY0SrsuSnXFviJwkpKYT9p7QrB4lsf3LNQ==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.8.tgz",
"integrity": "sha512-od6I2Y7kU7i1SwZYG2EKW8rWz6JiedtPpko4WEe1DDsiikrfaotVBCRaUTM5/yeZKaZ92EatoAS+5xG+6uJlYA==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.8.tgz",
"integrity": "sha512-yYkPbJDJiWj6N0gASA3CAvRypZmVpJnxU0DQg3aBhneLDQde9TPLKADsQkobNoJUtTT/lj46aWpzT48PDb3Qcg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.8.tgz",
"integrity": "sha512-PB00MSKAp4VwK/xwe6duKxRKmH8UH4GIl1pqHSbxng0jnU9Dr7FwaDypDiqwNFZ774N+8G7mJLGuLtg9NTcQsg==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.8.tgz",
"integrity": "sha512-TWM2XWJoitLiIPCvgJh7SriC+L/T9qkYCVzC66AidsZy0QP1hkKzBzVwshCdcA3q6fIn3yE0ISbq4lMJSy8jFw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.8.tgz",
"integrity": "sha512-hb20MxKXXb5IB7AAwN8UHz9WRsa2HmdZfjsDCzjElwJoeV1aotVEwFU4FrFQcYQVzsJQLeaCc/2Qdt/0Q72mMg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.8.tgz",
"integrity": "sha512-WwPN08IXE4SkL+FhJyPz/iFnycMAUkbphFIT4cmKLlvbSU0Zfn1R7BGJ3Hqky1S89QUYc0Q4IOScXb/42Re9wQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.8.tgz",
"integrity": "sha512-XkrVqKb+pxyba7kjy2LJvABFVBTE0DNpEl7MrG4OYUmaWarrXH+t54z/Czj2YxCKtizYTV4mg6phNm3x24qjhQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@noble/ciphers": { "node_modules/@noble/ciphers": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.3.0.tgz",
@@ -3732,6 +3998,18 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/pdfjs-dist": {
"version": "6.2.108",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz",
"integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.0"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@tanstack/react-start": "latest", "@tanstack/react-start": "latest",
"better-auth": "^1.6.29", "better-auth": "^1.6.29",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"pdfjs-dist": "^6.2.108",
"postgres": "^3.4.9", "postgres": "^3.4.9",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0", "react-dom": "^19.2.0",
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="14" fill="#17120f"/>
<rect x="2" y="2" width="60" height="60" rx="12" fill="none" stroke="#c9a15a" stroke-width="2"/>
<text x="32" y="44" font-family="Georgia, 'Times New Roman', serif" font-size="34" font-weight="600" text-anchor="middle" fill="#e0bb78">M</text>
</svg>

After

Width:  |  Height:  |  Size: 371 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500"><path d="M 79.47,45.64 C 71.12,45.78 60.80,48.79 55.89,52.64 C 50.98,56.49 48.85,62.75 50.00,68.74 C 51.15,74.74 56.86,81.99 62.81,88.62 C 68.75,95.24 77.19,102.34 85.70,108.49 C 94.20,114.64 104.30,120.74 113.83,125.51 C 123.36,130.27 133.30,134.13 142.89,137.07 C 152.48,140.00 161.90,141.73 171.36,143.12 C 180.83,144.51 190.19,144.97 199.69,145.40 C 209.20,145.83 218.68,145.63 228.38,145.70 C 238.08,145.76 247.88,145.49 257.92,145.78 C 267.95,146.06 278.42,146.34 288.57,147.41 C 298.73,148.47 309.21,149.81 318.83,152.17 C 328.44,154.53 337.98,157.42 346.24,161.57 C 354.50,165.73 362.29,170.66 368.39,177.11 C 374.49,183.56 379.31,191.38 382.83,200.28 C 386.35,209.19 386.77,220.92 389.51,230.55 C 392.24,240.17 395.02,249.65 399.25,258.04 C 403.47,266.43 409.89,273.06 414.85,280.88 C 419.80,288.70 425.82,296.22 428.98,304.97 C 432.14,313.71 433.03,323.66 433.82,333.38 C 434.62,343.09 433.47,353.42 433.75,363.25 C 434.04,373.07 433.68,382.96 435.52,392.31 C 437.35,401.67 440.01,411.11 444.78,419.37 C 449.55,427.63 456.73,436.04 464.14,441.88 C 471.56,447.71 480.75,452.58 489.28,454.36 C 497.81,456.14 507.05,455.10 515.32,452.56 C 523.60,450.02 531.95,444.80 538.93,439.13 C 545.90,433.45 552.25,426.02 557.18,418.50 C 562.11,410.98 565.65,402.67 568.51,394.01 C 571.36,385.34 572.93,376.03 574.30,366.50 C 575.67,356.97 575.77,346.70 576.73,336.83 C 577.68,326.96 577.97,316.66 580.02,307.28 C 582.06,297.89 584.88,288.90 588.99,280.55 C 593.09,272.19 599.37,265.01 604.64,257.15 C 609.90,249.28 616.07,241.81 620.59,233.35 C 625.11,224.89 628.08,215.26 631.74,206.39 C 635.41,197.51 637.83,187.81 642.59,180.10 C 647.35,172.40 652.72,165.26 660.30,160.18 C 667.89,155.10 678.09,152.07 688.10,149.61 C 698.10,147.15 709.79,146.33 720.34,145.43 C 730.89,144.53 741.33,144.43 751.41,144.20 C 761.48,143.97 771.14,144.17 780.77,144.04 C 790.40,143.91 799.75,143.96 809.20,143.44 C 818.65,142.91 827.94,142.31 837.47,140.89 C 846.99,139.47 856.56,137.70 866.35,134.89 C 876.14,132.08 886.65,128.54 896.23,124.04 C 905.80,119.54 915.88,114.14 923.78,107.87 C 931.68,101.61 939.26,94.04 943.63,86.46 C 948.00,78.89 951.03,69.06 950.00,62.42 C 948.97,55.78 943.45,49.15 937.43,46.64 C 931.42,44.12 922.45,45.15 913.92,47.34 C 905.39,49.53 895.66,55.24 886.23,59.77 C 876.79,64.29 866.85,70.36 857.32,74.49 C 847.79,78.63 838.46,82.23 829.04,84.56 C 819.61,86.89 810.31,88.20 800.77,88.46 C 791.24,88.71 781.58,87.55 771.84,86.11 C 762.10,84.67 752.23,82.10 742.35,79.81 C 732.47,77.52 722.50,74.57 712.58,72.36 C 702.65,70.15 692.68,67.75 682.79,66.54 C 672.91,65.34 663.05,64.73 653.27,65.12 C 643.50,65.52 633.80,67.12 624.15,68.91 C 614.49,70.71 604.90,73.41 595.33,75.88 C 585.76,78.36 576.24,81.36 566.73,83.76 C 557.21,86.17 547.73,88.72 538.24,90.29 C 528.75,91.86 519.27,93.10 509.77,93.19 C 500.26,93.28 490.75,92.26 481.22,90.82 C 471.68,89.39 462.13,86.92 452.56,84.58 C 442.98,82.23 433.39,79.25 423.76,76.77 C 414.14,74.29 404.50,71.56 394.82,69.72 C 385.14,67.88 375.44,66.28 365.70,65.75 C 355.96,65.22 346.18,65.68 336.39,66.54 C 326.60,67.41 316.78,69.20 306.95,70.92 C 297.12,72.64 287.26,74.96 277.41,76.86 C 267.56,78.77 257.70,80.94 247.85,82.35 C 238.00,83.77 228.13,85.07 218.32,85.38 C 208.51,85.68 198.66,85.47 188.99,84.19 C 179.32,82.91 169.69,80.84 160.31,77.69 C 150.93,74.54 141.76,69.61 132.71,65.30 C 123.65,60.99 114.85,55.10 105.97,51.83 C 97.10,48.55 87.82,45.51 79.47,45.64 Z" fill="#7B2D2F"/></svg>

After

Width:  |  Height:  |  Size: 3.4 KiB

BIN
View File
Binary file not shown.
+108
View File
@@ -0,0 +1,108 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
type Props = {
label?: string
children: React.ReactNode
}
function DotsIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="5" r="1.8" />
<circle cx="12" cy="12" r="1.8" />
<circle cx="12" cy="19" r="1.8" />
</svg>
)
}
type Position = { top: number; right: number }
// A small "⋮" menu that reveals its children (typically a stack of `.btn`s) in a
// dropdown — used to tuck row actions away instead of listing every button inline.
// The panel is portaled to <body> and positioned with `position: fixed` from a
// measured rect rather than anchored with `position: absolute` in place, since
// rows live inside `.table-scroll` (`overflow-x: auto`, which per the CSS spec
// forces `overflow-y` to clip too) — an in-place dropdown on the last row or two
// would otherwise get cut off at the table's bottom edge.
export function ActionMenu({ label = 'Aktionen', children }: Props) {
const [position, setPosition] = useState<Position | null>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const open = position !== null
function close() {
setPosition(null)
}
function toggle() {
if (open) {
close()
return
}
const rect = triggerRef.current?.getBoundingClientRect()
if (!rect) return
setPosition({ top: rect.bottom + 4, right: window.innerWidth - rect.right })
}
useEffect(() => {
if (!open) return
function handleOutsideClick(event: MouseEvent) {
const target = event.target as Node
if (!triggerRef.current?.contains(target) && !panelRef.current?.contains(target)) {
close()
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') close()
}
// The menu's position is measured once, on open — rather than track scroll to
// keep it glued to the trigger, just close it, matching how most native and
// web dropdowns behave when the page moves under them.
function handleScroll() {
close()
}
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleKeyDown)
window.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('scroll', handleScroll, true)
}
}, [open])
return (
<>
<button
ref={triggerRef}
type="button"
className="action-menu-trigger"
aria-label={label}
aria-haspopup="true"
aria-expanded={open}
onClick={toggle}
>
<DotsIcon />
</button>
{open &&
position &&
createPortal(
<div
ref={panelRef}
className="action-menu-panel"
role="menu"
style={{ top: position.top, right: position.right }}
onClick={close}
>
{children}
</div>,
document.body,
)}
</>
)
}
+27 -1
View File
@@ -3,7 +3,11 @@ import { useEffect, useRef } from 'react'
type Props = { type Props = {
open: boolean open: boolean
title: string title: string
message: string message: React.ReactNode
// A final, visually distinct callout for what irreversible actions this
// confirms — kept separate from `message` so callers don't have to hand-roll
// the emphasis every time (e.g. "This cannot be undone.").
warning?: React.ReactNode
confirmLabel?: string confirmLabel?: string
cancelLabel?: string cancelLabel?: string
danger?: boolean danger?: boolean
@@ -11,10 +15,26 @@ type Props = {
onCancel: () => void onCancel: () => void
} }
function WarningIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M12 3.5 21.5 20h-19L12 3.5Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M12 9.5v4.2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="12" cy="16.7" r="1" fill="currentColor" />
</svg>
)
}
export function ConfirmDialog({ export function ConfirmDialog({
open, open,
title, title,
message, message,
warning,
confirmLabel = 'Bestätigen', confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen', cancelLabel = 'Abbrechen',
danger, danger,
@@ -54,6 +74,12 @@ export function ConfirmDialog({
<p id="confirm-dialog-message" className="confirm-dialog-message"> <p id="confirm-dialog-message" className="confirm-dialog-message">
{message} {message}
</p> </p>
{warning && (
<div className="confirm-dialog-warning">
<WarningIcon />
<span>{warning}</span>
</div>
)}
<div className="confirm-dialog-actions"> <div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onCancel}> <button type="button" className="btn btn-secondary" onClick={onCancel}>
{cancelLabel} {cancelLabel}
+191
View File
@@ -0,0 +1,191 @@
import { useEffect, useRef, useState } from 'react'
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'
type Props = {
src: string
}
// pdfjs-dist touches the DOM/Canvas at import time, so it's loaded lazily inside
// the effect below instead of at module scope — this file also runs during SSR.
export function PdfMenu({ src }: Props) {
const containerRef = useRef<HTMLDivElement>(null)
const canvasRef = useRef<HTMLCanvasElement>(null)
const pdfRef = useRef<PDFDocumentProxy | null>(null)
const directionRef = useRef(1)
const hasRenderedOnceRef = useRef(false)
const [error, setError] = useState(false)
const [numPages, setNumPages] = useState(0)
const [pageNumber, setPageNumber] = useState(1)
const [isBusy, setIsBusy] = useState(false)
function goToPage(next: number) {
directionRef.current = next > pageNumber ? 1 : -1
setPageNumber(next)
}
useEffect(() => {
let cancelled = false
setError(false)
setNumPages(0)
setPageNumber(1)
pdfRef.current = null
hasRenderedOnceRef.current = false
async function load() {
const pdfjsLib = await import('pdfjs-dist')
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).href
try {
const pdf = await pdfjsLib.getDocument({ url: src }).promise
if (cancelled) return
pdfRef.current = pdf
setNumPages(pdf.numPages)
} catch (err) {
console.error('PdfMenu failed to load', err)
if (!cancelled) setError(true)
}
}
load()
return () => {
cancelled = true
}
}, [src])
useEffect(() => {
const pdf = pdfRef.current
const canvas = canvasRef.current
const container = containerRef.current
if (!pdf || !canvas || !container || numPages === 0) return
let cancelled = false
let renderTask: RenderTask | undefined
let exitTimeout: ReturnType<typeof setTimeout> | undefined
function wait(ms: number) {
return new Promise<void>((resolve) => {
exitTimeout = setTimeout(resolve, ms)
})
}
async function render(pdf: PDFDocumentProxy, canvas: HTMLCanvasElement, container: HTMLDivElement) {
const direction = directionRef.current
setIsBusy(true)
if (hasRenderedOnceRef.current) {
canvas.style.transition = 'opacity 0.18s ease, transform 0.18s ease'
canvas.style.opacity = '0'
canvas.style.transform = `translateX(${direction * -2}rem)`
await wait(180)
if (cancelled) return
}
try {
const page = await pdf.getPage(pageNumber)
if (cancelled) return
const unscaledViewport = page.getViewport({ scale: 1 })
const scale = container.clientWidth / unscaledViewport.width
const viewport = page.getViewport({ scale })
const pixelRatio = window.devicePixelRatio || 1
canvas.width = viewport.width * pixelRatio
canvas.height = viewport.height * pixelRatio
canvas.style.width = `${viewport.width}px`
canvas.style.height = `${viewport.height}px`
const context = canvas.getContext('2d')
if (!context) return
context.scale(pixelRatio, pixelRatio)
renderTask = page.render({ canvasContext: context, viewport, canvas })
await renderTask.promise
if (cancelled) return
// Jump to the entry offset with transitions off, then force the browser to
// commit that position before re-enabling them — otherwise the two style
// writes land in the same frame and the entry animation never paints.
canvas.style.transition = 'none'
canvas.style.transform = `translateX(${direction * 2}rem)`
canvas.offsetHeight
canvas.style.transition = 'opacity 0.22s ease, transform 0.22s ease'
canvas.style.opacity = '1'
canvas.style.transform = 'translateX(0)'
hasRenderedOnceRef.current = true
} catch (err) {
if (!cancelled) {
console.error('PdfMenu failed to render page', err)
setError(true)
}
} finally {
if (!cancelled) setIsBusy(false)
}
}
render(pdf, canvas, container)
return () => {
cancelled = true
clearTimeout(exitTimeout)
renderTask?.cancel()
}
}, [numPages, pageNumber])
if (error) {
return (
<p className="field-hint">
Die Speisekarte konnte nicht geladen werden.{' '}
<a href={src} target="_blank" rel="noopener noreferrer">
Als PDF öffnen
</a>
.
</p>
)
}
return (
<div className="pdf-menu" ref={containerRef}>
<div className="pdf-menu-viewport">
<canvas className="pdf-menu-page" ref={canvasRef} />
{numPages === 0 && (
<div className="pdf-menu-loading" role="status">
<div className="pdf-menu-spinner" aria-hidden="true" />
<p>Die Speisekarte wird aufgetischt …</p>
</div>
)}
</div>
{numPages > 1 && (
<div className="pdf-menu-pagination">
<button
type="button"
className="btn btn-secondary"
onClick={() => goToPage(pageNumber - 1)}
disabled={isBusy || pageNumber <= 1}
>
Zurück
</button>
<span>
Seite {pageNumber} von {numPages}
</span>
<button
type="button"
className="btn btn-secondary"
onClick={() => goToPage(pageNumber + 1)}
disabled={isBusy || pageNumber >= numPages}
>
Weiter
</button>
</div>
)}
<p className="field-hint">
Speisekarte nicht geladen?{' '}
<a href={src} target="_blank" rel="noopener noreferrer">
Klick hier
</a>
</p>
</div>
)
}
@@ -0,0 +1,108 @@
import { useEffect, useRef } from 'react'
type Props = {
open: boolean
customerName: string
customerEmail: string
tableName: string
date: string
startTime: string
endTime: string
partySize: number
onClose: () => void
}
function FlameBadgeIcon() {
return (
<svg className="reservation-confirmed-flame" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path
d="M12 2c1 3-3 4-3 7.5A3.5 3.5 0 0 0 12 13a3.5 3.5 0 0 0 3-5.3c1.5 1 2.5 3 2.5 5a5.5 5.5 0 1 1-11 0C6.5 8.5 9.5 6 12 2Z"
stroke="currentColor"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
)
}
export function ReservationConfirmedModal({
open,
customerName,
customerEmail,
tableName,
date,
startTime,
endTime,
partySize,
onClose,
}: Props) {
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
closeRef.current?.focus()
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-confirmed-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="reservation-confirmed-title"
onClick={(event) => event.stopPropagation()}
>
<FlameBadgeIcon />
<h2 id="reservation-confirmed-title" className="reservation-confirmed-title">
Tisch reserviert!
</h2>
<p className="reservation-confirmed-message">
Bis bald, {customerName}! Wir halten Ihnen den Platz warm und legen schon mal Kohle nach.
</p>
<dl className="detail-list">
<div>
<dt>Tisch</dt>
<dd>{tableName}</dd>
</div>
<div>
<dt>Datum</dt>
<dd>{date}</dd>
</div>
<div>
<dt>Uhrzeit</dt>
<dd>
{startTime} – {endTime}
</dd>
</div>
<div>
<dt>Personen</dt>
<dd>{partySize}</dd>
</div>
</dl>
<p className="reservation-confirmed-footnote">
Eine Bestätigung ist unterwegs an <strong>{customerEmail}</strong>. Dort finden Sie auch einen
Link, falls sich doch noch etwas ändert.
</p>
<div className="confirm-dialog-actions">
<button ref={closeRef} type="button" className="btn btn-primary" onClick={onClose}>
Alles klar
</button>
</div>
</div>
</div>
)
}
+86 -39
View File
@@ -14,6 +14,9 @@ type Props = {
// made — both configurable restaurant settings, falling back to sane defaults. // made — both configurable restaurant settings, falling back to sane defaults.
intervalMinutes?: number intervalMinutes?: number
minLeadTimeMinutes?: number minLeadTimeMinutes?: number
// How long a reservation booked at a slot occupies the table — a slot too close
// to closing time to fit the whole reservation is left out of the list entirely.
durationMinutes?: number
// How far into the future a booking may be made. Undefined leaves the horizon open, // 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. // since not every caller of this field needs the restriction.
maxLeadTimeDays?: number maxLeadTimeDays?: number
@@ -101,6 +104,7 @@ export function ReservationDateTimeField({
bookedTimes = [], bookedTimes = [],
intervalMinutes = RESERVATION_INTERVAL_MINUTES, intervalMinutes = RESERVATION_INTERVAL_MINUTES,
minLeadTimeMinutes = 0, minLeadTimeMinutes = 0,
durationMinutes = RESERVATION_INTERVAL_MINUTES,
maxLeadTimeDays, maxLeadTimeDays,
}: Props) { }: Props) {
const { date, time } = splitValue(value) const { date, time } = splitValue(value)
@@ -133,9 +137,44 @@ export function ReservationDateTimeField({
document.addEventListener('mousedown', handleOutsideClick) document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleKeyDown) document.addEventListener('keydown', handleKeyDown)
// Only locks the background on mobile, where the popover becomes a fixed,
// full-screen overlay that would otherwise let the page behind it scroll —
// on desktop the popover is just a small anchored dropdown, and pinning the
// body there only removes the scrollbar and shifts the whole page sideways.
// Matches the `@media (max-width: 30rem)` breakpoint the popover-to-modal
// CSS switches on.
const isMobile = window.matchMedia('(max-width: 30rem)').matches
if (!isMobile) {
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
}
}
// `overflow: hidden` alone doesn't stop touch-driven rubber-band scrolling on
// iOS Safari, so the body is pinned in place instead and the scroll position
// restored after.
const scrollY = window.scrollY
const body = document.body.style
const previousStyle = { position: body.position, top: body.top, left: body.left, right: body.right }
body.position = 'fixed'
body.top = `-${scrollY}px`
body.left = '0'
body.right = '0'
return () => { return () => {
document.removeEventListener('mousedown', handleOutsideClick) document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown) document.removeEventListener('keydown', handleKeyDown)
body.position = previousStyle.position
body.top = previousStyle.top
body.left = previousStyle.left
body.right = previousStyle.right
// `behavior: 'instant'` is required, not just the default — the page sets
// `scroll-behavior: smooth` globally, which would otherwise animate this
// restore from the top of the page instead of snapping back in place.
window.scrollTo({ top: scrollY, left: 0, behavior: 'instant' })
} }
// Only re-run when the popover opens/closes — reopening should reset to the // Only re-run when the popover opens/closes — reopening should reset to the
// currently selected month, not follow every keystroke in the text field. // currently selected month, not follow every keystroke in the text field.
@@ -154,7 +193,7 @@ export function ReservationDateTimeField({
// otherwise) can't slip through with a leftover, still-"valid"-looking time. // otherwise) can't slip through with a leftover, still-"valid"-looking time.
function optionsForDate(dateStr: string) { function optionsForDate(dateStr: string) {
if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return [] if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return []
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes) return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes, durationMinutes)
} }
function isSelectableDate(dateStr: string) { function isSelectableDate(dateStr: string) {
@@ -250,45 +289,53 @@ export function ReservationDateTimeField({
</button> </button>
{isCalendarOpen && ( {isCalendarOpen && (
<div className="date-popover" role="dialog" aria-label="Datum auswählen"> <div className="date-popover-backdrop" onClick={() => setIsCalendarOpen(false)}>
<div className="date-popover-header"> <div
<button className="date-popover"
type="button" role="dialog"
aria-label="Vorheriger Monat" aria-modal="true"
disabled={isAtEarliestMonth} aria-label="Datum auswählen"
onClick={() => goToMonth(-1)} onClick={(event) => event.stopPropagation()}
> >
‹ <div className="date-popover-header">
</button> <button
<span>{monthLabel}</span> type="button"
<button aria-label="Vorheriger Monat"
type="button" disabled={isAtEarliestMonth}
aria-label="Nächster Monat" onClick={() => goToMonth(-1)}
disabled={isAtLatestMonth} >
onClick={() => goToMonth(1)} ‹
> </button>
› <span>{monthLabel}</span>
</button> <button
</div> type="button"
<div className="date-popover-weekdays"> aria-label="Nächster Monat"
{WEEKDAY_HEADER_LABELS.map((label) => ( disabled={isAtLatestMonth}
<span key={label}>{label}</span> onClick={() => goToMonth(1)}
))} >
</div> ›
<div className="date-popover-grid"> </button>
{getMonthGrid(view.year, view.month) </div>
.flat() <div className="date-popover-weekdays">
.map((cell, index) => ( {WEEKDAY_HEADER_LABELS.map((label) => (
<button <span key={label}>{label}</span>
key={cell?.dateStr ?? `blank-${index}`}
type="button"
className={cell?.dateStr === date ? 'is-selected' : undefined}
disabled={!cell || !isSelectableDate(cell.dateStr)}
onClick={() => cell && handleDayPick(cell.dateStr)}
>
{cell?.day ?? ''}
</button>
))} ))}
</div>
<div className="date-popover-grid">
{getMonthGrid(view.year, view.month)
.flat()
.map((cell, index) => (
<button
key={cell?.dateStr ?? `blank-${index}`}
type="button"
className={cell?.dateStr === date ? 'is-selected' : undefined}
disabled={!cell || !isSelectableDate(cell.dateStr)}
onClick={() => cell && handleDayPick(cell.dateStr)}
>
{cell?.day ?? ''}
</button>
))}
</div>
</div> </div>
</div> </div>
)} )}
+456
View File
@@ -0,0 +1,456 @@
import { useEffect, useState } from 'react'
import { ActionMenu } from '#/components/action-menu'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
import {
RESERVATION_STATUS_LABELS,
type ReservationStatus,
type getReservations,
type getTables,
} from '#/lib/reservations'
import { type getReservationSettings } from '#/lib/settings'
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
type SettingsRow = Awaited<ReturnType<typeof getReservationSettings>>
export type ReservationPayload = {
tableId: number
customerName: string
customerEmail: string
customerPhone: string
message: string
partySize: number
reservationTime: string
}
type FormState = {
tableId: number | string
customerName: string
customerEmail: string
customerPhone: string
message: string
partySize: number
reservationTime: string
}
type Props = {
// `null` reservation with `open: true` is "create" mode.
open: boolean
reservation: ReservationRow | null
tables: TableRow[]
settings: SettingsRow
reservations: ReservationRow[]
onClose: () => void
onCreate: (payload: ReservationPayload) => Promise<void>
onUpdate: (id: number, payload: ReservationPayload) => Promise<void>
onStatusChange: (status: ReservationStatus) => void
isUpdatingStatus?: boolean
onDelete: () => void
statusError?: string | null
}
const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[]
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'full',
})
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short',
})
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
function emptyForm(defaultTableId: number | string, settings: SettingsRow): FormState {
const slot = getNextAvailableSlot(
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
)
return {
tableId: defaultTableId,
customerName: '',
customerEmail: '',
customerPhone: '',
message: '',
partySize: 2,
reservationTime: slot ? `${slot.date}T${slot.time}` : '',
}
}
function formFromReservation(reservation: ReservationRow): FormState {
return {
tableId: reservation.tableId,
customerName: reservation.customerName,
customerEmail: reservation.customerEmail,
customerPhone: reservation.customerPhone ?? '',
message: reservation.message ?? '',
partySize: reservation.partySize,
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
}
}
// Rendered with a `key` (see staff.index.tsx) so a fresh instance mounts every time
// the dialog opens — that's what lets this component safely derive its initial
// state from props once, instead of syncing it via effects.
export function ReservationDialog({
open,
reservation,
tables,
settings,
reservations,
onClose,
onCreate,
onUpdate,
onStatusChange,
isUpdatingStatus,
onDelete,
statusError,
}: Props) {
const [isEditing, setIsEditing] = useState(reservation === null)
const [form, setForm] = useState<FormState>(() =>
reservation ? formFromReservation(reservation) : emptyForm(tables[0]?.id ?? '', settings),
)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose])
if (!open) return null
// 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. `minLeadTimeMinutes` is reused here as a
// table turnover buffer, extending the taken window on both sides so back-to-back
// bookings on the same table always leave that much of a gap.
const [selectedDate] = form.reservationTime.split('T')
const otherReservationsOnSelection = reservations.filter(
(r) =>
r.id !== reservation?.id &&
String(r.tableId) === String(form.tableId) &&
formatDateOnly(new Date(r.reservationTime)) === selectedDate,
)
const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
const bookedTimesForForm = selectedDate
? generateTimeOptions(
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
).filter((option) => {
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
return otherReservationsOnSelection.some((r) => {
const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
return slotStart >= otherStart && slotStart < otherEnd
})
})
: []
// Mirrors the form fields' own `required`/`type`/`pattern`/`min`/`max` constraints,
// so the submit button is disabled until they'd all actually pass — instead of only
// catching an invalid table/time on submit and leaving everything else to the
// browser's native validation popup.
const isFormValid =
Boolean(form.tableId) &&
form.customerName.trim().length > 0 &&
EMAIL_PATTERN.test(form.customerEmail) &&
PHONE_PATTERN.test(form.customerPhone) &&
form.partySize >= 1 &&
form.partySize <= 20 &&
Boolean(form.reservationTime.split('T')[1])
function startEditing() {
if (reservation) setForm(formFromReservation(reservation))
setError(null)
setIsEditing(true)
}
function cancelEditing() {
if (!reservation) {
onClose()
return
}
setForm(formFromReservation(reservation))
setError(null)
setIsEditing(false)
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
if (!form.tableId) {
setError('Bitte wählen Sie einen Tisch aus')
return
}
if (!form.reservationTime.split('T')[1]) {
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
return
}
setIsSubmitting(true)
try {
const payload: ReservationPayload = {
tableId: Number(form.tableId),
customerName: form.customerName,
customerEmail: form.customerEmail,
customerPhone: form.customerPhone,
message: form.message,
partySize: Number(form.partySize),
reservationTime: new Date(form.reservationTime).toISOString(),
}
if (reservation) {
await onUpdate(reservation.id, payload)
} else {
await onCreate(payload)
}
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
}
const title = reservation
? isEditing
? `Reservierung von ${reservation.customerName} bearbeiten`
: `Reservierung von ${reservation.customerName}`
: 'Neue Reservierung'
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className={`confirm-dialog reservation-detail-dialog${isEditing ? ' reservation-dialog-editing' : ''}`}
role="dialog"
aria-modal="true"
aria-labelledby="reservation-dialog-title"
onClick={(event) => event.stopPropagation()}
>
<div className="confirm-dialog-header">
<h2 id="reservation-dialog-title" className="confirm-dialog-title">
{title}
</h2>
{reservation && !isEditing && (
<div className="confirm-dialog-header-actions">
<ActionMenu label={`Weitere Aktionen für Reservierung von ${reservation.customerName}`}>
<button type="button" className="btn btn-danger" onClick={onDelete}>
Löschen
</button>
</ActionMenu>
</div>
)}
</div>
{isEditing ? (
<form className="reservation-form reservation-form-dialog" onSubmit={handleSubmit}>
<label>
Tisch
<select
value={form.tableId}
onChange={(e) => setForm((f) => ({ ...f, tableId: e.target.value }))}
required
>
<option value="" disabled>
Tisch auswählen
</option>
{tables.map((table) => (
<option key={table.id} value={table.id}>
{table.name} ({table.seats} Plätze)
</option>
))}
</select>
</label>
<div className="form-row">
<label>
Name
<input
type="text"
value={form.customerName}
onChange={(e) => setForm((f) => ({ ...f, customerName: e.target.value }))}
required
/>
</label>
<label>
E-Mail
<input
type="email"
value={form.customerEmail}
onChange={(e) => setForm((f) => ({ ...f, customerEmail: e.target.value }))}
required
/>
</label>
</div>
<div className="form-row">
<label>
Telefon
<input
type="tel"
inputMode="tel"
pattern={PHONE_PATTERN_SOURCE}
title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={form.customerPhone}
onChange={(e) => setForm((f) => ({ ...f, customerPhone: e.target.value }))}
required
/>
</label>
<label>
Anzahl Personen
<input
type="number"
inputMode="numeric"
min={1}
max={20}
value={form.partySize}
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
required
/>
</label>
</div>
<label>
Datum &amp; Uhrzeit
<ReservationDateTimeField
value={form.reservationTime}
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
durationMinutes={settings.reservationDurationMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
<label>
Nachricht (optional)
<textarea
rows={2}
maxLength={500}
value={form.message}
onChange={(e) => setForm((f) => ({ ...f, message: e.target.value }))}
/>
</label>
{error && <p className="message message-error">{error}</p>}
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={cancelEditing}>
Abbrechen
</button>
<button type="submit" className="btn btn-primary" disabled={isSubmitting || !isFormValid}>
{isSubmitting ? 'Wird gespeichert …' : reservation ? 'Speichern' : 'Anlegen'}
</button>
</div>
</form>
) : (
reservation && (
<>
<div className="status-options">
{STATUS_OPTIONS.map((status) => (
<button
key={status}
type="button"
className={`status-badge status-option status-${status}${
reservation.status === status ? ' is-active' : ''
}`}
disabled={isUpdatingStatus}
onClick={() => onStatusChange(status)}
>
{RESERVATION_STATUS_LABELS[status]}
</button>
))}
</div>
<dl className="detail-list">
{reservation.tableNumber !== null && (
<div>
<dt>Tischnummer</dt>
<dd>{reservation.tableNumber}</dd>
</div>
)}
<div>
<dt>Tisch</dt>
<dd>{reservation.tableName}</dd>
</div>
<div>
<dt>Datum</dt>
<dd>{dateFormatter.format(new Date(reservation.reservationTime))}</dd>
</div>
<div>
<dt>Start</dt>
<dd>{timeFormatter.format(new Date(reservation.reservationTime))}</dd>
</div>
<div>
<dt>Ende</dt>
<dd>
{timeFormatter.format(
new Date(new Date(reservation.reservationTime).getTime() + reservation.durationMinutes * 60_000),
)}
</dd>
</div>
<div>
<dt>Personen</dt>
<dd>{reservation.partySize}</dd>
</div>
<div>
<dt>E-Mail</dt>
<dd>
<a href={`mailto:${reservation.customerEmail}`}>{reservation.customerEmail}</a>
</dd>
</div>
{reservation.customerPhone && (
<div>
<dt>Telefon</dt>
<dd>
<a href={`tel:${reservation.customerPhone}`}>{reservation.customerPhone}</a>
</dd>
</div>
)}
</dl>
{reservation.message && (
<div className="detail-message">
<span className="detail-message-label">Nachricht</span>
<p>{reservation.message}</p>
</div>
)}
{statusError && <p className="message message-error">{statusError}</p>}
<div className="confirm-dialog-actions confirm-dialog-actions-split">
<button type="button" className="btn btn-ghost" onClick={startEditing}>
Bearbeiten
</button>
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</>
)
)}
</div>
</div>
)
}
@@ -0,0 +1,83 @@
import { useEffect } from 'react'
import type { listStaffMembers } from '#/lib/staff'
type StaffMember = Awaited<ReturnType<typeof listStaffMembers>>[number]
type Props = {
member: StaffMember | null
onClose: () => void
}
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
})
export function StaffMemberDetailDialog({ member, onClose }: Props) {
useEffect(() => {
if (!member) return
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [member, onClose])
if (!member) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-detail-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="staff-member-detail-title"
onClick={(event) => event.stopPropagation()}
>
<h2 id="staff-member-detail-title" className="confirm-dialog-title">
{member.name}
</h2>
<dl className="detail-list">
<div>
<dt>E-Mail</dt>
<dd>
<a href={`mailto:${member.email}`}>{member.email}</a>
</dd>
</div>
<div>
<dt>Telefon</dt>
<dd>{member.phone ? <a href={`tel:${member.phone}`}>{member.phone}</a> : '—'}</dd>
</div>
<div>
<dt>Rolle</dt>
<dd>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{member.banned ? 'Gesperrt' : 'Aktiv'}</dd>
</div>
{member.banned && member.banReason && (
<div>
<dt>Sperrgrund</dt>
<dd>{member.banReason}</dd>
</div>
)}
<div>
<dt>Angelegt am</dt>
<dd>{dateFormatter.format(new Date(member.createdAt))}</dd>
</div>
</dl>
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
)
}
+10
View File
@@ -16,6 +16,16 @@ export function StaffTabs({ role }: Props) {
> >
Reservierungen Reservierungen
</Link> </Link>
{role === 'admin' && (
<Link
className="staff-tab"
to="/staff/tables"
activeProps={{ className: 'is-active' }}
viewTransition
>
Tische
</Link>
)}
{role === 'admin' && ( {role === 'admin' && (
<Link <Link
className="staff-tab" className="staff-tab"
+5
View File
@@ -0,0 +1,5 @@
import { RESERVATION_STATUS_LABELS, type ReservationStatus } from '#/lib/reservations'
export function StatusBadge({ status }: { status: ReservationStatus }) {
return <span className={`status-badge status-${status}`}>{RESERVATION_STATUS_LABELS[status]}</span>
}
+67
View File
@@ -0,0 +1,67 @@
import { useEffect, useRef } from 'react'
type Props = {
open: boolean
title: string
message: React.ReactNode
onClose: () => void
}
function CheckIcon() {
return (
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
<path
d="m8.2 12.3 2.6 2.6 5-5.4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)
}
export function SuccessModal({ open, title, message, onClose }: Props) {
const closeRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
closeRef.current?.focus()
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, onClose])
if (!open) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog success-modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="success-modal-title"
onClick={(event) => event.stopPropagation()}
>
<div className="success-modal-icon">
<CheckIcon />
</div>
<h2 id="success-modal-title" className="confirm-dialog-title">
{title}
</h2>
<p className="confirm-dialog-message">{message}</p>
<div className="confirm-dialog-actions">
<button ref={closeRef} type="button" className="btn btn-primary" onClick={onClose}>
OK
</button>
</div>
</div>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect } from 'react'
import type { getTables } from '#/lib/reservations'
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
type Props = {
table: TableRow | null
onClose: () => void
}
export function TableDetailDialog({ table, onClose }: Props) {
useEffect(() => {
if (!table) return
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [table, onClose])
if (!table) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-detail-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="table-detail-title"
onClick={(event) => event.stopPropagation()}
>
<h2 id="table-detail-title" className="confirm-dialog-title">
{table.name}
</h2>
{table.imageUrl && <img className="table-preview" src={table.imageUrl} alt="" />}
<dl className="detail-list">
<div>
<dt>Tischnummer</dt>
<dd>{table.tableNumber ?? '—'}</dd>
</div>
<div>
<dt>Name</dt>
<dd>{table.name}</dd>
</div>
<div>
<dt>Plätze</dt>
<dd>{table.seats}</dd>
</div>
<div>
<dt>Marker</dt>
<dd>{table.markerX !== null && table.markerY !== null ? 'Gesetzt' : 'Nicht gesetzt'}</dd>
</div>
</dl>
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
)
}
+36 -1
View File
@@ -1,10 +1,24 @@
import { sql } from 'drizzle-orm' import { sql } from 'drizzle-orm'
import { boolean, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core' import { boolean, doublePrecision, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
export const restaurantTables = pgTable('restaurant_tables', { export const restaurantTables = pgTable('restaurant_tables', {
id: serial('id').primaryKey(), id: serial('id').primaryKey(),
name: text('name').notNull(), name: text('name').notNull(),
seats: integer('seats').notNull(), seats: integer('seats').notNull(),
// The number shown to guests (on the table's marker, etc.) — distinct from
// `name`, which can be a description like "Eck Tisch". Nullable so existing
// rows created before this field don't need a value until staff set one.
tableNumber: integer('table_number'),
// A data: URL of the uploaded photo, stored inline rather than as a file on disk —
// there's no separate object storage in this app, and this keeps the image tied
// to the row with no extra infra. Null until staff upload one.
imageUrl: text('image_url'),
// Where this table's pin sits on the settings overview photo, as a percentage
// (0-100) of that image's width/height — resolution-independent, so the same
// marker lines up whether it's rendered in the admin editor or on the public
// booking page. Both null until staff place one; always set together.
markerX: doublePrecision('marker_x'),
markerY: doublePrecision('marker_y'),
}) })
export const reservations = pgTable( export const reservations = pgTable(
@@ -16,7 +30,20 @@ export const reservations = pgTable(
.references(() => restaurantTables.id, { onDelete: 'cascade' }), .references(() => restaurantTables.id, { onDelete: 'cascade' }),
customerName: text('customer_name').notNull(), customerName: text('customer_name').notNull(),
customerEmail: text('customer_email').notNull(), customerEmail: text('customer_email').notNull(),
// Nullable so existing rows created before this field don't need a value —
// required going forward by the create/update zod schema instead.
customerPhone: text('customer_phone'),
// An optional free-text note from whoever booked — allergies, a birthday, a
// seating preference. Null rather than '' when nothing was entered.
message: text('message'),
partySize: integer('party_size').notNull(), partySize: integer('party_size').notNull(),
// 'confirmed' | 'no_show' | 'completed' — an app-level enum (validated by the
// zod schema in lib/reservations.ts, not a pg enum), matching the pattern
// already used for restaurant_settings.table_display_mode. Every reservation
// is confirmed at booking time, so that's the default; staff mark the other
// two afterward. Cancelling still deletes the row (and emails the guest) —
// this doesn't introduce a "cancelled" status.
status: text('status').notNull().default('confirmed'),
reservationTime: timestamp('reservation_time', { withTimezone: true }).notNull(), reservationTime: timestamp('reservation_time', { withTimezone: true }).notNull(),
// Captured from the reservation-duration setting at booking time, so a later // Captured from the reservation-duration setting at booking time, so a later
// change to that setting doesn't retroactively change how long past bookings // change to that setting doesn't retroactively change how long past bookings
@@ -38,6 +65,11 @@ export const restaurantSettings = pgTable('restaurant_settings', {
reservationDurationMinutes: integer('reservation_duration_minutes').notNull().default(90), reservationDurationMinutes: integer('reservation_duration_minutes').notNull().default(90),
minLeadTimeMinutes: integer('min_lead_time_minutes').notNull().default(60), minLeadTimeMinutes: integer('min_lead_time_minutes').notNull().default(60),
maxLeadTimeDays: integer('max_lead_time_days').notNull().default(60), maxLeadTimeDays: integer('max_lead_time_days').notNull().default(60),
// Whether the booking form shows a photo of the selected table ('individual') or
// a single always-visible photo of the whole room ('overview') — an app-level
// enum, validated by the zod schema in lib/settings.ts rather than a pg enum.
tableDisplayMode: text('table_display_mode').notNull().default('individual'),
tableOverviewImageUrl: text('table_overview_image_url'),
}) })
// Better Auth's standard schema (staff accounts) — see https://www.better-auth.com/docs/concepts/database // Better Auth's standard schema (staff accounts) — see https://www.better-auth.com/docs/concepts/database
@@ -54,6 +86,9 @@ export const user = pgTable('user', {
banned: boolean('banned'), banned: boolean('banned'),
banReason: text('ban_reason'), banReason: text('ban_reason'),
banExpires: timestamp('ban_expires'), banExpires: timestamp('ban_expires'),
// Registered with Better Auth as a `user.additionalFields` entry in
// auth-instance.ts — not part of its base schema, same as `role`/`banned` above.
phone: text('phone'),
}) })
export const session = pgTable('session', { export const session = pgTable('session', {
+13
View File
@@ -24,6 +24,19 @@ function createAuth() {
// Staff accounts are provisioned via the seed script, not public self sign-up. // Staff accounts are provisioned via the seed script, not public self sign-up.
disableSignUp: true, disableSignUp: true,
}, },
user: {
additionalFields: {
// `required` here only governs Better Auth's own self-service sign-up/
// update-user flows, which this app doesn't use for staff — accounts go
// through admin.createUser, whose `data` field isn't schema-validated by
// Better Auth, so the actual "required" enforcement lives in the staff
// team UI (staff.team.tsx) instead.
phone: {
type: 'string',
required: true,
},
},
},
session: { session: {
expiresIn: 60 * 60 * 12, expiresIn: 60 * 60 * 12,
}, },
+15
View File
@@ -42,6 +42,7 @@ function getResendClient() {
type ReservationEmailInfo = { type ReservationEmailInfo = {
customerName: string customerName: string
customerEmail: string customerEmail: string
message?: string | null
partySize: number partySize: number
reservationTime: Date reservationTime: Date
cancelToken: string cancelToken: string
@@ -131,6 +132,19 @@ function renderReservationEmailHtml(
</table> </table>
</td> </td>
</tr> </tr>
${
reservation.message
? `
<tr>
<td style="padding-top:16px;">
<div style="color:${COLORS.textMuted};font:400 12px/1.4 'Helvetica Neue',Arial,sans-serif;letter-spacing:0.03em;text-transform:uppercase;padding-bottom:4px;">
Nachricht
</div>
<div style="color:${COLORS.text};font-size:14px;">${escapeHtml(reservation.message)}</div>
</td>
</tr>`
: ''
}
${ ${
showActions showActions
? ` ? `
@@ -190,6 +204,7 @@ function renderReservationEmailText(
`Tisch: ${reservation.table.name}`, `Tisch: ${reservation.table.name}`,
`Datum & Uhrzeit: ${formattedTime}`, `Datum & Uhrzeit: ${formattedTime}`,
`Personen: ${reservation.partySize}`, `Personen: ${reservation.partySize}`,
...(reservation.message ? [`Nachricht: ${reservation.message}`] : []),
'', '',
...(showActions ...(showActions
? [ ? [
+15 -4
View File
@@ -57,12 +57,16 @@ export function isOnReservationInterval(date: Date, intervalMinutes: number = RE
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0 return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
} }
export function isWithinOpeningHours(date: Date) { // `durationMinutes` requires the whole reservation — not just its start — to fit
// before closing, so a slot too close to close time is rejected outright.
export function isWithinOpeningHours(date: Date, durationMinutes = 0) {
const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date) const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date)
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? [] const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
return ranges.some( return ranges.some(
(range) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight < toMinutes(range.end), (range) =>
minutesSinceMidnight >= toMinutes(range.start) &&
minutesSinceMidnight + durationMinutes <= toMinutes(range.end),
) )
} }
@@ -89,9 +93,15 @@ export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
return OPENING_HOURS_BY_WEEKDAY[weekday] ?? [] return OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
} }
// `durationMinutes` (how long a reservation booked at a slot would occupy the
// table) defaults to `intervalMinutes` so a bare call still behaves sensibly —
// but every real caller should pass the restaurant's actual reservation
// duration, so a slot too close to closing time to fit the whole reservation
// is excluded rather than offered and then silently running past close.
export function generateTimeOptions( export function generateTimeOptions(
ranges: OpeningRange[], ranges: OpeningRange[],
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): string[] { ): string[] {
const options: string[] = [] const options: string[] = []
@@ -99,7 +109,7 @@ export function generateTimeOptions(
const startMinutes = toMinutes(range.start) const startMinutes = toMinutes(range.start)
const endMinutes = toMinutes(range.end) const endMinutes = toMinutes(range.end)
for (let minutes = startMinutes; minutes < endMinutes; minutes += intervalMinutes) { for (let minutes = startMinutes; minutes + durationMinutes <= endMinutes; minutes += intervalMinutes) {
const hours = Math.floor(minutes / 60) const hours = Math.floor(minutes / 60)
const remainder = minutes % 60 const remainder = minutes % 60
options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`) options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`)
@@ -112,13 +122,14 @@ export function generateTimeOptions(
export function getNextAvailableSlot( export function getNextAvailableSlot(
from: Date, from: Date,
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): { date: string; time: string } | null { ): { date: string; time: string } | null {
for (let dayOffset = 0; dayOffset < 14; dayOffset++) { for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
const day = new Date(from) const day = new Date(from)
day.setDate(from.getDate() + dayOffset) day.setDate(from.getDate() + dayOffset)
const dateStr = formatDateOnly(day) const dateStr = formatDateOnly(day)
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes) const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes)
for (const time of options) { for (const time of options) {
const slot = new Date(`${dateStr}T${time}`) const slot = new Date(`${dateStr}T${time}`)
+15
View File
@@ -0,0 +1,15 @@
import { z } from 'zod'
// Shared between the customer reservation form (lib/reservations.ts) and the
// staff phone field (routes/staff.team.tsx), so "a valid phone number" means
// the same thing everywhere in the app.
export const phoneSchema = z
.string()
.trim()
.max(30, 'Enter a valid phone number')
// Optional leading `+` plus digits and common separators (spaces, dashes,
// dots, parentheses) — permissive about international formats, but the
// character class alone would still accept e.g. "()---" or "+", so the
// refine below requires it to actually contain a real number of digits.
.regex(/^\+?[0-9()\-.\s]+$/, 'Enter a valid phone number')
.refine((value) => (value.match(/\d/g)?.length ?? 0) >= 6, 'Enter a valid phone number')
+200 -10
View File
@@ -11,7 +11,10 @@ import {
sendReservationUpdateEmail, sendReservationUpdateEmail,
} from '#/lib/email' } from '#/lib/email'
import { isOnReservationInterval, isWithinOpeningHours } from '#/lib/opening-hours' import { isOnReservationInterval, isWithinOpeningHours } from '#/lib/opening-hours'
import { phoneSchema } from '#/lib/phone'
import { zodValidator } from '#/lib/server-validator'
import { loadReservationSettings } from '#/lib/settings' import { loadReservationSettings } from '#/lib/settings'
import { dataUrlImageSchema } from '#/lib/table-image'
function formatLeadTime(minutes: number) { function formatLeadTime(minutes: number) {
if (minutes % 60 === 0) { if (minutes % 60 === 0) {
@@ -26,6 +29,7 @@ function assertValidReservationSlot(
intervalMinutes: number, intervalMinutes: number,
minLeadTimeMinutes: number, minLeadTimeMinutes: number,
maxLeadTimeDays: number, maxLeadTimeDays: number,
durationMinutes: number,
) { ) {
const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000 const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000
@@ -47,28 +51,33 @@ function assertValidReservationSlot(
throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`) throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`)
} }
if (!isWithinOpeningHours(reservationTime)) { if (!isWithinOpeningHours(reservationTime, durationMinutes)) {
throw new Error('Zu dieser Zeit haben wir leider geschlossen') throw new Error('Zu dieser Zeit reicht die verbleibende Zeit bis zum Schließen nicht mehr aus')
} }
} }
// A table is occupied for its own configured duration, not just the exact minute it // 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. // 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 // 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. // older booking keeps whatever duration was in effect when it was made. `bufferMinutes`
// (the current minLeadTimeMinutes setting, reused here as a table turnover gap) is
// added on both sides of the existing reservation, so back-to-back bookings need at
// least that much breathing room between one guest leaving and the next arriving.
async function findOverlappingReservation( async function findOverlappingReservation(
tableId: number, tableId: number,
start: Date, start: Date,
durationMinutes: number, durationMinutes: number,
bufferMinutes: number,
excludeId?: number, excludeId?: number,
) { ) {
const end = start.getTime() + durationMinutes * 60_000 const end = start.getTime() + durationMinutes * 60_000
const bufferMs = bufferMinutes * 60_000
const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId)) const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId))
return candidates.find((r) => { return candidates.find((r) => {
if (excludeId !== undefined && r.id === excludeId) return false if (excludeId !== undefined && r.id === excludeId) return false
const existingStart = r.reservationTime.getTime() const existingStart = r.reservationTime.getTime() - bufferMs
const existingEnd = existingStart + r.durationMinutes * 60_000 const existingEnd = existingStart + r.durationMinutes * 60_000 + 2 * bufferMs
return existingStart < end && start.getTime() < existingEnd return existingStart < end && start.getTime() < existingEnd
}) })
} }
@@ -77,6 +86,120 @@ export const getTables = createServerFn({ method: 'GET' }).handler(async () => {
return db.select().from(restaurantTables).orderBy(restaurantTables.name) return db.select().from(restaurantTables).orderBy(restaurantTables.name)
}) })
const tableSchema = z.object({
name: z.string().trim().min(1, 'Name ist erforderlich').max(120),
tableNumber: z.number().int().positive('Tischnummer muss positiv sein'),
seats: z.number().int().min(1, 'Mindestens 1 Platz').max(50),
imageUrl: dataUrlImageSchema,
})
export const createTable = createServerFn({ method: 'POST' })
.validator(zodValidator(tableSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
const [table] = await db.insert(restaurantTables).values(data).returning()
return table
})
const updateTableSchema = tableSchema.extend({
id: z.number().int().positive(),
})
export const updateTable = createServerFn({ method: 'POST' })
.validator(zodValidator(updateTableSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
const [updated] = await db
.update(restaurantTables)
.set({ name: data.name, tableNumber: data.tableNumber, seats: data.seats, imageUrl: data.imageUrl })
.where(eq(restaurantTables.id, data.id))
.returning()
if (!updated) {
throw new Error('Tisch wurde nicht gefunden')
}
return updated
})
const deleteTableSchema = z.object({
id: z.number().int().positive(),
})
export const deleteTable = createServerFn({ method: 'POST' })
.validator(zodValidator(deleteTableSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
// The FK from reservations to this table cascades on delete, which would
// silently wipe out (and never notify) any guest booked on it. Deleting
// reservations individually first goes through deleteReservation, which
// does send a cancellation email.
const [existingReservation] = await db
.select({ id: reservations.id })
.from(reservations)
.where(eq(reservations.tableId, data.id))
.limit(1)
if (existingReservation) {
throw new Error(
'Tisch kann nicht gelöscht werden, solange Reservierungen dafür bestehen — bitte zuerst verschieben oder löschen',
)
}
const [deleted] = await db.delete(restaurantTables).where(eq(restaurantTables.id, data.id)).returning()
if (!deleted) {
throw new Error('Tisch wurde nicht gefunden')
}
return deleted
})
const setTableMarkerSchema = z.object({
id: z.number().int().positive(),
// Both coordinates set together (a marker) or both cleared (none) — there's no
// meaningful state for only one of them being present.
marker: z
.object({
x: z.number().min(0).max(100),
y: z.number().min(0).max(100),
})
.nullable(),
})
export const setTableMarker = createServerFn({ method: 'POST' })
.validator(zodValidator(setTableMarkerSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
const [updated] = await db
.update(restaurantTables)
.set({ markerX: data.marker?.x ?? null, markerY: data.marker?.y ?? null })
.where(eq(restaurantTables.id, data.id))
.returning()
if (!updated) {
throw new Error('Tisch wurde nicht gefunden')
}
return updated
})
export const getReservations = createServerFn({ method: 'GET' }).handler(async () => { export const getReservations = createServerFn({ method: 'GET' }).handler(async () => {
await requireStaffSession() await requireStaffSession()
@@ -85,9 +208,13 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
id: reservations.id, id: reservations.id,
tableId: reservations.tableId, tableId: reservations.tableId,
tableName: restaurantTables.name, tableName: restaurantTables.name,
tableNumber: restaurantTables.tableNumber,
customerName: reservations.customerName, customerName: reservations.customerName,
customerEmail: reservations.customerEmail, customerEmail: reservations.customerEmail,
customerPhone: reservations.customerPhone,
message: reservations.message,
partySize: reservations.partySize, partySize: reservations.partySize,
status: reservations.status,
reservationTime: reservations.reservationTime, reservationTime: reservations.reservationTime,
durationMinutes: reservations.durationMinutes, durationMinutes: reservations.durationMinutes,
}) })
@@ -96,10 +223,30 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
.orderBy(reservations.reservationTime) .orderBy(reservations.reservationTime)
}) })
// Unauthenticated — powers the public booking form's time picker, so it only
// exposes what's needed to block already-taken slots (table + time span), never
// any customer data.
export const getReservationAvailability = createServerFn({ method: 'GET' }).handler(async () => {
return db
.select({
tableId: reservations.tableId,
reservationTime: reservations.reservationTime,
durationMinutes: reservations.durationMinutes,
})
.from(reservations)
})
const createReservationSchema = z.object({ const createReservationSchema = z.object({
tableId: z.number().int().positive(), tableId: z.number().int().positive(),
customerName: z.string().trim().min(1, 'Name is required').max(120), customerName: z.string().trim().min(1, 'Name is required').max(120),
customerEmail: z.string().trim().email('Enter a valid email'), customerEmail: z.string().trim().email('Enter a valid email'),
customerPhone: phoneSchema,
message: z
.string()
.trim()
.max(500, 'Message must be at most 500 characters')
.optional()
.transform((value) => value || null),
partySize: z.number().int().min(1, 'At least 1 guest').max(20), partySize: z.number().int().min(1, 'At least 1 guest').max(20),
reservationTime: z.string().refine((value) => !Number.isNaN(Date.parse(value)), { reservationTime: z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Enter a valid date and time', message: 'Enter a valid date and time',
@@ -107,7 +254,7 @@ const createReservationSchema = z.object({
}) })
export const createReservation = createServerFn({ method: 'POST' }) export const createReservation = createServerFn({ method: 'POST' })
.validator(createReservationSchema) .validator(zodValidator(createReservationSchema))
.handler(async ({ data }) => { .handler(async ({ data }) => {
const settings = await loadReservationSettings() const settings = await loadReservationSettings()
const reservationTime = new Date(data.reservationTime) const reservationTime = new Date(data.reservationTime)
@@ -117,6 +264,7 @@ export const createReservation = createServerFn({ method: 'POST' })
settings.reservationIntervalMinutes, settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes, settings.minLeadTimeMinutes,
settings.maxLeadTimeDays, settings.maxLeadTimeDays,
settings.reservationDurationMinutes,
) )
const [table] = await db const [table] = await db
@@ -136,6 +284,7 @@ export const createReservation = createServerFn({ method: 'POST' })
data.tableId, data.tableId,
reservationTime, reservationTime,
settings.reservationDurationMinutes, settings.reservationDurationMinutes,
settings.minLeadTimeMinutes,
) )
if (conflict) { if (conflict) {
@@ -148,6 +297,8 @@ export const createReservation = createServerFn({ method: 'POST' })
tableId: data.tableId, tableId: data.tableId,
customerName: data.customerName, customerName: data.customerName,
customerEmail: data.customerEmail, customerEmail: data.customerEmail,
customerPhone: data.customerPhone,
message: data.message,
durationMinutes: settings.reservationDurationMinutes, durationMinutes: settings.reservationDurationMinutes,
partySize: data.partySize, partySize: data.partySize,
reservationTime, reservationTime,
@@ -157,6 +308,7 @@ export const createReservation = createServerFn({ method: 'POST' })
await sendReservationConfirmationEmail({ await sendReservationConfirmationEmail({
customerName: reservation.customerName, customerName: reservation.customerName,
customerEmail: reservation.customerEmail, customerEmail: reservation.customerEmail,
message: reservation.message,
partySize: reservation.partySize, partySize: reservation.partySize,
reservationTime: reservation.reservationTime, reservationTime: reservation.reservationTime,
cancelToken: reservation.cancelToken, cancelToken: reservation.cancelToken,
@@ -171,7 +323,7 @@ const updateReservationSchema = createReservationSchema.extend({
}) })
export const updateReservation = createServerFn({ method: 'POST' }) export const updateReservation = createServerFn({ method: 'POST' })
.validator(updateReservationSchema) .validator(zodValidator(updateReservationSchema))
.handler(async ({ data }) => { .handler(async ({ data }) => {
await requireStaffSession() await requireStaffSession()
@@ -183,6 +335,7 @@ export const updateReservation = createServerFn({ method: 'POST' })
settings.reservationIntervalMinutes, settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes, settings.minLeadTimeMinutes,
settings.maxLeadTimeDays, settings.maxLeadTimeDays,
settings.reservationDurationMinutes,
) )
const [table] = await db const [table] = await db
@@ -202,6 +355,7 @@ export const updateReservation = createServerFn({ method: 'POST' })
data.tableId, data.tableId,
reservationTime, reservationTime,
settings.reservationDurationMinutes, settings.reservationDurationMinutes,
settings.minLeadTimeMinutes,
data.id, data.id,
) )
@@ -215,6 +369,8 @@ export const updateReservation = createServerFn({ method: 'POST' })
tableId: data.tableId, tableId: data.tableId,
customerName: data.customerName, customerName: data.customerName,
customerEmail: data.customerEmail, customerEmail: data.customerEmail,
customerPhone: data.customerPhone,
message: data.message,
partySize: data.partySize, partySize: data.partySize,
reservationTime, reservationTime,
durationMinutes: settings.reservationDurationMinutes, durationMinutes: settings.reservationDurationMinutes,
@@ -229,6 +385,7 @@ export const updateReservation = createServerFn({ method: 'POST' })
await sendReservationUpdateEmail({ await sendReservationUpdateEmail({
customerName: updated.customerName, customerName: updated.customerName,
customerEmail: updated.customerEmail, customerEmail: updated.customerEmail,
message: updated.message,
partySize: updated.partySize, partySize: updated.partySize,
reservationTime: updated.reservationTime, reservationTime: updated.reservationTime,
cancelToken: updated.cancelToken, cancelToken: updated.cancelToken,
@@ -238,12 +395,45 @@ export const updateReservation = createServerFn({ method: 'POST' })
return updated return updated
}) })
export const reservationStatusSchema = z.enum(['confirmed', 'no_show', 'completed'])
export type ReservationStatus = z.infer<typeof reservationStatusSchema>
export const RESERVATION_STATUS_LABELS: Record<ReservationStatus, string> = {
confirmed: 'Bestätigt',
no_show: 'Nicht erschienen',
completed: 'Abgeschlossen',
}
const updateReservationStatusSchema = z.object({
id: z.number().int().positive(),
status: reservationStatusSchema,
})
export const updateReservationStatus = createServerFn({ method: 'POST' })
.validator(zodValidator(updateReservationStatusSchema))
.handler(async ({ data }) => {
await requireStaffSession()
const [updated] = await db
.update(reservations)
.set({ status: data.status })
.where(eq(reservations.id, data.id))
.returning()
if (!updated) {
throw new Error('Reservation not found')
}
return updated
})
const deleteReservationSchema = z.object({ const deleteReservationSchema = z.object({
id: z.number().int().positive(), id: z.number().int().positive(),
}) })
export const deleteReservation = createServerFn({ method: 'POST' }) export const deleteReservation = createServerFn({ method: 'POST' })
.validator(deleteReservationSchema) .validator(zodValidator(deleteReservationSchema))
.handler(async ({ data }) => { .handler(async ({ data }) => {
await requireStaffSession() await requireStaffSession()
@@ -277,7 +467,7 @@ const getReservationByCancelTokenSchema = z.object({
}) })
export const getReservationByCancelToken = createServerFn({ method: 'GET' }) export const getReservationByCancelToken = createServerFn({ method: 'GET' })
.validator(getReservationByCancelTokenSchema) .validator(zodValidator(getReservationByCancelTokenSchema))
.handler(async ({ data }) => { .handler(async ({ data }) => {
const [reservation] = await db const [reservation] = await db
.select({ .select({
@@ -298,7 +488,7 @@ const cancelReservationByTokenSchema = z.object({
}) })
export const cancelReservationByToken = createServerFn({ method: 'POST' }) export const cancelReservationByToken = createServerFn({ method: 'POST' })
.validator(cancelReservationByTokenSchema) .validator(zodValidator(cancelReservationByTokenSchema))
.handler(async ({ data }) => { .handler(async ({ data }) => {
const [deleted] = await db const [deleted] = await db
.delete(reservations) .delete(reservations)
+17
View File
@@ -0,0 +1,17 @@
import type { z, ZodType } from 'zod'
// `createServerFn().validator(schema)` passes a zod schema straight through to
// TanStack Start, which on failure throws `new Error(JSON.stringify(issues))` —
// the raw issue array, not a message meant for a user to read. Wrapping the
// schema in a plain function (`.validator(zodValidator(schema))`) routes
// validation through this instead, so a rejected field surfaces its own zod
// `.message` (e.g. "Enter a valid phone number") rather than a JSON blob.
export function zodValidator<Schema extends ZodType>(schema: Schema) {
return (input: unknown): z.infer<Schema> => {
const result = schema.safeParse(input)
if (!result.success) {
throw new Error(result.error.issues[0]?.message ?? 'Invalid input')
}
return result.data
}
}
+5 -1
View File
@@ -5,6 +5,8 @@ import { z } from 'zod'
import { db } from '#/db' import { db } from '#/db'
import { restaurantSettings } from '#/db/schema' import { restaurantSettings } from '#/db/schema'
import { requireStaffSession } from '#/lib/auth' import { requireStaffSession } from '#/lib/auth'
import { zodValidator } from '#/lib/server-validator'
import { dataUrlImageSchema } from '#/lib/table-image'
// Reads never require a session — the public booking form needs the interval and // 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 // lead time to generate its own time-slot grid, just like getTables(). Exported
@@ -31,10 +33,12 @@ const updateReservationSettingsSchema = z.object({
reservationDurationMinutes: z.number().int().min(15).max(480), reservationDurationMinutes: z.number().int().min(15).max(480),
minLeadTimeMinutes: z.number().int().min(0).max(10080), minLeadTimeMinutes: z.number().int().min(0).max(10080),
maxLeadTimeDays: z.number().int().min(1).max(365), maxLeadTimeDays: z.number().int().min(1).max(365),
tableDisplayMode: z.enum(['individual', 'overview']),
tableOverviewImageUrl: dataUrlImageSchema,
}) })
export const updateReservationSettings = createServerFn({ method: 'POST' }) export const updateReservationSettings = createServerFn({ method: 'POST' })
.validator(updateReservationSettingsSchema) .validator(zodValidator(updateReservationSettingsSchema))
.handler(async ({ data }) => { .handler(async ({ data }) => {
console.log('[settings] updateReservationSettings called with', data) console.log('[settings] updateReservationSettings called with', data)
+5 -1
View File
@@ -11,5 +11,9 @@ export const listStaffMembers = createServerFn({ method: 'GET' }).handler(async
query: { sortBy: 'createdAt', sortDirection: 'asc', limit: 100 }, query: { sortBy: 'createdAt', sortDirection: 'asc', limit: 100 },
}) })
return users // `listUsers`'s return type doesn't pick up `user.additionalFields` from the
// auth config the way the actual response body does — `phone` is present at
// runtime (Better Auth includes every configured additional field on every
// user-returning endpoint) but missing from admin.d.ts's `UserWithRole` type.
return users as Array<(typeof users)[number] & { phone: string | null }>
}) })
+24
View File
@@ -0,0 +1,24 @@
import { z } from 'zod'
// Generous enough for a photo, small enough to keep rows (and the customer-facing
// page that loads them up front) from bloating — these are data: URLs stored
// inline, not uploads to disk-backed storage, which this app doesn't have.
export const MAX_TABLE_IMAGE_BYTES = 2_000_000
export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp']
export const dataUrlImageSchema = z
.string()
.trim()
.max(Math.ceil((MAX_TABLE_IMAGE_BYTES * 4) / 3) + 100)
.regex(/^data:image\/(png|jpeg|jpg|webp);base64,/, 'Bild muss als PNG, JPEG oder WebP hochgeladen werden')
.nullable()
export function readImageAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error ?? new Error('Datei konnte nicht gelesen werden'))
reader.readAsDataURL(file)
})
}
+21
View File
@@ -14,6 +14,7 @@ import { Route as ImpressumRouteImport } from './routes/impressum'
import { Route as StaffIndexRouteImport } from './routes/staff.index' import { Route as StaffIndexRouteImport } from './routes/staff.index'
import { Route as StaffLoginRouteImport } from './routes/staff.login' import { Route as StaffLoginRouteImport } from './routes/staff.login'
import { Route as StaffSettingsRouteImport } from './routes/staff.settings' import { Route as StaffSettingsRouteImport } from './routes/staff.settings'
import { Route as StaffTablesRouteImport } from './routes/staff.tables'
import { Route as StaffTeamRouteImport } from './routes/staff.team' import { Route as StaffTeamRouteImport } from './routes/staff.team'
import { Route as StornierenTokenRouteImport } from './routes/stornieren.$token' import { Route as StornierenTokenRouteImport } from './routes/stornieren.$token'
@@ -42,6 +43,11 @@ const StaffSettingsRoute = StaffSettingsRouteImport.update({
path: '/staff/settings', path: '/staff/settings',
getParentRoute: () => rootRouteImport, getParentRoute: () => rootRouteImport,
} as any) } as any)
const StaffTablesRoute = StaffTablesRouteImport.update({
id: '/staff/tables',
path: '/staff/tables',
getParentRoute: () => rootRouteImport,
} as any)
const StaffTeamRoute = StaffTeamRouteImport.update({ const StaffTeamRoute = StaffTeamRouteImport.update({
id: '/staff/team', id: '/staff/team',
path: '/staff/team', path: '/staff/team',
@@ -58,6 +64,7 @@ export interface FileRoutesByFullPath {
'/impressum': typeof ImpressumRoute '/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute '/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute '/staff/settings': typeof StaffSettingsRoute
'/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute '/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute '/stornieren/$token': typeof StornierenTokenRoute
'/staff/': typeof StaffIndexRoute '/staff/': typeof StaffIndexRoute
@@ -67,6 +74,7 @@ export interface FileRoutesByTo {
'/impressum': typeof ImpressumRoute '/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute '/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute '/staff/settings': typeof StaffSettingsRoute
'/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute '/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute '/stornieren/$token': typeof StornierenTokenRoute
'/staff': typeof StaffIndexRoute '/staff': typeof StaffIndexRoute
@@ -77,6 +85,7 @@ export interface FileRoutesById {
'/impressum': typeof ImpressumRoute '/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute '/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute '/staff/settings': typeof StaffSettingsRoute
'/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute '/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute '/stornieren/$token': typeof StornierenTokenRoute
'/staff/': typeof StaffIndexRoute '/staff/': typeof StaffIndexRoute
@@ -88,6 +97,7 @@ export interface FileRouteTypes {
| '/impressum' | '/impressum'
| '/staff/login' | '/staff/login'
| '/staff/settings' | '/staff/settings'
| '/staff/tables'
| '/staff/team' | '/staff/team'
| '/stornieren/$token' | '/stornieren/$token'
| '/staff/' | '/staff/'
@@ -97,6 +107,7 @@ export interface FileRouteTypes {
| '/impressum' | '/impressum'
| '/staff/login' | '/staff/login'
| '/staff/settings' | '/staff/settings'
| '/staff/tables'
| '/staff/team' | '/staff/team'
| '/stornieren/$token' | '/stornieren/$token'
| '/staff' | '/staff'
@@ -106,6 +117,7 @@ export interface FileRouteTypes {
| '/impressum' | '/impressum'
| '/staff/login' | '/staff/login'
| '/staff/settings' | '/staff/settings'
| '/staff/tables'
| '/staff/team' | '/staff/team'
| '/stornieren/$token' | '/stornieren/$token'
| '/staff/' | '/staff/'
@@ -116,6 +128,7 @@ export interface RootRouteChildren {
ImpressumRoute: typeof ImpressumRoute ImpressumRoute: typeof ImpressumRoute
StaffLoginRoute: typeof StaffLoginRoute StaffLoginRoute: typeof StaffLoginRoute
StaffSettingsRoute: typeof StaffSettingsRoute StaffSettingsRoute: typeof StaffSettingsRoute
StaffTablesRoute: typeof StaffTablesRoute
StaffTeamRoute: typeof StaffTeamRoute StaffTeamRoute: typeof StaffTeamRoute
StornierenTokenRoute: typeof StornierenTokenRoute StornierenTokenRoute: typeof StornierenTokenRoute
StaffIndexRoute: typeof StaffIndexRoute StaffIndexRoute: typeof StaffIndexRoute
@@ -158,6 +171,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StaffSettingsRouteImport preLoaderRoute: typeof StaffSettingsRouteImport
parentRoute: typeof rootRouteImport parentRoute: typeof rootRouteImport
} }
'/staff/tables': {
id: '/staff/tables'
path: '/staff/tables'
fullPath: '/staff/tables'
preLoaderRoute: typeof StaffTablesRouteImport
parentRoute: typeof rootRouteImport
}
'/staff/team': { '/staff/team': {
id: '/staff/team' id: '/staff/team'
path: '/staff/team' path: '/staff/team'
@@ -180,6 +200,7 @@ const rootRouteChildren: RootRouteChildren = {
ImpressumRoute: ImpressumRoute, ImpressumRoute: ImpressumRoute,
StaffLoginRoute: StaffLoginRoute, StaffLoginRoute: StaffLoginRoute,
StaffSettingsRoute: StaffSettingsRoute, StaffSettingsRoute: StaffSettingsRoute,
StaffTablesRoute: StaffTablesRoute,
StaffTeamRoute: StaffTeamRoute, StaffTeamRoute: StaffTeamRoute,
StornierenTokenRoute: StornierenTokenRoute, StornierenTokenRoute: StornierenTokenRoute,
StaffIndexRoute: StaffIndexRoute, StaffIndexRoute: StaffIndexRoute,
+5
View File
@@ -26,6 +26,11 @@ export const Route = createRootRoute({
rel: 'stylesheet', rel: 'stylesheet',
href: appCss, href: appCss,
}, },
{
rel: 'icon',
href: '/favicon.svg',
type: 'image/svg+xml',
},
], ],
}), }),
shellComponent: RootDocument, shellComponent: RootDocument,
+1 -1
View File
@@ -57,7 +57,7 @@ function Impressum() {
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers. bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
</p> </p>
<h2>Datenschutz</h2> <h2 id="datenschutz">Datenschutz</h2>
<p> <p>
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
oben genannte Drenica &amp; Mousa GbR. oben genannte Drenica &amp; Mousa GbR.
+211 -37
View File
@@ -1,61 +1,120 @@
import { useState } from 'react' import { useState } from 'react'
import { createFileRoute, useRouter } from '@tanstack/react-router' import { createFileRoute, useRouter } from '@tanstack/react-router'
import { PdfMenu } from '#/components/pdf-menu'
import { ReservationConfirmedModal } from '#/components/reservation-confirmed-modal'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field' import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { SiteFooter } from '#/components/site-footer' import { SiteFooter } from '#/components/site-footer'
import { SiteHeader } from '#/components/site-header' import { SiteHeader } from '#/components/site-header'
import { getNextAvailableSlot, getOpeningHoursForDisplay, getTodayLabelBerlin } from '#/lib/opening-hours' import { formatDateOnly } from '#/lib/datetime'
import { createReservation, getTables } from '#/lib/reservations' import {
generateTimeOptions,
getNextAvailableSlot,
getOpeningHoursForDisplay,
getOpeningRangesForWeekday,
getTodayLabelBerlin,
} from '#/lib/opening-hours'
import { createReservation, getReservationAvailability, getTables } from '#/lib/reservations'
import { getReservationSettings } from '#/lib/settings' import { getReservationSettings } from '#/lib/settings'
export const Route = createFileRoute('/')({ export const Route = createFileRoute('/')({
component: Home, component: Home,
loader: async () => { loader: async () => {
const [tables, settings] = await Promise.all([getTables(), getReservationSettings()]) const [tables, settings, availability] = await Promise.all([
return { tables, settings } getTables(),
getReservationSettings(),
getReservationAvailability(),
])
return { tables, settings, availability }
}, },
}) })
const OPENING_HOURS = getOpeningHoursForDisplay() const OPENING_HOURS = getOpeningHoursForDisplay()
function FlameIcon() { const confirmedDateFormatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' })
return ( const confirmedTimeFormatter = new Intl.DateTimeFormat('de-DE', { timeStyle: 'short' })
<svg className="hero-flame" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<path const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
d="M12 2c1 3-3 4-3 7.5A3.5 3.5 0 0 0 12 13a3.5 3.5 0 0 0 3-5.3c1.5 1 2.5 3 2.5 5a5.5 5.5 0 1 1-11 0C6.5 8.5 9.5 6 12 2Z" // Also used as the phone input's `pattern` attribute below, so the two stay in sync.
stroke="currentColor" const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
strokeWidth="1.4" const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
strokeLinejoin="round"
/> type ConfirmedReservation = {
</svg> customerName: string
) customerEmail: string
tableName: string
date: string
startTime: string
endTime: string
partySize: number
} }
function Home() { function Home() {
const router = useRouter() const router = useRouter()
const { tables, settings } = Route.useLoaderData() const { tables, settings, availability } = Route.useLoaderData()
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '') const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
const [customerName, setCustomerName] = useState('') const [customerName, setCustomerName] = useState('')
const [customerEmail, setCustomerEmail] = useState('') const [customerEmail, setCustomerEmail] = useState('')
const [customerPhone, setCustomerPhone] = useState('')
const [message, setMessage] = useState('')
const [partySize, setPartySize] = useState(2) const [partySize, setPartySize] = useState(2)
const [acceptsTerms, setAcceptsTerms] = useState(false)
const [reservationTime, setReservationTime] = useState(() => { const [reservationTime, setReservationTime] = useState(() => {
const slot = getNextAvailableSlot( const slot = getNextAvailableSlot(
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000), new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
settings.reservationIntervalMinutes, settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
) )
return slot ? `${slot.date}T${slot.time}` : '' return slot ? `${slot.date}T${slot.time}` : ''
}) })
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const todayName = getTodayLabelBerlin() const todayName = getTodayLabelBerlin()
const selectedTable = tables.find((table) => String(table.id) === String(tableId))
const [selectedDate] = reservationTime.split('T')
const otherReservationsOnSelection = availability.filter(
(r) => String(r.tableId) === String(tableId) && formatDateOnly(new Date(r.reservationTime)) === selectedDate,
)
// `minLeadTimeMinutes` is reused here as a table turnover buffer, extending each
// existing reservation's taken window on both sides so back-to-back bookings on the
// same table always leave that much of a gap.
const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
const bookedTimesForForm = selectedDate
? generateTimeOptions(
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
).filter((option) => {
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
return otherReservationsOnSelection.some((r) => {
const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
return slotStart >= otherStart && slotStart < otherEnd
})
})
: []
// Mirrors the fields' own `required`/`type`/`pattern`/`min`/`max` constraints, so
// the submit button is disabled until they'd all actually pass — instead of only
// catching a missing table/time/consent on submit and leaving everything else to
// the browser's native validation popup.
const isFormValid =
Boolean(tableId) &&
customerName.trim().length > 0 &&
EMAIL_PATTERN.test(customerEmail) &&
PHONE_PATTERN.test(customerPhone) &&
partySize >= 1 &&
partySize <= 20 &&
Boolean(reservationTime.split('T')[1]) &&
acceptsTerms
async function handleSubmit(event: React.FormEvent) { async function handleSubmit(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
setError(null) setError(null)
setSuccess(null)
if (!tableId) { if (!tableId) {
setError('Bitte wählen Sie einen Tisch aus') setError('Bitte wählen Sie einen Tisch aus')
@@ -67,22 +126,42 @@ function Home() {
return return
} }
if (!acceptsTerms) {
setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu')
return
}
setIsSubmitting(true) setIsSubmitting(true)
try { try {
await createReservation({ const created = await createReservation({
data: { data: {
tableId: Number(tableId), tableId: Number(tableId),
customerName, customerName,
customerEmail, customerEmail,
customerPhone,
message,
partySize: Number(partySize), partySize: Number(partySize),
reservationTime: new Date(reservationTime).toISOString(), reservationTime: new Date(reservationTime).toISOString(),
}, },
}) })
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.') const start = new Date(created.reservationTime)
const end = new Date(start.getTime() + created.durationMinutes * 60_000)
setConfirmedReservation({
customerName: created.customerName,
customerEmail: created.customerEmail,
tableName: selectedTable?.name ?? '',
date: confirmedDateFormatter.format(start),
startTime: confirmedTimeFormatter.format(start),
endTime: confirmedTimeFormatter.format(end),
partySize: created.partySize,
})
setCustomerName('') setCustomerName('')
setCustomerEmail('') setCustomerEmail('')
setCustomerPhone('')
setMessage('')
setPartySize(2) setPartySize(2)
setAcceptsTerms(false)
await router.invalidate() await router.invalidate()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
@@ -96,13 +175,13 @@ function Home() {
<SiteHeader /> <SiteHeader />
<section className="hero" id="top"> <section className="hero" id="top">
<FlameIcon /> <img className="hero-flame" src="/logo.svg" alt="" aria-hidden="true" />
<h1> <h1>
Steakhaus <span>bei Mirko</span> Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
</h1> </h1>
<p> <p>
Steaks vom Grill, gute Gesellschaft und ein gemütlicher Abend mitten in Meppen. Reservieren Dicke Steaks, knisterndes Holzkohlefeuer und Gelächter am Nachbartisch – so schmeckt ein
Sie Ihren Tisch – wir freuen uns auf Sie. guter Abend mitten in Meppen. Reservieren Sie Ihren Tisch, wir legen schon auf.
</p> </p>
<div className="hero-actions"> <div className="hero-actions">
<a className="btn btn-primary" href="#reservieren"> <a className="btn btn-primary" href="#reservieren">
@@ -136,6 +215,16 @@ function Home() {
</div> </div>
</section> </section>
<section className="section" id="speisekarte">
<div className="section-grid">
<div>
<span className="section-kicker">Was uns schmeckt</span>
<h2 className="section-heading">Speisekarte</h2>
</div>
<PdfMenu src="/menu.pdf" />
</div>
</section>
<section className="section" id="reservieren"> <section className="section" id="reservieren">
<div className="section-grid"> <div className="section-grid">
<div> <div>
@@ -158,6 +247,36 @@ function Home() {
</select> </select>
</label> </label>
{settings.tableDisplayMode === 'overview' ? (
settings.tableOverviewImageUrl && (
<div className="table-overview-wrap">
<img className="table-overview-image" src={settings.tableOverviewImageUrl} alt="Übersicht unserer Tische" />
{tables
.filter((table) => table.markerX !== null && table.markerY !== null)
.map((table) => (
<button
key={table.id}
type="button"
className={`table-marker${String(table.id) === String(tableId) ? ' is-selected' : ''}`}
style={{ left: `${table.markerX}%`, top: `${table.markerY}%` }}
onClick={() => setTableId(table.id)}
aria-label={`${table.name} auswählen`}
>
<span className="table-marker-label">{table.tableNumber ?? '?'}</span>
</button>
))}
</div>
)
) : (
selectedTable?.imageUrl && (
<img
className="table-preview"
src={selectedTable.imageUrl}
alt={`Ansicht von ${selectedTable.name}`}
/>
)
)}
<div className="form-row"> <div className="form-row">
<label> <label>
Name Name
@@ -181,6 +300,19 @@ function Home() {
</div> </div>
<div className="form-row"> <div className="form-row">
<label>
Telefon
<input
type="tel"
inputMode="tel"
pattern={PHONE_PATTERN_SOURCE}
title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={customerPhone}
onChange={(e) => setCustomerPhone(e.target.value)}
required
/>
</label>
<label> <label>
Anzahl Personen Anzahl Personen
<input <input
@@ -193,30 +325,58 @@ function Home() {
required required
/> />
</label> </label>
<label>
Datum &amp; Uhrzeit
<ReservationDateTimeField
value={reservationTime}
onChange={setReservationTime}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
</div> </div>
<label>
Datum &amp; Uhrzeit
<ReservationDateTimeField
value={reservationTime}
onChange={setReservationTime}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
durationMinutes={settings.reservationDurationMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
<label>
Nachricht (optional)
<textarea
rows={3}
maxLength={500}
placeholder="Allergien, ein besonderer Anlass, Sitzwünsche …"
value={message}
onChange={(e) => setMessage(e.target.value)}
/>
</label>
<p className="field-hint"> <p className="field-hint">
Verfügbare Uhrzeiten richten sich nach unseren{' '} Verfügbare Uhrzeiten richten sich nach unseren{' '}
<a href="#oeffnungszeiten">Öffnungszeiten</a>. <a href="#oeffnungszeiten">Öffnungszeiten</a>.
</p> </p>
<button type="submit" disabled={isSubmitting}> <label className="radio-option consent-option">
<input
type="checkbox"
checked={acceptsTerms}
onChange={(e) => setAcceptsTerms(e.target.checked)}
required
/>
<span>
Ich stimme der Verarbeitung meiner Daten gemäß der{' '}
<a href="/impressum#datenschutz" target="_blank" rel="noopener noreferrer">
Datenschutzerklärung
</a>{' '}
zu.
</span>
</label>
<button type="submit" disabled={isSubmitting || !isFormValid}>
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'} {isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
</button> </button>
{error && <p className="message message-error">{error}</p>} {error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form> </form>
</div> </div>
</section> </section>
@@ -250,6 +410,20 @@ function Home() {
</main> </main>
<SiteFooter /> <SiteFooter />
{confirmedReservation && (
<ReservationConfirmedModal
open
customerName={confirmedReservation.customerName}
customerEmail={confirmedReservation.customerEmail}
tableName={confirmedReservation.tableName}
date={confirmedReservation.date}
startTime={confirmedReservation.startTime}
endTime={confirmedReservation.endTime}
partySize={confirmedReservation.partySize}
onClose={() => setConfirmedReservation(null)}
/>
)}
</> </>
) )
} }
+162 -240
View File
@@ -3,18 +3,21 @@ import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-rout
import { ConfirmDialog } from '#/components/confirm-dialog' import { ConfirmDialog } from '#/components/confirm-dialog'
import { ReservationCharts } from '#/components/reservation-charts' import { ReservationCharts } from '#/components/reservation-charts'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field' import { ReservationDialog, type ReservationPayload } from '#/components/reservation-dialog'
import { StaffTabs } from '#/components/staff-tabs' import { StaffTabs } from '#/components/staff-tabs'
import { StatusBadge } from '#/components/status-badge'
import { SuccessModal } from '#/components/success-modal'
import { getCurrentStaffUser } from '#/lib/auth' import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client' import { authClient } from '#/lib/auth-client'
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
import { import {
RESERVATION_STATUS_LABELS,
createReservation, createReservation,
deleteReservation, deleteReservation,
getReservations, getReservations,
getTables, getTables,
updateReservation, updateReservation,
updateReservationStatus,
type ReservationStatus,
} from '#/lib/reservations' } from '#/lib/reservations'
import { getReservationSettings } from '#/lib/settings' import { getReservationSettings } from '#/lib/settings'
@@ -37,8 +40,11 @@ export const Route = createFileRoute('/staff/')({
}, },
}) })
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', { const dateFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium', dateStyle: 'medium',
})
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short', timeStyle: 'short',
}) })
@@ -50,45 +56,22 @@ function isSameDay(a: Date, b: Date) {
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number] type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
type FormState = {
tableId: number | string
customerName: string
customerEmail: string
partySize: number
reservationTime: string
}
type SettingsRow = Awaited<ReturnType<typeof getReservationSettings>>
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: slot ? `${slot.date}T${slot.time}` : '',
}
}
function StaffDashboard() { function StaffDashboard() {
const router = useRouter() const router = useRouter()
const { staffUser, reservations, tables, settings } = Route.useLoaderData() const { staffUser, reservations, tables, settings } = Route.useLoaderData()
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState<FormState>(() => emptyForm(tables[0]?.id ?? '', settings))
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [successModal, setSuccessModal] = useState<{ title: string; message: string } | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null) const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null)
const [viewingReservation, setViewingReservation] = useState<ReservationRow | null>(null)
const [isCreating, setIsCreating] = useState(false)
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
const [searchQuery, setSearchQuery] = useState('') const [searchQuery, setSearchQuery] = useState('')
const [tableFilter, setTableFilter] = useState('all') const [tableFilter, setTableFilter] = useState('all')
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('all') const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('today')
const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all')
const dialogOpen = viewingReservation !== null || isCreating
const now = new Date() const now = new Date()
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now)) const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
@@ -97,6 +80,7 @@ function StaffDashboard() {
const filteredReservations = reservations.filter((r) => { const filteredReservations = reservations.filter((r) => {
if (tableFilter !== 'all' && String(r.tableId) !== tableFilter) return false if (tableFilter !== 'all' && String(r.tableId) !== tableFilter) return false
if (statusFilter !== 'all' && r.status !== statusFilter) return false
const reservationTime = new Date(r.reservationTime) const reservationTime = new Date(r.reservationTime)
if (timeFilter === 'today' && !isSameDay(reservationTime, now)) return false if (timeFilter === 'today' && !isSameDay(reservationTime, now)) return false
@@ -105,99 +89,34 @@ function StaffDashboard() {
const query = searchQuery.trim().toLowerCase() const query = searchQuery.trim().toLowerCase()
if (query) { if (query) {
const haystack = `${r.customerName} ${r.customerEmail} ${r.tableName}`.toLowerCase() const haystack = `${r.customerName} ${r.customerEmail} ${r.customerPhone ?? ''} ${r.tableName} ${r.tableNumber ?? ''}`.toLowerCase()
if (!haystack.includes(query)) return false if (!haystack.includes(query)) return false
} }
return true return true
}) })
// Other reservations already sitting on the currently selected table + date, so the function closeDialog() {
// time picker can block them instead of letting staff pick a slot the server will setViewingReservation(null)
// just reject as double-booked. A reservation occupies its whole configured setIsCreating(false)
// 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) { async function handleCreate(payload: ReservationPayload) {
setEditingId(reservation.id) await createReservation({ data: payload })
setForm({ setSuccessModal({
tableId: reservation.tableId, title: 'Reservierung angelegt',
customerName: reservation.customerName, message: `Die Reservierung für ${payload.customerName} wurde erfolgreich angelegt.`,
customerEmail: reservation.customerEmail,
partySize: reservation.partySize,
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
}) })
setError(null) await router.invalidate()
setSuccess(null)
} }
function cancelEdit() { async function handleUpdate(id: number, payload: ReservationPayload) {
setEditingId(null) await updateReservation({ data: { id, ...payload } })
setForm(emptyForm(tables[0]?.id ?? '', settings)) setSuccessModal({
setError(null) title: 'Reservierung aktualisiert',
} message: `Die Reservierung für ${payload.customerName} wurde erfolgreich aktualisiert.`,
})
async function handleSubmit(event: React.FormEvent) { await router.invalidate()
event.preventDefault()
setError(null)
setSuccess(null)
if (!form.tableId) {
setError('Bitte wählen Sie einen Tisch aus')
return
}
if (!form.reservationTime.split('T')[1]) {
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
return
}
setIsSubmitting(true)
try {
const payload = {
tableId: Number(form.tableId),
customerName: form.customerName,
customerEmail: form.customerEmail,
partySize: Number(form.partySize),
reservationTime: new Date(form.reservationTime).toISOString(),
}
if (editingId) {
await updateReservation({ data: { id: editingId, ...payload } })
setSuccess('Reservierung wurde aktualisiert.')
} else {
await createReservation({ data: payload })
setSuccess('Reservierung wurde angelegt.')
}
setEditingId(null)
setForm(emptyForm(tables[0]?.id ?? '', settings))
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
} }
async function confirmDelete() { async function confirmDelete() {
@@ -205,21 +124,32 @@ function StaffDashboard() {
if (!reservation) return if (!reservation) return
setPendingDelete(null) setPendingDelete(null)
setDeletingId(reservation.id)
setError(null) setError(null)
setSuccess(null)
try { try {
await deleteReservation({ data: { id: reservation.id } }) await deleteReservation({ data: { id: reservation.id } })
if (editingId === reservation.id) { setSuccessModal({
cancelEdit() title: 'Reservierung gelöscht',
} message: `Die Reservierung von ${reservation.customerName} wurde gelöscht.`,
setSuccess('Reservierung wurde gelöscht.') })
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
}
}
async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) {
setIsUpdatingStatus(true)
setError(null)
try {
await updateReservationStatus({ data: { id: reservation.id, status } })
setViewingReservation((current) => (current ? { ...current, status } : current))
await router.invalidate() await router.invalidate()
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen') setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally { } finally {
setDeletingId(null) setIsUpdatingStatus(false)
} }
} }
@@ -252,6 +182,12 @@ function StaffDashboard() {
<main className="page staff-main"> <main className="page staff-main">
<h1 className="section-heading">Reservierungen</h1> <h1 className="section-heading">Reservierungen</h1>
{!dialogOpen && error && (
<div className="staff-page-messages">
<p className="message message-error">{error}</p>
</div>
)}
<div className="staff-stats"> <div className="staff-stats">
<div className="stat-card"> <div className="stat-card">
<span className="stat-value">{reservations.length}</span> <span className="stat-value">{reservations.length}</span>
@@ -273,96 +209,14 @@ function StaffDashboard() {
<ReservationCharts reservations={reservations} tables={tables} /> <ReservationCharts reservations={reservations} tables={tables} />
<section className="staff-form-section">
<h2 className="section-heading staff-subheading">
{editingId ? 'Reservierung bearbeiten' : 'Neue Reservierung'}
</h2>
<form className="reservation-form" onSubmit={handleSubmit}>
<label>
Tisch
<select
value={form.tableId}
onChange={(e) => setForm((f) => ({ ...f, tableId: e.target.value }))}
required
>
<option value="" disabled>
Tisch auswählen
</option>
{tables.map((table) => (
<option key={table.id} value={table.id}>
{table.name} ({table.seats} Plätze)
</option>
))}
</select>
</label>
<div className="form-row">
<label>
Name
<input
type="text"
value={form.customerName}
onChange={(e) => setForm((f) => ({ ...f, customerName: e.target.value }))}
required
/>
</label>
<label>
E-Mail
<input
type="email"
value={form.customerEmail}
onChange={(e) => setForm((f) => ({ ...f, customerEmail: e.target.value }))}
required
/>
</label>
</div>
<div className="form-row">
<label>
Anzahl Personen
<input
type="number"
inputMode="numeric"
min={1}
max={20}
value={form.partySize}
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
required
/>
</label>
<label>
Datum &amp; Uhrzeit
<ReservationDateTimeField
value={form.reservationTime}
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
bookedTimes={bookedTimesForForm}
intervalMinutes={settings.reservationIntervalMinutes}
minLeadTimeMinutes={settings.minLeadTimeMinutes}
maxLeadTimeDays={settings.maxLeadTimeDays}
/>
</label>
</div>
<div className="form-actions">
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gespeichert …' : editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" className="btn btn-secondary" onClick={cancelEdit}>
Abbrechen
</button>
)}
</div>
{error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form>
</section>
<section className="staff-table-section"> <section className="staff-table-section">
<div className="table-toolbar">
<h2 className="section-heading staff-subheading">Alle Reservierungen</h2>
<button type="button" className="btn btn-primary" onClick={() => setIsCreating(true)}>
+ Neue Reservierung
</button>
</div>
{reservations.length > 0 && ( {reservations.length > 0 && (
<div className="table-filters"> <div className="table-filters">
<input <input
@@ -377,7 +231,7 @@ function StaffDashboard() {
<option value="all">Alle Tische</option> <option value="all">Alle Tische</option>
{tables.map((table) => ( {tables.map((table) => (
<option key={table.id} value={String(table.id)}> <option key={table.id} value={String(table.id)}>
{table.name} {table.tableNumber === null ? table.name : `${table.tableNumber} — ${table.name}`}
</option> </option>
))} ))}
</select> </select>
@@ -391,6 +245,18 @@ function StaffDashboard() {
<option value="upcoming">Anstehend</option> <option value="upcoming">Anstehend</option>
<option value="past">Vergangen</option> <option value="past">Vergangen</option>
</select> </select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
aria-label="Nach Status filtern"
>
<option value="all">Alle Status</option>
{Object.entries(RESERVATION_STATUS_LABELS).map(([status, label]) => (
<option key={status} value={status}>
{label}
</option>
))}
</select>
</div> </div>
)} )}
@@ -402,41 +268,64 @@ function StaffDashboard() {
</p> </p>
) : ( ) : (
<div className="table-scroll"> <div className="table-scroll">
<table className="reservations-table"> <table className="reservations-table reservations-table-clickable">
<thead> <thead>
<tr> <tr>
<th>Datum &amp; Uhrzeit</th> {timeFilter !== 'today' && <th>Datum</th>}
<th>Start</th>
<th>Ende</th>
<th>Nr.</th>
<th>Tisch</th> <th>Tisch</th>
<th>Kunde</th> <th>Kunde</th>
<th>Personen</th> <th>Personen</th>
<th></th> <th>Status</th>
<th>Nachricht</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filteredReservations.map((reservation) => ( {filteredReservations.map((reservation) => (
<tr key={reservation.id} className={editingId === reservation.id ? 'is-editing' : undefined}> <tr key={reservation.id} onClick={() => setViewingReservation(reservation)}>
<td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td> {timeFilter !== 'today' && (
<td>{reservation.tableName}</td> <td data-label="Datum" className="cell-datetime">
<td> {dateFormatter.format(new Date(reservation.reservationTime))}
</td>
)}
<td data-label="Start" className="cell-datetime">
{timeFormatter.format(new Date(reservation.reservationTime))}
</td>
<td data-label="Ende" className="cell-datetime">
{timeFormatter.format(
new Date(
new Date(reservation.reservationTime).getTime() +
reservation.durationMinutes * 60_000,
),
)}
</td>
<td data-label="Nr." className="cell-table-nr">
{reservation.tableNumber ?? '—'}
</td>
<td data-label="Tisch" className="cell-table-name">
{reservation.tableName}
</td>
<td data-label="Kunde" className="cell-customer">
{reservation.customerName} {reservation.customerName}
<br /> <br />
<span className="muted">{reservation.customerEmail}</span> <span className="muted">{reservation.customerEmail}</span>
{reservation.customerPhone && (
<>
<br />
<span className="muted">{reservation.customerPhone}</span>
</>
)}
</td> </td>
<td>{reservation.partySize}</td> <td data-label="Personen" className="cell-party">
<td> {reservation.partySize}
<div className="table-actions"> </td>
<button type="button" className="btn btn-secondary" onClick={() => startEdit(reservation)}> <td data-label="Status" className="cell-status">
Bearbeiten <StatusBadge status={reservation.status as ReservationStatus} />
</button> </td>
<button <td data-label="Nachricht" className="message-cell">
type="button" {reservation.message || <span className="muted">—</span>}
className="btn btn-danger"
disabled={deletingId === reservation.id}
onClick={() => setPendingDelete(reservation)}
>
{deletingId === reservation.id ? 'Löschen …' : 'Löschen'}
</button>
</div>
</td> </td>
</tr> </tr>
))} ))}
@@ -451,15 +340,48 @@ function StaffDashboard() {
open={pendingDelete !== null} open={pendingDelete !== null}
title="Reservierung löschen" title="Reservierung löschen"
message={ message={
pendingDelete pendingDelete && (
? `Reservierung von ${pendingDelete.customerName} wirklich löschen? Der Gast erhält eine E-Mail zur Bestätigung der Stornierung. Dies kann nicht rückgängig gemacht werden.` <>
: '' Reservierung von <strong>{pendingDelete.customerName}</strong> wirklich löschen? Der Gast
erhält eine E-Mail zur Bestätigung der Stornierung.
</>
)
} }
warning="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen" confirmLabel="Löschen"
danger danger
onConfirm={confirmDelete} onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)} onCancel={() => setPendingDelete(null)}
/> />
{dialogOpen && (
<ReservationDialog
key={viewingReservation ? `view-${viewingReservation.id}` : 'create'}
open={dialogOpen}
reservation={viewingReservation}
tables={tables}
settings={settings}
reservations={reservations}
onClose={closeDialog}
onCreate={handleCreate}
onUpdate={handleUpdate}
onStatusChange={(status) => viewingReservation && handleStatusChange(viewingReservation, status)}
isUpdatingStatus={isUpdatingStatus}
statusError={error}
onDelete={() => {
if (!viewingReservation) return
setPendingDelete(viewingReservation)
setViewingReservation(null)
}}
/>
)}
<SuccessModal
open={successModal !== null}
title={successModal?.title ?? ''}
message={successModal?.message ?? ''}
onClose={() => setSuccessModal(null)}
/>
</div> </div>
) )
} }
+47 -6
View File
@@ -18,6 +18,35 @@ function ArrowLeftIcon() {
) )
} }
function EyeIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
</svg>
)
}
function EyeOffIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 4l16 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export const Route = createFileRoute('/staff/login')({ export const Route = createFileRoute('/staff/login')({
component: StaffLogin, component: StaffLogin,
beforeLoad: async () => { beforeLoad: async () => {
@@ -33,6 +62,7 @@ function StaffLogin() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
@@ -78,12 +108,23 @@ function StaffLogin() {
<label> <label>
Passwort Passwort
<input <div className="password-field">
type="password" <input
value={password} type={showPassword ? 'text' : 'password'}
onChange={(e) => setPassword(e.target.value)} value={password}
required onChange={(e) => setPassword(e.target.value)}
/> required
/>
<button
type="button"
className="password-toggle"
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
aria-pressed={showPassword}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
</label> </label>
<button type="submit" disabled={isSubmitting}> <button type="submit" disabled={isSubmitting}>
+202 -4
View File
@@ -1,10 +1,12 @@
import { useState } from 'react' import { useRef, useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router' import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { StaffTabs } from '#/components/staff-tabs' import { StaffTabs } from '#/components/staff-tabs'
import { getCurrentStaffUser } from '#/lib/auth' import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client' import { authClient } from '#/lib/auth-client'
import { getTables, setTableMarker } from '#/lib/reservations'
import { getReservationSettings, updateReservationSettings } from '#/lib/settings' import { getReservationSettings, updateReservationSettings } from '#/lib/settings'
import { ACCEPTED_IMAGE_TYPES, MAX_TABLE_IMAGE_BYTES, readImageAsDataUrl } from '#/lib/table-image'
export const Route = createFileRoute('/staff/settings')({ export const Route = createFileRoute('/staff/settings')({
component: StaffSettings, component: StaffSettings,
@@ -19,8 +21,8 @@ export const Route = createFileRoute('/staff/settings')({
return { staffUser } return { staffUser }
}, },
loader: async ({ context }) => { loader: async ({ context }) => {
const settings = await getReservationSettings() const [settings, tables] = await Promise.all([getReservationSettings(), getTables()])
return { staffUser: context.staffUser, settings } return { staffUser: context.staffUser, settings, tables }
}, },
}) })
@@ -29,21 +31,35 @@ type FormState = {
reservationDurationMinutes: number reservationDurationMinutes: number
minLeadTimeMinutes: number minLeadTimeMinutes: number
maxLeadTimeDays: number maxLeadTimeDays: number
tableDisplayMode: 'individual' | 'overview'
tableOverviewImageUrl: string | null
} }
const MAX_IMAGE_MB = Math.floor(MAX_TABLE_IMAGE_BYTES / 1_000_000)
function StaffSettings() { function StaffSettings() {
const router = useRouter() const router = useRouter()
const { staffUser, settings } = Route.useLoaderData() const { staffUser, settings, tables } = Route.useLoaderData()
const [form, setForm] = useState<FormState>({ const [form, setForm] = useState<FormState>({
reservationIntervalMinutes: settings.reservationIntervalMinutes, reservationIntervalMinutes: settings.reservationIntervalMinutes,
reservationDurationMinutes: settings.reservationDurationMinutes, reservationDurationMinutes: settings.reservationDurationMinutes,
minLeadTimeMinutes: settings.minLeadTimeMinutes, minLeadTimeMinutes: settings.minLeadTimeMinutes,
maxLeadTimeDays: settings.maxLeadTimeDays, maxLeadTimeDays: settings.maxLeadTimeDays,
tableDisplayMode: settings.tableDisplayMode === 'overview' ? 'overview' : 'individual',
tableOverviewImageUrl: settings.tableOverviewImageUrl,
}) })
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const overviewFileInputRef = useRef<HTMLInputElement>(null)
const [markerTableId, setMarkerTableId] = useState<string>('')
const [isPlacingMarker, setIsPlacingMarker] = useState(false)
const [markerError, setMarkerError] = useState<string | null>(null)
const [markerSuccess, setMarkerSuccess] = useState<string | null>(null)
const markerTable = tables.find((table) => String(table.id) === markerTableId)
const tablesWithoutMarker = tables.filter((table) => table.markerX === null || table.markerY === null)
async function handleLogout() { async function handleLogout() {
await authClient.signOut() await authClient.signOut()
@@ -51,6 +67,67 @@ function StaffSettings() {
await router.invalidate() await router.invalidate()
} }
async function handleOverviewImageChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
setError('Bild muss PNG, JPEG oder WebP sein')
return
}
if (file.size > MAX_TABLE_IMAGE_BYTES) {
setError(`Bild ist zu groß (max. ${MAX_IMAGE_MB} MB)`)
return
}
setError(null)
const dataUrl = await readImageAsDataUrl(file)
setForm((f) => ({ ...f, tableOverviewImageUrl: dataUrl }))
}
async function handlePlaceMarker(event: React.MouseEvent<HTMLDivElement>) {
if (!markerTable) {
setMarkerError('Bitte zuerst einen Tisch für den Marker auswählen')
return
}
const rect = event.currentTarget.getBoundingClientRect()
const x = Math.min(100, Math.max(0, ((event.clientX - rect.left) / rect.width) * 100))
const y = Math.min(100, Math.max(0, ((event.clientY - rect.top) / rect.height) * 100))
setMarkerError(null)
setMarkerSuccess(null)
setIsPlacingMarker(true)
try {
await setTableMarker({ data: { id: markerTable.id, marker: { x, y } } })
setMarkerSuccess(`Marker für ${markerTable.name} wurde gesetzt.`)
await router.invalidate()
} catch (err) {
setMarkerError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsPlacingMarker(false)
}
}
async function handleClearMarker() {
if (!markerTable) return
setMarkerError(null)
setMarkerSuccess(null)
setIsPlacingMarker(true)
try {
await setTableMarker({ data: { id: markerTable.id, marker: null } })
setMarkerSuccess(`Marker für ${markerTable.name} wurde entfernt.`)
await router.invalidate()
} catch (err) {
setMarkerError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsPlacingMarker(false)
}
}
async function handleSubmit(event: React.FormEvent) { async function handleSubmit(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
setError(null) setError(null)
@@ -161,6 +238,62 @@ function StaffSettings() {
Wie weit im Voraus reserviert werden kann, z. B. 60 für „höchstens 60 Tage im Voraus“. Wie weit im Voraus reserviert werden kann, z. B. 60 für „höchstens 60 Tage im Voraus“.
</p> </p>
<fieldset className="table-display-fieldset">
<legend>Tischfotos im Buchungsformular</legend>
<label className="radio-option">
<input
type="radio"
name="tableDisplayMode"
checked={form.tableDisplayMode === 'individual'}
onChange={() => setForm((f) => ({ ...f, tableDisplayMode: 'individual' }))}
/>
Foto des gewählten Tisches
</label>
<label className="radio-option">
<input
type="radio"
name="tableDisplayMode"
checked={form.tableDisplayMode === 'overview'}
onChange={() => setForm((f) => ({ ...f, tableDisplayMode: 'overview' }))}
/>
Ein Übersichtsfoto für alle Tische
</label>
</fieldset>
<p className="field-hint">
„Foto des gewählten Tisches“ zeigt das jeweilige Foto aus der Tischverwaltung, sobald ein
Tisch ausgewählt wird. „Übersichtsfoto“ zeigt stattdessen immer dasselbe Bild, z. B. eine
Aufnahme des ganzen Raums.
</p>
{form.tableDisplayMode === 'overview' && (
<>
<label>
Übersichtsfoto
<div className="table-image-field">
{form.tableOverviewImageUrl && (
<img className="table-image-preview" src={form.tableOverviewImageUrl} alt="" />
)}
<input
ref={overviewFileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleOverviewImageChange}
/>
{form.tableOverviewImageUrl && (
<button
type="button"
className="btn btn-secondary"
onClick={() => setForm((f) => ({ ...f, tableOverviewImageUrl: null }))}
>
Bild entfernen
</button>
)}
</div>
</label>
<p className="field-hint">PNG, JPEG oder WebP, max. {MAX_IMAGE_MB} MB.</p>
</>
)}
<button type="submit" disabled={isSubmitting}> <button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gespeichert …' : 'Speichern'} {isSubmitting ? 'Wird gespeichert …' : 'Speichern'}
</button> </button>
@@ -169,6 +302,71 @@ function StaffSettings() {
{success && <p className="message message-success">{success}</p>} {success && <p className="message message-success">{success}</p>}
</form> </form>
</section> </section>
{settings.tableDisplayMode === 'overview' && settings.tableOverviewImageUrl && (
<section className="staff-form-section">
<h2 className="section-heading staff-subheading">Tisch-Marker</h2>
<p className="field-hint">
Wählen Sie einen Tisch aus und klicken Sie dann auf das gespeicherte Übersichtsfoto, um
seinen Marker zu setzen oder zu verschieben.
</p>
<div className="reservation-form">
<label>
Marker setzen für
<select value={markerTableId} onChange={(e) => setMarkerTableId(e.target.value)}>
<option value="" disabled>
Tisch auswählen
</option>
{tables.map((table) => (
<option key={table.id} value={table.id}>
{table.tableNumber === null ? table.name : `${table.tableNumber} — ${table.name}`}
{table.markerX === null || table.markerY === null ? '' : ' ✓'}
</option>
))}
</select>
</label>
<div
className={`table-overview-wrap${isPlacingMarker ? ' is-busy' : ''}`}
onClick={handlePlaceMarker}
>
<img className="table-overview-image" src={settings.tableOverviewImageUrl} alt="" />
{tables
.filter((table) => table.markerX !== null && table.markerY !== null)
.map((table) => (
<span
key={table.id}
className={`table-marker${String(table.id) === markerTableId ? ' is-active' : ''}`}
style={{ left: `${table.markerX}%`, top: `${table.markerY}%` }}
>
<span className="table-marker-label">{table.tableNumber ?? '?'}</span>
</span>
))}
</div>
{markerTable && (markerTable.markerX !== null || markerTable.markerY !== null) && (
<button
type="button"
className="btn btn-secondary"
disabled={isPlacingMarker}
onClick={handleClearMarker}
>
Marker für {markerTable.name} entfernen
</button>
)}
{tablesWithoutMarker.length > 0 && (
<p className="field-hint">
Noch ohne Marker: {tablesWithoutMarker.map((table) => table.name).join(', ')}
</p>
)}
{markerError && <p className="message message-error">{markerError}</p>}
{markerSuccess && <p className="message message-success">{markerSuccess}</p>}
</div>
</section>
)}
</main> </main>
</div> </div>
) )
+338
View File
@@ -0,0 +1,338 @@
import { useRef, useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { ConfirmDialog } from '#/components/confirm-dialog'
import { StaffTabs } from '#/components/staff-tabs'
import { TableDetailDialog } from '#/components/table-detail-dialog'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { createTable, deleteTable, getTables, updateTable } from '#/lib/reservations'
import { ACCEPTED_IMAGE_TYPES, MAX_TABLE_IMAGE_BYTES, readImageAsDataUrl } from '#/lib/table-image'
export const Route = createFileRoute('/staff/tables')({
component: StaffTables,
beforeLoad: async () => {
const staffUser = await getCurrentStaffUser()
if (!staffUser) {
throw redirect({ to: '/staff/login' })
}
if (staffUser.role !== 'admin') {
throw redirect({ to: '/staff' })
}
return { staffUser }
},
loader: async ({ context }) => {
const tables = await getTables()
return { staffUser: context.staffUser, tables }
},
})
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
type FormState = {
name: string
tableNumber: number
seats: number
imageUrl: string | null
}
function emptyForm(): FormState {
return { name: '', tableNumber: 1, seats: 2, imageUrl: null }
}
const MAX_IMAGE_MB = Math.floor(MAX_TABLE_IMAGE_BYTES / 1_000_000)
function StaffTables() {
const router = useRouter()
const { staffUser, tables } = Route.useLoaderData()
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [pendingDelete, setPendingDelete] = useState<TableRow | null>(null)
const [viewingTable, setViewingTable] = useState<TableRow | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
function startEdit(table: TableRow) {
setEditingId(table.id)
setForm({
name: table.name,
tableNumber: table.tableNumber ?? 0,
seats: table.seats,
imageUrl: table.imageUrl,
})
setError(null)
setSuccess(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
function cancelEdit() {
setEditingId(null)
setForm(emptyForm())
setError(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
async function handleImageChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
setError('Bild muss PNG, JPEG oder WebP sein')
return
}
if (file.size > MAX_TABLE_IMAGE_BYTES) {
setError(`Bild ist zu groß (max. ${MAX_IMAGE_MB} MB)`)
return
}
setError(null)
const dataUrl = await readImageAsDataUrl(file)
setForm((f) => ({ ...f, imageUrl: dataUrl }))
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
setSuccess(null)
setIsSubmitting(true)
try {
const payload = {
name: form.name,
tableNumber: Number(form.tableNumber),
seats: Number(form.seats),
imageUrl: form.imageUrl,
}
if (editingId) {
await updateTable({ data: { id: editingId, ...payload } })
setSuccess('Tisch wurde aktualisiert.')
} else {
await createTable({ data: payload })
setSuccess('Tisch wurde angelegt.')
}
cancelEdit()
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
}
async function confirmDelete() {
const table = pendingDelete
if (!table) return
setPendingDelete(null)
setDeletingId(table.id)
setError(null)
setSuccess(null)
try {
await deleteTable({ data: { id: table.id } })
if (editingId === table.id) cancelEdit()
setSuccess('Tisch wurde gelöscht.')
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setDeletingId(null)
}
}
async function handleLogout() {
await authClient.signOut()
await router.navigate({ to: '/staff/login' })
await router.invalidate()
}
return (
<div className="staff-page">
<div className="staff-nav">
<header className="staff-topbar">
<Link className="wordmark" to="/">
Steakhaus <span>bei Mirko</span>
</Link>
<div className="staff-topbar-right">
<span>Angemeldet als {staffUser.name}</span>
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
Abmelden
</button>
</div>
</header>
<div className="staff-tabs-bar">
<StaffTabs role={staffUser.role} />
</div>
</div>
<main className="page staff-main">
<h1 className="section-heading">Tische</h1>
<section className="staff-form-section">
<h2 className="section-heading staff-subheading">
{editingId ? 'Tisch bearbeiten' : 'Neuer Tisch'}
</h2>
<form className="reservation-form" onSubmit={handleSubmit}>
<div className="form-row">
<label>
Tischnummer
<input
type="number"
inputMode="numeric"
min={1}
value={form.tableNumber}
onChange={(e) => setForm((f) => ({ ...f, tableNumber: Number(e.target.value) }))}
required
/>
</label>
<label>
Plätze
<input
type="number"
inputMode="numeric"
min={1}
max={50}
value={form.seats}
onChange={(e) => setForm((f) => ({ ...f, seats: Number(e.target.value) }))}
required
/>
</label>
</div>
<label>
Name
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</label>
<p className="field-hint">
Die Tischnummer wird auf dem Marker angezeigt; der Name ist nur für die Verwaltung hier.
</p>
<label>
Foto
<div className="table-image-field">
{form.imageUrl && <img className="table-image-preview" src={form.imageUrl} alt="" />}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleImageChange}
/>
{form.imageUrl && (
<button
type="button"
className="btn btn-secondary"
onClick={() => setForm((f) => ({ ...f, imageUrl: null }))}
>
Bild entfernen
</button>
)}
</div>
</label>
<p className="field-hint">
Zeigt Gästen im Buchungsformular, wie der Tisch aussieht. PNG, JPEG oder WebP, max.{' '}
{MAX_IMAGE_MB} MB.
</p>
<div className="form-actions">
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gespeichert …' : editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" className="btn btn-secondary" onClick={cancelEdit}>
Abbrechen
</button>
)}
</div>
{error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form>
</section>
<section className="staff-table-section">
{tables.length === 0 ? (
<p className="empty-state">Noch keine Tische vorhanden.</p>
) : (
<div className="table-scroll">
<table className="reservations-table reservations-table-clickable">
<thead>
<tr>
<th></th>
<th>Nr.</th>
<th>Name</th>
<th>Plätze</th>
<th></th>
</tr>
</thead>
<tbody>
{tables.map((table) => (
<tr
key={table.id}
className={editingId === table.id ? 'is-editing' : undefined}
onClick={() => setViewingTable(table)}
>
<td>
{table.imageUrl ? (
<img className="table-thumb" src={table.imageUrl} alt="" />
) : (
<span className="table-thumb table-thumb-empty" aria-hidden="true" />
)}
</td>
<td>{table.tableNumber ?? '—'}</td>
<td>{table.name}</td>
<td>{table.seats}</td>
<td onClick={(event) => event.stopPropagation()}>
<div className="table-actions">
<button type="button" className="btn btn-secondary" onClick={() => startEdit(table)}>
Bearbeiten
</button>
<button
type="button"
className="btn btn-danger"
disabled={deletingId === table.id}
onClick={() => setPendingDelete(table)}
>
{deletingId === table.id ? 'Löschen …' : 'Löschen'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
<ConfirmDialog
open={pendingDelete !== null}
title="Tisch löschen"
message={pendingDelete && <>Tisch <strong>{pendingDelete.name}</strong> wirklich löschen?</>}
warning="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen"
danger
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
<TableDetailDialog table={viewingTable} onClose={() => setViewingTable(null)} />
</div>
)
}
+91 -9
View File
@@ -1,9 +1,12 @@
import { useState } from 'react' import { useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router' import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { ConfirmDialog } from '#/components/confirm-dialog'
import { StaffMemberDetailDialog } from '#/components/staff-member-detail-dialog'
import { StaffTabs } from '#/components/staff-tabs' import { StaffTabs } from '#/components/staff-tabs'
import { getCurrentStaffUser } from '#/lib/auth' import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client' import { authClient } from '#/lib/auth-client'
import { phoneSchema } from '#/lib/phone'
import { listStaffMembers } from '#/lib/staff' import { listStaffMembers } from '#/lib/staff'
export const Route = createFileRoute('/staff/team')({ export const Route = createFileRoute('/staff/team')({
@@ -29,12 +32,13 @@ type StaffMember = Awaited<ReturnType<typeof listStaffMembers>>[number]
type FormState = { type FormState = {
name: string name: string
email: string email: string
phone: string
password: string password: string
role: 'admin' | 'user' role: 'admin' | 'user'
} }
function emptyForm(): FormState { function emptyForm(): FormState {
return { name: '', email: '', password: '', role: 'user' } return { name: '', email: '', phone: '', password: '', role: 'user' }
} }
function StaffTeam() { function StaffTeam() {
@@ -46,11 +50,20 @@ function StaffTeam() {
const [success, setSuccess] = useState<string | null>(null) const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false) const [isSubmitting, setIsSubmitting] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null) const [busyId, setBusyId] = useState<string | null>(null)
const [viewingMember, setViewingMember] = useState<StaffMember | null>(null)
const [pendingRemove, setPendingRemove] = useState<StaffMember | null>(null)
async function handleCreate(event: React.FormEvent) { async function handleCreate(event: React.FormEvent) {
event.preventDefault() event.preventDefault()
setError(null) setError(null)
setSuccess(null) setSuccess(null)
const phoneResult = phoneSchema.safeParse(form.phone)
if (!phoneResult.success) {
setError('Bitte geben Sie eine gültige Telefonnummer an')
return
}
setIsSubmitting(true) setIsSubmitting(true)
const { error: createError } = await authClient.admin.createUser({ const { error: createError } = await authClient.admin.createUser({
@@ -58,6 +71,7 @@ function StaffTeam() {
email: form.email, email: form.email,
password: form.password, password: form.password,
role: form.role, role: form.role,
data: { phone: phoneResult.data },
}) })
if (createError) { if (createError) {
@@ -72,11 +86,11 @@ function StaffTeam() {
await router.invalidate() await router.invalidate()
} }
async function handleRemove(member: StaffMember) { async function confirmRemove() {
if (!window.confirm(`Zugang für ${member.name} wirklich entfernen?`)) { const member = pendingRemove
return if (!member) return
}
setPendingRemove(null)
setBusyId(member.id) setBusyId(member.id)
setError(null) setError(null)
setSuccess(null) setSuccess(null)
@@ -136,6 +150,38 @@ function StaffTeam() {
await router.invalidate() await router.invalidate()
} }
async function handleSetPhone(member: StaffMember) {
const newPhone = window.prompt(`Telefonnummer für ${member.name}:`, member.phone ?? '')
if (newPhone === null) {
return
}
const phoneResult = phoneSchema.safeParse(newPhone)
if (!phoneResult.success) {
setError('Bitte geben Sie eine gültige Telefonnummer an')
return
}
setBusyId(member.id)
setError(null)
setSuccess(null)
const { error: phoneError } = await authClient.admin.updateUser({
userId: member.id,
data: { phone: phoneResult.data },
})
if (phoneError) {
setError(phoneError.message ?? 'Etwas ist schiefgelaufen')
} else {
setSuccess(`Telefonnummer für ${member.name} wurde aktualisiert.`)
await router.invalidate()
}
setBusyId(null)
}
async function handleSetPassword(member: StaffMember) { async function handleSetPassword(member: StaffMember) {
const newPassword = window.prompt(`Neues Passwort für ${member.name} (mind. 8 Zeichen):`) const newPassword = window.prompt(`Neues Passwort für ${member.name} (mind. 8 Zeichen):`)
@@ -210,6 +256,19 @@ function StaffTeam() {
</label> </label>
</div> </div>
<label>
Telefon
<input
type="tel"
inputMode="tel"
pattern="[+]?[0-9()\-.\s]{6,30}"
title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
required
/>
</label>
<div className="form-row"> <div className="form-row">
<label> <label>
Passwort Passwort
@@ -245,11 +304,12 @@ function StaffTeam() {
<section className="staff-table-section"> <section className="staff-table-section">
<div className="table-scroll"> <div className="table-scroll">
<table className="reservations-table"> <table className="reservations-table reservations-table-clickable">
<thead> <thead>
<tr> <tr>
<th>Name</th> <th>Name</th>
<th>E-Mail</th> <th>E-Mail</th>
<th>Telefon</th>
<th>Rolle</th> <th>Rolle</th>
<th>Status</th> <th>Status</th>
<th></th> <th></th>
@@ -257,12 +317,13 @@ function StaffTeam() {
</thead> </thead>
<tbody> <tbody>
{members.map((member) => ( {members.map((member) => (
<tr key={member.id}> <tr key={member.id} onClick={() => setViewingMember(member)}>
<td>{member.name}</td> <td>{member.name}</td>
<td>{member.email}</td> <td>{member.email}</td>
<td>{member.phone || <span className="muted">—</span>}</td>
<td>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</td> <td>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</td>
<td>{member.banned ? 'Gesperrt' : 'Aktiv'}</td> <td>{member.banned ? 'Gesperrt' : 'Aktiv'}</td>
<td> <td onClick={(event) => event.stopPropagation()}>
<div className="table-actions"> <div className="table-actions">
<button <button
type="button" type="button"
@@ -272,6 +333,14 @@ function StaffTeam() {
> >
{member.role === 'admin' ? 'Zu Mitarbeiter machen' : 'Zu Admin machen'} {member.role === 'admin' ? 'Zu Mitarbeiter machen' : 'Zu Admin machen'}
</button> </button>
<button
type="button"
className="btn btn-secondary"
disabled={busyId === member.id}
onClick={() => handleSetPhone(member)}
>
Telefon bearbeiten
</button>
<button <button
type="button" type="button"
className="btn btn-secondary" className="btn btn-secondary"
@@ -292,7 +361,7 @@ function StaffTeam() {
type="button" type="button"
className="btn btn-danger" className="btn btn-danger"
disabled={busyId === member.id || member.id === staffUser.id} disabled={busyId === member.id || member.id === staffUser.id}
onClick={() => handleRemove(member)} onClick={() => setPendingRemove(member)}
> >
Entfernen Entfernen
</button> </button>
@@ -305,6 +374,19 @@ function StaffTeam() {
</div> </div>
</section> </section>
</main> </main>
<StaffMemberDetailDialog member={viewingMember} onClose={() => setViewingMember(null)} />
<ConfirmDialog
open={pendingRemove !== null}
title="Zugang entfernen"
message={pendingRemove && <>Zugang für <strong>{pendingRemove.name}</strong> wirklich entfernen?</>}
warning="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Entfernen"
danger
onConfirm={confirmRemove}
onCancel={() => setPendingRemove(null)}
/>
</div> </div>
) )
} }
+832 -21
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -7,6 +7,9 @@ import viteReact from '@vitejs/plugin-react'
const config = defineConfig({ const config = defineConfig({
resolve: { tsconfigPaths: true }, resolve: { tsconfigPaths: true },
plugins: [tanstackStart(), viteReact()], plugins: [tanstackStart(), viteReact()],
preview: {
allowedHosts: ['localhost', '127.0.0.1', 'promo-steakhouse.lukas-harat.com'],
}
}) })
export default config export default config