Compare commits

..
1 Commits
Author SHA1 Message Date
lukas bb18170b3f wip
CI / build-and-test (pull_request) Failing after 51s
2026-08-25 12:09:17 +02:00
58 changed files with 439 additions and 8552 deletions
-20
View File
@@ -1,20 +0,0 @@
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
+63 -129
View File
@@ -1,29 +1,42 @@
name: CI
on:
push:
branches:
- main
- proof
workflow_dispatch:
env:
REGISTRY: git.lukas-harat.com
IMAGE_NAME: ${{ github.repository }}
permissions:
contents: read
packages: write
pull_request:
branches: ['**']
jobs:
test:
build-and-test:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: steakhouse
POSTGRES_PASSWORD: steakhouse
POSTGRES_DB: steakhouse
ports:
- 5432:5432
options: >-
--health-cmd "pg_isready -U steakhouse"
--health-interval 5s
--health-timeout 5s
--health-retries 10
env:
DATABASE_URL: postgres://steakhouse:steakhouse@localhost:5432/steakhouse
BETTER_AUTH_SECRET: ci-only-secret-not-used-in-production
BETTER_AUTH_URL: http://localhost:4173
STAFF_SEED_EMAIL: admin@example.com
STAFF_SEED_PASSWORD: ci-only-password-not-used-in-production
RESEND_API_KEY: re_ci_dummy_key
RESERVATIONS_FROM_EMAIL: 'Steakhaus bei Mirko <onboarding@resend.dev>'
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
@@ -35,126 +48,47 @@ jobs:
- name: Type check
run: npx tsc --noEmit
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 }}
- name: Check that Drizzle migrations are up to date
run: |
set -euo pipefail
npx drizzle-kit generate
if [ -n "$(git status --porcelain drizzle)" ]; then
echo "::error::src/db/schema.ts changed without a matching migration. Run 'npm run db:generate' and commit the result."
git status --porcelain drizzle
git diff drizzle
exit 1
fi
mkdir -p ~/.ssh
- name: Apply migrations
run: npm run db:migrate
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
- name: Seed baseline data
run: npm run db:seed
ssh-keyscan \
-p "${DEPLOY_PORT:-22}" \
-H "$DEPLOY_HOST" \
>> ~/.ssh/known_hosts
- name: Build
run: npm run build
echo "Creating deployment directory if needed..."
- name: Smoke test the production build
run: |
npx vite preview --port 4173 &
PREVIEW_PID=$!
ssh \
-i ~/.ssh/deploy_key \
-p "${DEPLOY_PORT:-22}" \
"$DEPLOY_USER@$DEPLOY_HOST" \
"mkdir -p '$DEPLOY_PATH'"
for i in $(seq 1 30); do
curl -sf http://localhost:4173/ >/dev/null 2>&1 && break
sleep 1
done
echo "Uploading compose file..."
check() {
code=$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:4173$1")
if [ "$code" != "$2" ]; then
echo "::error::GET $1 returned $code, expected $2"
kill "$PREVIEW_PID"
exit 1
fi
echo "GET $1 -> $code"
}
scp \
-i ~/.ssh/deploy_key \
-P "${DEPLOY_PORT:-22}" \
docker-compose.prod.yml \
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
check / 200
check /staff/login 200
check /impressum 200
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.\"
'"
kill "$PREVIEW_PID"
-31
View File
@@ -1,31 +0,0 @@
# 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
@@ -1,35 +0,0 @@
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
@@ -1 +0,0 @@
ALTER TABLE "restaurant_tables" ADD COLUMN "image_url" text;
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE "restaurant_settings" ADD COLUMN "table_display_mode" text DEFAULT 'individual' NOT NULL;--> statement-breakpoint
ALTER TABLE "restaurant_settings" ADD COLUMN "table_overview_image_url" text;
-2
View File
@@ -1,2 +0,0 @@
ALTER TABLE "restaurant_tables" ADD COLUMN "marker_x" double precision;--> statement-breakpoint
ALTER TABLE "restaurant_tables" ADD COLUMN "marker_y" double precision;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "restaurant_tables" ADD COLUMN "table_number" integer;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "reservations" ADD COLUMN "message" text;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "reservations" ADD COLUMN "customer_phone" text;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "user" ADD COLUMN "phone" text;
-1
View File
@@ -1 +0,0 @@
ALTER TABLE "reservations" ADD COLUMN "status" text DEFAULT 'confirmed' NOT NULL;
-533
View File
@@ -1,533 +0,0 @@
{
"id": "05eac8a1-b172-4e4d-90d2-a409ecde610a",
"prevId": "d03b9103-cb6a-4968-a016-c04270ec176a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-546
View File
@@ -1,546 +0,0 @@
{
"id": "71bc6bc2-516c-4af1-8c61-3df7814e9976",
"prevId": "05eac8a1-b172-4e4d-90d2-a409ecde610a",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-558
View File
@@ -1,558 +0,0 @@
{
"id": "cc3da65b-b162-4866-ac44-b7d177588316",
"prevId": "71bc6bc2-516c-4af1-8c61-3df7814e9976",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-564
View File
@@ -1,564 +0,0 @@
{
"id": "0914058c-53fc-4e93-b703-a907e0ae72a8",
"prevId": "cc3da65b-b162-4866-ac44-b7d177588316",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-570
View File
@@ -1,570 +0,0 @@
{
"id": "c95ecb29-7866-4794-9aad-8a6fba8e7bc4",
"prevId": "0914058c-53fc-4e93-b703-a907e0ae72a8",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-576
View File
@@ -1,576 +0,0 @@
{
"id": "69162a80-7a1b-480b-85c2-7f11e2f1725c",
"prevId": "c95ecb29-7866-4794-9aad-8a6fba8e7bc4",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_phone": {
"name": "customer_phone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-582
View File
@@ -1,582 +0,0 @@
{
"id": "b5488545-3b1f-4145-a565-f4fcdc5b3748",
"prevId": "69162a80-7a1b-480b-85c2-7f11e2f1725c",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_phone": {
"name": "customer_phone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-589
View File
@@ -1,589 +0,0 @@
{
"id": "b91348a4-3a6f-4c6b-a7a0-c60c06eeb9f9",
"prevId": "b5488545-3b1f-4145-a565-f4fcdc5b3748",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.account": {
"name": "account",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"provider_id": {
"name": "provider_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"id_token": {
"name": "id_token",
"type": "text",
"primaryKey": false,
"notNull": false
},
"access_token_expires_at": {
"name": "access_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"refresh_token_expires_at": {
"name": "refresh_token_expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"scope": {
"name": "scope",
"type": "text",
"primaryKey": false,
"notNull": false
},
"password": {
"name": "password",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
}
},
"indexes": {},
"foreignKeys": {
"account_user_id_user_id_fk": {
"name": "account_user_id_user_id_fk",
"tableFrom": "account",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.reservations": {
"name": "reservations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"table_id": {
"name": "table_id",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"customer_name": {
"name": "customer_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_email": {
"name": "customer_email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"customer_phone": {
"name": "customer_phone",
"type": "text",
"primaryKey": false,
"notNull": false
},
"message": {
"name": "message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"party_size": {
"name": "party_size",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'confirmed'"
},
"reservation_time": {
"name": "reservation_time",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"duration_minutes": {
"name": "duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"cancel_token": {
"name": "cancel_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "gen_random_uuid()"
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {
"reservations_table_id_restaurant_tables_id_fk": {
"name": "reservations_table_id_restaurant_tables_id_fk",
"tableFrom": "reservations",
"tableTo": "restaurant_tables",
"columnsFrom": [
"table_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"reservations_cancel_token_unique": {
"name": "reservations_cancel_token_unique",
"nullsNotDistinct": false,
"columns": [
"cancel_token"
]
},
"reservations_table_id_reservation_time_unique": {
"name": "reservations_table_id_reservation_time_unique",
"nullsNotDistinct": false,
"columns": [
"table_id",
"reservation_time"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_settings": {
"name": "restaurant_settings",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"reservation_interval_minutes": {
"name": "reservation_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 15
},
"reservation_duration_minutes": {
"name": "reservation_duration_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 90
},
"min_lead_time_minutes": {
"name": "min_lead_time_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"max_lead_time_days": {
"name": "max_lead_time_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 60
},
"table_display_mode": {
"name": "table_display_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'individual'"
},
"table_overview_image_url": {
"name": "table_overview_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.restaurant_tables": {
"name": "restaurant_tables",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "serial",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"seats": {
"name": "seats",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"table_number": {
"name": "table_number",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"marker_x": {
"name": "marker_x",
"type": "double precision",
"primaryKey": false,
"notNull": false
},
"marker_y": {
"name": "marker_y",
"type": "double precision",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.session": {
"name": "session",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"token": {
"name": "token",
"type": "text",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"ip_address": {
"name": "ip_address",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_agent": {
"name": "user_agent",
"type": "text",
"primaryKey": false,
"notNull": false
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"impersonated_by": {
"name": "impersonated_by",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {
"session_user_id_user_id_fk": {
"name": "session_user_id_user_id_fk",
"tableFrom": "session",
"tableTo": "user",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"session_token_unique": {
"name": "session_token_unique",
"nullsNotDistinct": false,
"columns": [
"token"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.user": {
"name": "user",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": true
},
"email_verified": {
"name": "email_verified",
"type": "boolean",
"primaryKey": false,
"notNull": true
},
"image": {
"name": "image",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"role": {
"name": "role",
"type": "text",
"primaryKey": false,
"notNull": false
},
"banned": {
"name": "banned",
"type": "boolean",
"primaryKey": false,
"notNull": false
},
"ban_reason": {
"name": "ban_reason",
"type": "text",
"primaryKey": false,
"notNull": false
},
"ban_expires": {
"name": "ban_expires",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"phone": {
"name": "phone",
"type": "text",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"user_email_unique": {
"name": "user_email_unique",
"nullsNotDistinct": false,
"columns": [
"email"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.verification": {
"name": "verification",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"identifier": {
"name": "identifier",
"type": "text",
"primaryKey": false,
"notNull": true
},
"value": {
"name": "value",
"type": "text",
"primaryKey": false,
"notNull": true
},
"expires_at": {
"name": "expires_at",
"type": "timestamp",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
},
"updated_at": {
"name": "updated_at",
"type": "timestamp",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
-56
View File
@@ -29,62 +29,6 @@
"when": 1787646264161,
"tag": "0003_big_jack_flag",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1787683584543,
"tag": "0004_wide_thunderbolt",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1787684215918,
"tag": "0005_nice_stark_industries",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1787684576667,
"tag": "0006_uneven_captain_cross",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1787685540508,
"tag": "0007_dusty_payback",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1787686033197,
"tag": "0008_even_toxin",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1787686357931,
"tag": "0009_nosy_alice",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1787688761615,
"tag": "0010_common_masque",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1787690760361,
"tag": "0011_cloudy_gwen_stacy",
"breakpoints": true
}
]
}
-278
View File
@@ -10,7 +10,6 @@
"@tanstack/react-start": "latest",
"better-auth": "^1.6.29",
"drizzle-orm": "^0.45.2",
"pdfjs-dist": "^6.2.108",
"postgres": "^3.4.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
@@ -1311,271 +1310,6 @@
"@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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.3.0.tgz",
@@ -3998,18 +3732,6 @@
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
"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": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
-1
View File
@@ -21,7 +21,6 @@
"@tanstack/react-start": "latest",
"better-auth": "^1.6.29",
"drizzle-orm": "^0.45.2",
"pdfjs-dist": "^6.2.108",
"postgres": "^3.4.9",
"react": "^19.2.0",
"react-dom": "^19.2.0",
-5
View File
@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 371 B

-1
View File
@@ -1 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 3.4 KiB

BIN
View File
Binary file not shown.
-108
View File
@@ -1,108 +0,0 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
type Props = {
label?: string
children: React.ReactNode
}
function DotsIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
<circle cx="12" cy="5" r="1.8" />
<circle cx="12" cy="12" r="1.8" />
<circle cx="12" cy="19" r="1.8" />
</svg>
)
}
type Position = { top: number; right: number }
// A small "⋮" menu that reveals its children (typically a stack of `.btn`s) in a
// dropdown — used to tuck row actions away instead of listing every button inline.
// The panel is portaled to <body> and positioned with `position: fixed` from a
// measured rect rather than anchored with `position: absolute` in place, since
// rows live inside `.table-scroll` (`overflow-x: auto`, which per the CSS spec
// forces `overflow-y` to clip too) — an in-place dropdown on the last row or two
// would otherwise get cut off at the table's bottom edge.
export function ActionMenu({ label = 'Aktionen', children }: Props) {
const [position, setPosition] = useState<Position | null>(null)
const triggerRef = useRef<HTMLButtonElement>(null)
const panelRef = useRef<HTMLDivElement>(null)
const open = position !== null
function close() {
setPosition(null)
}
function toggle() {
if (open) {
close()
return
}
const rect = triggerRef.current?.getBoundingClientRect()
if (!rect) return
setPosition({ top: rect.bottom + 4, right: window.innerWidth - rect.right })
}
useEffect(() => {
if (!open) return
function handleOutsideClick(event: MouseEvent) {
const target = event.target as Node
if (!triggerRef.current?.contains(target) && !panelRef.current?.contains(target)) {
close()
}
}
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') close()
}
// The menu's position is measured once, on open — rather than track scroll to
// keep it glued to the trigger, just close it, matching how most native and
// web dropdowns behave when the page moves under them.
function handleScroll() {
close()
}
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleKeyDown)
window.addEventListener('scroll', handleScroll, true)
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('scroll', handleScroll, true)
}
}, [open])
return (
<>
<button
ref={triggerRef}
type="button"
className="action-menu-trigger"
aria-label={label}
aria-haspopup="true"
aria-expanded={open}
onClick={toggle}
>
<DotsIcon />
</button>
{open &&
position &&
createPortal(
<div
ref={panelRef}
className="action-menu-panel"
role="menu"
style={{ top: position.top, right: position.right }}
onClick={close}
>
{children}
</div>,
document.body,
)}
</>
)
}
+1 -27
View File
@@ -3,11 +3,7 @@ import { useEffect, useRef } from 'react'
type Props = {
open: boolean
title: string
message: React.ReactNode
// A final, visually distinct callout for what irreversible actions this
// confirms — kept separate from `message` so callers don't have to hand-roll
// the emphasis every time (e.g. "This cannot be undone.").
warning?: React.ReactNode
message: string
confirmLabel?: string
cancelLabel?: string
danger?: boolean
@@ -15,26 +11,10 @@ type Props = {
onCancel: () => void
}
function WarningIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M12 3.5 21.5 20h-19L12 3.5Z"
stroke="currentColor"
strokeWidth="1.6"
strokeLinejoin="round"
/>
<path d="M12 9.5v4.2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<circle cx="12" cy="16.7" r="1" fill="currentColor" />
</svg>
)
}
export function ConfirmDialog({
open,
title,
message,
warning,
confirmLabel = 'Bestätigen',
cancelLabel = 'Abbrechen',
danger,
@@ -74,12 +54,6 @@ export function ConfirmDialog({
<p id="confirm-dialog-message" className="confirm-dialog-message">
{message}
</p>
{warning && (
<div className="confirm-dialog-warning">
<WarningIcon />
<span>{warning}</span>
</div>
)}
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onCancel}>
{cancelLabel}
-191
View File
@@ -1,191 +0,0 @@
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>
)
}
@@ -1,108 +0,0 @@
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>
)
}
+2 -49
View File
@@ -14,9 +14,6 @@ type Props = {
// made — both configurable restaurant settings, falling back to sane defaults.
intervalMinutes?: 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,
// since not every caller of this field needs the restriction.
maxLeadTimeDays?: number
@@ -104,7 +101,6 @@ export function ReservationDateTimeField({
bookedTimes = [],
intervalMinutes = RESERVATION_INTERVAL_MINUTES,
minLeadTimeMinutes = 0,
durationMinutes = RESERVATION_INTERVAL_MINUTES,
maxLeadTimeDays,
}: Props) {
const { date, time } = splitValue(value)
@@ -137,45 +133,10 @@ export function ReservationDateTimeField({
document.addEventListener('mousedown', handleOutsideClick)
document.addEventListener('keydown', handleKeyDown)
// Only locks the background on mobile, where the popover becomes a fixed,
// full-screen overlay that would otherwise let the page behind it scroll —
// on desktop the popover is just a small anchored dropdown, and pinning the
// body there only removes the scrollbar and shifts the whole page sideways.
// Matches the `@media (max-width: 30rem)` breakpoint the popover-to-modal
// CSS switches on.
const isMobile = window.matchMedia('(max-width: 30rem)').matches
if (!isMobile) {
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
}
}
// `overflow: hidden` alone doesn't stop touch-driven rubber-band scrolling on
// iOS Safari, so the body is pinned in place instead and the scroll position
// restored after.
const scrollY = window.scrollY
const body = document.body.style
const previousStyle = { position: body.position, top: body.top, left: body.left, right: body.right }
body.position = 'fixed'
body.top = `-${scrollY}px`
body.left = '0'
body.right = '0'
return () => {
document.removeEventListener('mousedown', handleOutsideClick)
document.removeEventListener('keydown', handleKeyDown)
body.position = previousStyle.position
body.top = previousStyle.top
body.left = previousStyle.left
body.right = previousStyle.right
// `behavior: 'instant'` is required, not just the default — the page sets
// `scroll-behavior: smooth` globally, which would otherwise animate this
// restore from the top of the page instead of snapping back in place.
window.scrollTo({ top: scrollY, left: 0, behavior: 'instant' })
}
// Only re-run when the popover opens/closes — reopening should reset to the
// currently selected month, not follow every keystroke in the text field.
// eslint-disable-next-line react-hooks/exhaustive-deps
@@ -193,7 +154,7 @@ export function ReservationDateTimeField({
// otherwise) can't slip through with a leftover, still-"valid"-looking time.
function optionsForDate(dateStr: string) {
if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return []
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes, durationMinutes)
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes)
}
function isSelectableDate(dateStr: string) {
@@ -289,14 +250,7 @@ export function ReservationDateTimeField({
</button>
{isCalendarOpen && (
<div className="date-popover-backdrop" onClick={() => setIsCalendarOpen(false)}>
<div
className="date-popover"
role="dialog"
aria-modal="true"
aria-label="Datum auswählen"
onClick={(event) => event.stopPropagation()}
>
<div className="date-popover" role="dialog" aria-label="Datum auswählen">
<div className="date-popover-header">
<button
type="button"
@@ -337,7 +291,6 @@ export function ReservationDateTimeField({
))}
</div>
</div>
</div>
)}
</div>
-456
View File
@@ -1,456 +0,0 @@
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>
)
}
@@ -1,83 +0,0 @@
import { useEffect } from 'react'
import type { listStaffMembers } from '#/lib/staff'
type StaffMember = Awaited<ReturnType<typeof listStaffMembers>>[number]
type Props = {
member: StaffMember | null
onClose: () => void
}
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
timeStyle: 'short',
})
export function StaffMemberDetailDialog({ member, onClose }: Props) {
useEffect(() => {
if (!member) return
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [member, onClose])
if (!member) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-detail-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="staff-member-detail-title"
onClick={(event) => event.stopPropagation()}
>
<h2 id="staff-member-detail-title" className="confirm-dialog-title">
{member.name}
</h2>
<dl className="detail-list">
<div>
<dt>E-Mail</dt>
<dd>
<a href={`mailto:${member.email}`}>{member.email}</a>
</dd>
</div>
<div>
<dt>Telefon</dt>
<dd>{member.phone ? <a href={`tel:${member.phone}`}>{member.phone}</a> : '—'}</dd>
</div>
<div>
<dt>Rolle</dt>
<dd>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</dd>
</div>
<div>
<dt>Status</dt>
<dd>{member.banned ? 'Gesperrt' : 'Aktiv'}</dd>
</div>
{member.banned && member.banReason && (
<div>
<dt>Sperrgrund</dt>
<dd>{member.banReason}</dd>
</div>
)}
<div>
<dt>Angelegt am</dt>
<dd>{dateFormatter.format(new Date(member.createdAt))}</dd>
</div>
</dl>
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
)
}
-10
View File
@@ -16,16 +16,6 @@ export function StaffTabs({ role }: Props) {
>
Reservierungen
</Link>
{role === 'admin' && (
<Link
className="staff-tab"
to="/staff/tables"
activeProps={{ className: 'is-active' }}
viewTransition
>
Tische
</Link>
)}
{role === 'admin' && (
<Link
className="staff-tab"
-5
View File
@@ -1,5 +0,0 @@
import { RESERVATION_STATUS_LABELS, type ReservationStatus } from '#/lib/reservations'
export function StatusBadge({ status }: { status: ReservationStatus }) {
return <span className={`status-badge status-${status}`}>{RESERVATION_STATUS_LABELS[status]}</span>
}
-67
View File
@@ -1,67 +0,0 @@
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
@@ -1,68 +0,0 @@
import { useEffect } from 'react'
import type { getTables } from '#/lib/reservations'
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
type Props = {
table: TableRow | null
onClose: () => void
}
export function TableDetailDialog({ table, onClose }: Props) {
useEffect(() => {
if (!table) return
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [table, onClose])
if (!table) return null
return (
<div className="confirm-dialog-backdrop" onClick={onClose}>
<div
className="confirm-dialog reservation-detail-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="table-detail-title"
onClick={(event) => event.stopPropagation()}
>
<h2 id="table-detail-title" className="confirm-dialog-title">
{table.name}
</h2>
{table.imageUrl && <img className="table-preview" src={table.imageUrl} alt="" />}
<dl className="detail-list">
<div>
<dt>Tischnummer</dt>
<dd>{table.tableNumber ?? '—'}</dd>
</div>
<div>
<dt>Name</dt>
<dd>{table.name}</dd>
</div>
<div>
<dt>Plätze</dt>
<dd>{table.seats}</dd>
</div>
<div>
<dt>Marker</dt>
<dd>{table.markerX !== null && table.markerY !== null ? 'Gesetzt' : 'Nicht gesetzt'}</dd>
</div>
</dl>
<div className="confirm-dialog-actions">
<button type="button" className="btn btn-secondary" onClick={onClose}>
Schließen
</button>
</div>
</div>
</div>
)
}
+1 -36
View File
@@ -1,24 +1,10 @@
import { sql } from 'drizzle-orm'
import { boolean, doublePrecision, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
import { boolean, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
export const restaurantTables = pgTable('restaurant_tables', {
id: serial('id').primaryKey(),
name: text('name').notNull(),
seats: integer('seats').notNull(),
// The number shown to guests (on the table's marker, etc.) — distinct from
// `name`, which can be a description like "Eck Tisch". Nullable so existing
// rows created before this field don't need a value until staff set one.
tableNumber: integer('table_number'),
// A data: URL of the uploaded photo, stored inline rather than as a file on disk —
// there's no separate object storage in this app, and this keeps the image tied
// to the row with no extra infra. Null until staff upload one.
imageUrl: text('image_url'),
// Where this table's pin sits on the settings overview photo, as a percentage
// (0-100) of that image's width/height — resolution-independent, so the same
// marker lines up whether it's rendered in the admin editor or on the public
// booking page. Both null until staff place one; always set together.
markerX: doublePrecision('marker_x'),
markerY: doublePrecision('marker_y'),
})
export const reservations = pgTable(
@@ -30,20 +16,7 @@ export const reservations = pgTable(
.references(() => restaurantTables.id, { onDelete: 'cascade' }),
customerName: text('customer_name').notNull(),
customerEmail: text('customer_email').notNull(),
// Nullable so existing rows created before this field don't need a value —
// required going forward by the create/update zod schema instead.
customerPhone: text('customer_phone'),
// An optional free-text note from whoever booked — allergies, a birthday, a
// seating preference. Null rather than '' when nothing was entered.
message: text('message'),
partySize: integer('party_size').notNull(),
// 'confirmed' | 'no_show' | 'completed' — an app-level enum (validated by the
// zod schema in lib/reservations.ts, not a pg enum), matching the pattern
// already used for restaurant_settings.table_display_mode. Every reservation
// is confirmed at booking time, so that's the default; staff mark the other
// two afterward. Cancelling still deletes the row (and emails the guest) —
// this doesn't introduce a "cancelled" status.
status: text('status').notNull().default('confirmed'),
reservationTime: timestamp('reservation_time', { withTimezone: true }).notNull(),
// Captured from the reservation-duration setting at booking time, so a later
// change to that setting doesn't retroactively change how long past bookings
@@ -65,11 +38,6 @@ export const restaurantSettings = pgTable('restaurant_settings', {
reservationDurationMinutes: integer('reservation_duration_minutes').notNull().default(90),
minLeadTimeMinutes: integer('min_lead_time_minutes').notNull().default(60),
maxLeadTimeDays: integer('max_lead_time_days').notNull().default(60),
// Whether the booking form shows a photo of the selected table ('individual') or
// a single always-visible photo of the whole room ('overview') — an app-level
// enum, validated by the zod schema in lib/settings.ts rather than a pg enum.
tableDisplayMode: text('table_display_mode').notNull().default('individual'),
tableOverviewImageUrl: text('table_overview_image_url'),
})
// Better Auth's standard schema (staff accounts) — see https://www.better-auth.com/docs/concepts/database
@@ -86,9 +54,6 @@ export const user = pgTable('user', {
banned: boolean('banned'),
banReason: text('ban_reason'),
banExpires: timestamp('ban_expires'),
// Registered with Better Auth as a `user.additionalFields` entry in
// auth-instance.ts — not part of its base schema, same as `role`/`banned` above.
phone: text('phone'),
})
export const session = pgTable('session', {
-13
View File
@@ -24,19 +24,6 @@ function createAuth() {
// Staff accounts are provisioned via the seed script, not public self sign-up.
disableSignUp: true,
},
user: {
additionalFields: {
// `required` here only governs Better Auth's own self-service sign-up/
// update-user flows, which this app doesn't use for staff — accounts go
// through admin.createUser, whose `data` field isn't schema-validated by
// Better Auth, so the actual "required" enforcement lives in the staff
// team UI (staff.team.tsx) instead.
phone: {
type: 'string',
required: true,
},
},
},
session: {
expiresIn: 60 * 60 * 12,
},
-15
View File
@@ -42,7 +42,6 @@ function getResendClient() {
type ReservationEmailInfo = {
customerName: string
customerEmail: string
message?: string | null
partySize: number
reservationTime: Date
cancelToken: string
@@ -132,19 +131,6 @@ function renderReservationEmailHtml(
</table>
</td>
</tr>
${
reservation.message
? `
<tr>
<td style="padding-top:16px;">
<div style="color:${COLORS.textMuted};font:400 12px/1.4 'Helvetica Neue',Arial,sans-serif;letter-spacing:0.03em;text-transform:uppercase;padding-bottom:4px;">
Nachricht
</div>
<div style="color:${COLORS.text};font-size:14px;">${escapeHtml(reservation.message)}</div>
</td>
</tr>`
: ''
}
${
showActions
? `
@@ -204,7 +190,6 @@ function renderReservationEmailText(
`Tisch: ${reservation.table.name}`,
`Datum & Uhrzeit: ${formattedTime}`,
`Personen: ${reservation.partySize}`,
...(reservation.message ? [`Nachricht: ${reservation.message}`] : []),
'',
...(showActions
? [
+4 -15
View File
@@ -57,16 +57,12 @@ export function isOnReservationInterval(date: Date, intervalMinutes: number = RE
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
}
// `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) {
export function isWithinOpeningHours(date: Date) {
const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date)
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
return ranges.some(
(range) =>
minutesSinceMidnight >= toMinutes(range.start) &&
minutesSinceMidnight + durationMinutes <= toMinutes(range.end),
(range) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight < toMinutes(range.end),
)
}
@@ -93,15 +89,9 @@ export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
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(
ranges: OpeningRange[],
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): string[] {
const options: string[] = []
@@ -109,7 +99,7 @@ export function generateTimeOptions(
const startMinutes = toMinutes(range.start)
const endMinutes = toMinutes(range.end)
for (let minutes = startMinutes; minutes + durationMinutes <= endMinutes; minutes += intervalMinutes) {
for (let minutes = startMinutes; minutes < endMinutes; minutes += intervalMinutes) {
const hours = Math.floor(minutes / 60)
const remainder = minutes % 60
options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`)
@@ -122,14 +112,13 @@ export function generateTimeOptions(
export function getNextAvailableSlot(
from: Date,
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
durationMinutes: number = intervalMinutes,
): { date: string; time: string } | null {
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
const day = new Date(from)
day.setDate(from.getDate() + dayOffset)
const dateStr = formatDateOnly(day)
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes)
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes)
for (const time of options) {
const slot = new Date(`${dateStr}T${time}`)
-15
View File
@@ -1,15 +0,0 @@
import { z } from 'zod'
// Shared between the customer reservation form (lib/reservations.ts) and the
// staff phone field (routes/staff.team.tsx), so "a valid phone number" means
// the same thing everywhere in the app.
export const phoneSchema = z
.string()
.trim()
.max(30, 'Enter a valid phone number')
// Optional leading `+` plus digits and common separators (spaces, dashes,
// dots, parentheses) — permissive about international formats, but the
// character class alone would still accept e.g. "()---" or "+", so the
// refine below requires it to actually contain a real number of digits.
.regex(/^\+?[0-9()\-.\s]+$/, 'Enter a valid phone number')
.refine((value) => (value.match(/\d/g)?.length ?? 0) >= 6, 'Enter a valid phone number')
+10 -200
View File
@@ -11,10 +11,7 @@ import {
sendReservationUpdateEmail,
} from '#/lib/email'
import { isOnReservationInterval, isWithinOpeningHours } from '#/lib/opening-hours'
import { phoneSchema } from '#/lib/phone'
import { zodValidator } from '#/lib/server-validator'
import { loadReservationSettings } from '#/lib/settings'
import { dataUrlImageSchema } from '#/lib/table-image'
function formatLeadTime(minutes: number) {
if (minutes % 60 === 0) {
@@ -29,7 +26,6 @@ function assertValidReservationSlot(
intervalMinutes: number,
minLeadTimeMinutes: number,
maxLeadTimeDays: number,
durationMinutes: number,
) {
const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000
@@ -51,33 +47,28 @@ function assertValidReservationSlot(
throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`)
}
if (!isWithinOpeningHours(reservationTime, durationMinutes)) {
throw new Error('Zu dieser Zeit reicht die verbleibende Zeit bis zum Schließen nicht mehr aus')
if (!isWithinOpeningHours(reservationTime)) {
throw new Error('Zu dieser Zeit haben wir leider geschlossen')
}
}
// A table is occupied for its own configured duration, not just the exact minute it
// was booked for — so overlapping bookings (not just identical ones) are conflicts.
// Each reservation's own stored duration is used (not the current setting), since an
// older booking keeps whatever duration was in effect when it was made. `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.
// older booking keeps whatever duration was in effect when it was made.
async function findOverlappingReservation(
tableId: number,
start: Date,
durationMinutes: number,
bufferMinutes: number,
excludeId?: number,
) {
const end = start.getTime() + durationMinutes * 60_000
const bufferMs = bufferMinutes * 60_000
const candidates = await db.select().from(reservations).where(eq(reservations.tableId, tableId))
return candidates.find((r) => {
if (excludeId !== undefined && r.id === excludeId) return false
const existingStart = r.reservationTime.getTime() - bufferMs
const existingEnd = existingStart + r.durationMinutes * 60_000 + 2 * bufferMs
const existingStart = r.reservationTime.getTime()
const existingEnd = existingStart + r.durationMinutes * 60_000
return existingStart < end && start.getTime() < existingEnd
})
}
@@ -86,120 +77,6 @@ export const getTables = createServerFn({ method: 'GET' }).handler(async () => {
return db.select().from(restaurantTables).orderBy(restaurantTables.name)
})
const tableSchema = z.object({
name: z.string().trim().min(1, 'Name ist erforderlich').max(120),
tableNumber: z.number().int().positive('Tischnummer muss positiv sein'),
seats: z.number().int().min(1, 'Mindestens 1 Platz').max(50),
imageUrl: dataUrlImageSchema,
})
export const createTable = createServerFn({ method: 'POST' })
.validator(zodValidator(tableSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
const [table] = await db.insert(restaurantTables).values(data).returning()
return table
})
const updateTableSchema = tableSchema.extend({
id: z.number().int().positive(),
})
export const updateTable = createServerFn({ method: 'POST' })
.validator(zodValidator(updateTableSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
const [updated] = await db
.update(restaurantTables)
.set({ name: data.name, tableNumber: data.tableNumber, seats: data.seats, imageUrl: data.imageUrl })
.where(eq(restaurantTables.id, data.id))
.returning()
if (!updated) {
throw new Error('Tisch wurde nicht gefunden')
}
return updated
})
const deleteTableSchema = z.object({
id: z.number().int().positive(),
})
export const deleteTable = createServerFn({ method: 'POST' })
.validator(zodValidator(deleteTableSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
// The FK from reservations to this table cascades on delete, which would
// silently wipe out (and never notify) any guest booked on it. Deleting
// reservations individually first goes through deleteReservation, which
// does send a cancellation email.
const [existingReservation] = await db
.select({ id: reservations.id })
.from(reservations)
.where(eq(reservations.tableId, data.id))
.limit(1)
if (existingReservation) {
throw new Error(
'Tisch kann nicht gelöscht werden, solange Reservierungen dafür bestehen — bitte zuerst verschieben oder löschen',
)
}
const [deleted] = await db.delete(restaurantTables).where(eq(restaurantTables.id, data.id)).returning()
if (!deleted) {
throw new Error('Tisch wurde nicht gefunden')
}
return deleted
})
const setTableMarkerSchema = z.object({
id: z.number().int().positive(),
// Both coordinates set together (a marker) or both cleared (none) — there's no
// meaningful state for only one of them being present.
marker: z
.object({
x: z.number().min(0).max(100),
y: z.number().min(0).max(100),
})
.nullable(),
})
export const setTableMarker = createServerFn({ method: 'POST' })
.validator(zodValidator(setTableMarkerSchema))
.handler(async ({ data }) => {
const session = await requireStaffSession()
if (session.user.role !== 'admin') {
throw new Error('Nur Admins dürfen Tische verwalten')
}
const [updated] = await db
.update(restaurantTables)
.set({ markerX: data.marker?.x ?? null, markerY: data.marker?.y ?? null })
.where(eq(restaurantTables.id, data.id))
.returning()
if (!updated) {
throw new Error('Tisch wurde nicht gefunden')
}
return updated
})
export const getReservations = createServerFn({ method: 'GET' }).handler(async () => {
await requireStaffSession()
@@ -208,13 +85,9 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
id: reservations.id,
tableId: reservations.tableId,
tableName: restaurantTables.name,
tableNumber: restaurantTables.tableNumber,
customerName: reservations.customerName,
customerEmail: reservations.customerEmail,
customerPhone: reservations.customerPhone,
message: reservations.message,
partySize: reservations.partySize,
status: reservations.status,
reservationTime: reservations.reservationTime,
durationMinutes: reservations.durationMinutes,
})
@@ -223,30 +96,10 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
.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({
tableId: z.number().int().positive(),
customerName: z.string().trim().min(1, 'Name is required').max(120),
customerEmail: z.string().trim().email('Enter a valid email'),
customerPhone: phoneSchema,
message: z
.string()
.trim()
.max(500, 'Message must be at most 500 characters')
.optional()
.transform((value) => value || null),
partySize: z.number().int().min(1, 'At least 1 guest').max(20),
reservationTime: z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
message: 'Enter a valid date and time',
@@ -254,7 +107,7 @@ const createReservationSchema = z.object({
})
export const createReservation = createServerFn({ method: 'POST' })
.validator(zodValidator(createReservationSchema))
.validator(createReservationSchema)
.handler(async ({ data }) => {
const settings = await loadReservationSettings()
const reservationTime = new Date(data.reservationTime)
@@ -264,7 +117,6 @@ export const createReservation = createServerFn({ method: 'POST' })
settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes,
settings.maxLeadTimeDays,
settings.reservationDurationMinutes,
)
const [table] = await db
@@ -284,7 +136,6 @@ export const createReservation = createServerFn({ method: 'POST' })
data.tableId,
reservationTime,
settings.reservationDurationMinutes,
settings.minLeadTimeMinutes,
)
if (conflict) {
@@ -297,8 +148,6 @@ export const createReservation = createServerFn({ method: 'POST' })
tableId: data.tableId,
customerName: data.customerName,
customerEmail: data.customerEmail,
customerPhone: data.customerPhone,
message: data.message,
durationMinutes: settings.reservationDurationMinutes,
partySize: data.partySize,
reservationTime,
@@ -308,7 +157,6 @@ export const createReservation = createServerFn({ method: 'POST' })
await sendReservationConfirmationEmail({
customerName: reservation.customerName,
customerEmail: reservation.customerEmail,
message: reservation.message,
partySize: reservation.partySize,
reservationTime: reservation.reservationTime,
cancelToken: reservation.cancelToken,
@@ -323,7 +171,7 @@ const updateReservationSchema = createReservationSchema.extend({
})
export const updateReservation = createServerFn({ method: 'POST' })
.validator(zodValidator(updateReservationSchema))
.validator(updateReservationSchema)
.handler(async ({ data }) => {
await requireStaffSession()
@@ -335,7 +183,6 @@ export const updateReservation = createServerFn({ method: 'POST' })
settings.reservationIntervalMinutes,
settings.minLeadTimeMinutes,
settings.maxLeadTimeDays,
settings.reservationDurationMinutes,
)
const [table] = await db
@@ -355,7 +202,6 @@ export const updateReservation = createServerFn({ method: 'POST' })
data.tableId,
reservationTime,
settings.reservationDurationMinutes,
settings.minLeadTimeMinutes,
data.id,
)
@@ -369,8 +215,6 @@ export const updateReservation = createServerFn({ method: 'POST' })
tableId: data.tableId,
customerName: data.customerName,
customerEmail: data.customerEmail,
customerPhone: data.customerPhone,
message: data.message,
partySize: data.partySize,
reservationTime,
durationMinutes: settings.reservationDurationMinutes,
@@ -385,7 +229,6 @@ export const updateReservation = createServerFn({ method: 'POST' })
await sendReservationUpdateEmail({
customerName: updated.customerName,
customerEmail: updated.customerEmail,
message: updated.message,
partySize: updated.partySize,
reservationTime: updated.reservationTime,
cancelToken: updated.cancelToken,
@@ -395,45 +238,12 @@ export const updateReservation = createServerFn({ method: 'POST' })
return updated
})
export const reservationStatusSchema = z.enum(['confirmed', 'no_show', 'completed'])
export type ReservationStatus = z.infer<typeof reservationStatusSchema>
export const RESERVATION_STATUS_LABELS: Record<ReservationStatus, string> = {
confirmed: 'Bestätigt',
no_show: 'Nicht erschienen',
completed: 'Abgeschlossen',
}
const updateReservationStatusSchema = z.object({
id: z.number().int().positive(),
status: reservationStatusSchema,
})
export const updateReservationStatus = createServerFn({ method: 'POST' })
.validator(zodValidator(updateReservationStatusSchema))
.handler(async ({ data }) => {
await requireStaffSession()
const [updated] = await db
.update(reservations)
.set({ status: data.status })
.where(eq(reservations.id, data.id))
.returning()
if (!updated) {
throw new Error('Reservation not found')
}
return updated
})
const deleteReservationSchema = z.object({
id: z.number().int().positive(),
})
export const deleteReservation = createServerFn({ method: 'POST' })
.validator(zodValidator(deleteReservationSchema))
.validator(deleteReservationSchema)
.handler(async ({ data }) => {
await requireStaffSession()
@@ -467,7 +277,7 @@ const getReservationByCancelTokenSchema = z.object({
})
export const getReservationByCancelToken = createServerFn({ method: 'GET' })
.validator(zodValidator(getReservationByCancelTokenSchema))
.validator(getReservationByCancelTokenSchema)
.handler(async ({ data }) => {
const [reservation] = await db
.select({
@@ -488,7 +298,7 @@ const cancelReservationByTokenSchema = z.object({
})
export const cancelReservationByToken = createServerFn({ method: 'POST' })
.validator(zodValidator(cancelReservationByTokenSchema))
.validator(cancelReservationByTokenSchema)
.handler(async ({ data }) => {
const [deleted] = await db
.delete(reservations)
-17
View File
@@ -1,17 +0,0 @@
import type { z, ZodType } from 'zod'
// `createServerFn().validator(schema)` passes a zod schema straight through to
// TanStack Start, which on failure throws `new Error(JSON.stringify(issues))` —
// the raw issue array, not a message meant for a user to read. Wrapping the
// schema in a plain function (`.validator(zodValidator(schema))`) routes
// validation through this instead, so a rejected field surfaces its own zod
// `.message` (e.g. "Enter a valid phone number") rather than a JSON blob.
export function zodValidator<Schema extends ZodType>(schema: Schema) {
return (input: unknown): z.infer<Schema> => {
const result = schema.safeParse(input)
if (!result.success) {
throw new Error(result.error.issues[0]?.message ?? 'Invalid input')
}
return result.data
}
}
+1 -5
View File
@@ -5,8 +5,6 @@ import { z } from 'zod'
import { db } from '#/db'
import { restaurantSettings } from '#/db/schema'
import { requireStaffSession } from '#/lib/auth'
import { zodValidator } from '#/lib/server-validator'
import { dataUrlImageSchema } from '#/lib/table-image'
// Reads never require a session — the public booking form needs the interval and
// lead time to generate its own time-slot grid, just like getTables(). Exported
@@ -33,12 +31,10 @@ const updateReservationSettingsSchema = z.object({
reservationDurationMinutes: z.number().int().min(15).max(480),
minLeadTimeMinutes: z.number().int().min(0).max(10080),
maxLeadTimeDays: z.number().int().min(1).max(365),
tableDisplayMode: z.enum(['individual', 'overview']),
tableOverviewImageUrl: dataUrlImageSchema,
})
export const updateReservationSettings = createServerFn({ method: 'POST' })
.validator(zodValidator(updateReservationSettingsSchema))
.validator(updateReservationSettingsSchema)
.handler(async ({ data }) => {
console.log('[settings] updateReservationSettings called with', data)
+1 -5
View File
@@ -11,9 +11,5 @@ export const listStaffMembers = createServerFn({ method: 'GET' }).handler(async
query: { sortBy: 'createdAt', sortDirection: 'asc', limit: 100 },
})
// `listUsers`'s return type doesn't pick up `user.additionalFields` from the
// auth config the way the actual response body does — `phone` is present at
// runtime (Better Auth includes every configured additional field on every
// user-returning endpoint) but missing from admin.d.ts's `UserWithRole` type.
return users as Array<(typeof users)[number] & { phone: string | null }>
return users
})
-24
View File
@@ -1,24 +0,0 @@
import { z } from 'zod'
// Generous enough for a photo, small enough to keep rows (and the customer-facing
// page that loads them up front) from bloating — these are data: URLs stored
// inline, not uploads to disk-backed storage, which this app doesn't have.
export const MAX_TABLE_IMAGE_BYTES = 2_000_000
export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp']
export const dataUrlImageSchema = z
.string()
.trim()
.max(Math.ceil((MAX_TABLE_IMAGE_BYTES * 4) / 3) + 100)
.regex(/^data:image\/(png|jpeg|jpg|webp);base64,/, 'Bild muss als PNG, JPEG oder WebP hochgeladen werden')
.nullable()
export function readImageAsDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(reader.error ?? new Error('Datei konnte nicht gelesen werden'))
reader.readAsDataURL(file)
})
}
-21
View File
@@ -14,7 +14,6 @@ import { Route as ImpressumRouteImport } from './routes/impressum'
import { Route as StaffIndexRouteImport } from './routes/staff.index'
import { Route as StaffLoginRouteImport } from './routes/staff.login'
import { Route as StaffSettingsRouteImport } from './routes/staff.settings'
import { Route as StaffTablesRouteImport } from './routes/staff.tables'
import { Route as StaffTeamRouteImport } from './routes/staff.team'
import { Route as StornierenTokenRouteImport } from './routes/stornieren.$token'
@@ -43,11 +42,6 @@ const StaffSettingsRoute = StaffSettingsRouteImport.update({
path: '/staff/settings',
getParentRoute: () => rootRouteImport,
} as any)
const StaffTablesRoute = StaffTablesRouteImport.update({
id: '/staff/tables',
path: '/staff/tables',
getParentRoute: () => rootRouteImport,
} as any)
const StaffTeamRoute = StaffTeamRouteImport.update({
id: '/staff/team',
path: '/staff/team',
@@ -64,7 +58,6 @@ export interface FileRoutesByFullPath {
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute
'/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute
'/staff/': typeof StaffIndexRoute
@@ -74,7 +67,6 @@ export interface FileRoutesByTo {
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute
'/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute
'/staff': typeof StaffIndexRoute
@@ -85,7 +77,6 @@ export interface FileRoutesById {
'/impressum': typeof ImpressumRoute
'/staff/login': typeof StaffLoginRoute
'/staff/settings': typeof StaffSettingsRoute
'/staff/tables': typeof StaffTablesRoute
'/staff/team': typeof StaffTeamRoute
'/stornieren/$token': typeof StornierenTokenRoute
'/staff/': typeof StaffIndexRoute
@@ -97,7 +88,6 @@ export interface FileRouteTypes {
| '/impressum'
| '/staff/login'
| '/staff/settings'
| '/staff/tables'
| '/staff/team'
| '/stornieren/$token'
| '/staff/'
@@ -107,7 +97,6 @@ export interface FileRouteTypes {
| '/impressum'
| '/staff/login'
| '/staff/settings'
| '/staff/tables'
| '/staff/team'
| '/stornieren/$token'
| '/staff'
@@ -117,7 +106,6 @@ export interface FileRouteTypes {
| '/impressum'
| '/staff/login'
| '/staff/settings'
| '/staff/tables'
| '/staff/team'
| '/stornieren/$token'
| '/staff/'
@@ -128,7 +116,6 @@ export interface RootRouteChildren {
ImpressumRoute: typeof ImpressumRoute
StaffLoginRoute: typeof StaffLoginRoute
StaffSettingsRoute: typeof StaffSettingsRoute
StaffTablesRoute: typeof StaffTablesRoute
StaffTeamRoute: typeof StaffTeamRoute
StornierenTokenRoute: typeof StornierenTokenRoute
StaffIndexRoute: typeof StaffIndexRoute
@@ -171,13 +158,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof StaffSettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/staff/tables': {
id: '/staff/tables'
path: '/staff/tables'
fullPath: '/staff/tables'
preLoaderRoute: typeof StaffTablesRouteImport
parentRoute: typeof rootRouteImport
}
'/staff/team': {
id: '/staff/team'
path: '/staff/team'
@@ -200,7 +180,6 @@ const rootRouteChildren: RootRouteChildren = {
ImpressumRoute: ImpressumRoute,
StaffLoginRoute: StaffLoginRoute,
StaffSettingsRoute: StaffSettingsRoute,
StaffTablesRoute: StaffTablesRoute,
StaffTeamRoute: StaffTeamRoute,
StornierenTokenRoute: StornierenTokenRoute,
StaffIndexRoute: StaffIndexRoute,
-5
View File
@@ -26,11 +26,6 @@ export const Route = createRootRoute({
rel: 'stylesheet',
href: appCss,
},
{
rel: 'icon',
href: '/favicon.svg',
type: 'image/svg+xml',
},
],
}),
shellComponent: RootDocument,
+1 -1
View File
@@ -57,7 +57,7 @@ function Impressum() {
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
</p>
<h2 id="datenschutz">Datenschutz</h2>
<h2>Datenschutz</h2>
<p>
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
oben genannte Drenica &amp; Mousa GbR.
+27 -201
View File
@@ -1,120 +1,61 @@
import { useState } from 'react'
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 { SiteFooter } from '#/components/site-footer'
import { SiteHeader } from '#/components/site-header'
import { formatDateOnly } from '#/lib/datetime'
import {
generateTimeOptions,
getNextAvailableSlot,
getOpeningHoursForDisplay,
getOpeningRangesForWeekday,
getTodayLabelBerlin,
} from '#/lib/opening-hours'
import { createReservation, getReservationAvailability, getTables } from '#/lib/reservations'
import { getNextAvailableSlot, getOpeningHoursForDisplay, getTodayLabelBerlin } from '#/lib/opening-hours'
import { createReservation, getTables } from '#/lib/reservations'
import { getReservationSettings } from '#/lib/settings'
export const Route = createFileRoute('/')({
component: Home,
loader: async () => {
const [tables, settings, availability] = await Promise.all([
getTables(),
getReservationSettings(),
getReservationAvailability(),
])
return { tables, settings, availability }
const [tables, settings] = await Promise.all([getTables(), getReservationSettings()])
return { tables, settings }
},
})
const OPENING_HOURS = getOpeningHoursForDisplay()
const confirmedDateFormatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' })
const confirmedTimeFormatter = 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}$`)
type ConfirmedReservation = {
customerName: string
customerEmail: string
tableName: string
date: string
startTime: string
endTime: string
partySize: number
function FlameIcon() {
return (
<svg className="hero-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>
)
}
function Home() {
const router = useRouter()
const { tables, settings, availability } = Route.useLoaderData()
const { tables, settings } = Route.useLoaderData()
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
const [customerName, setCustomerName] = useState('')
const [customerEmail, setCustomerEmail] = useState('')
const [customerPhone, setCustomerPhone] = useState('')
const [message, setMessage] = useState('')
const [partySize, setPartySize] = useState(2)
const [acceptsTerms, setAcceptsTerms] = useState(false)
const [reservationTime, setReservationTime] = useState(() => {
const slot = getNextAvailableSlot(
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
settings.reservationIntervalMinutes,
settings.reservationDurationMinutes,
)
return slot ? `${slot.date}T${slot.time}` : ''
})
const [error, setError] = useState<string | null>(null)
const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
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) {
event.preventDefault()
setError(null)
setSuccess(null)
if (!tableId) {
setError('Bitte wählen Sie einen Tisch aus')
@@ -126,42 +67,22 @@ function Home() {
return
}
if (!acceptsTerms) {
setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu')
return
}
setIsSubmitting(true)
try {
const created = await createReservation({
await createReservation({
data: {
tableId: Number(tableId),
customerName,
customerEmail,
customerPhone,
message,
partySize: Number(partySize),
reservationTime: new Date(reservationTime).toISOString(),
},
})
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,
})
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
setCustomerName('')
setCustomerEmail('')
setCustomerPhone('')
setMessage('')
setPartySize(2)
setAcceptsTerms(false)
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
@@ -175,13 +96,13 @@ function Home() {
<SiteHeader />
<section className="hero" id="top">
<img className="hero-flame" src="/logo.svg" alt="" aria-hidden="true" />
<FlameIcon />
<h1>
Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
Steakhaus <span>bei Mirko</span>
</h1>
<p>
Dicke Steaks, knisterndes Holzkohlefeuer und Gelächter am Nachbartisch – so schmeckt ein
guter Abend mitten in Meppen. Reservieren Sie Ihren Tisch, wir legen schon auf.
Steaks vom Grill, gute Gesellschaft und ein gemütlicher Abend mitten in Meppen. Reservieren
Sie Ihren Tisch – wir freuen uns auf Sie.
</p>
<div className="hero-actions">
<a className="btn btn-primary" href="#reservieren">
@@ -215,16 +136,6 @@ function Home() {
</div>
</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">
<div className="section-grid">
<div>
@@ -247,36 +158,6 @@ function Home() {
</select>
</label>
{settings.tableDisplayMode === 'overview' ? (
settings.tableOverviewImageUrl && (
<div className="table-overview-wrap">
<img className="table-overview-image" src={settings.tableOverviewImageUrl} alt="Übersicht unserer Tische" />
{tables
.filter((table) => table.markerX !== null && table.markerY !== null)
.map((table) => (
<button
key={table.id}
type="button"
className={`table-marker${String(table.id) === String(tableId) ? ' is-selected' : ''}`}
style={{ left: `${table.markerX}%`, top: `${table.markerY}%` }}
onClick={() => setTableId(table.id)}
aria-label={`${table.name} auswählen`}
>
<span className="table-marker-label">{table.tableNumber ?? '?'}</span>
</button>
))}
</div>
)
) : (
selectedTable?.imageUrl && (
<img
className="table-preview"
src={selectedTable.imageUrl}
alt={`Ansicht von ${selectedTable.name}`}
/>
)
)}
<div className="form-row">
<label>
Name
@@ -300,19 +181,6 @@ function Home() {
</div>
<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>
Anzahl Personen
<input
@@ -325,58 +193,30 @@ function Home() {
required
/>
</label>
</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>
</div>
<p className="field-hint">
Verfügbare Uhrzeiten richten sich nach unseren{' '}
<a href="#oeffnungszeiten">Öffnungszeiten</a>.
</p>
<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}>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
</button>
{error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form>
</div>
</section>
@@ -410,20 +250,6 @@ function Home() {
</main>
<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)}
/>
)}
</>
)
}
+236 -158
View File
@@ -3,21 +3,18 @@ import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-rout
import { ConfirmDialog } from '#/components/confirm-dialog'
import { ReservationCharts } from '#/components/reservation-charts'
import { ReservationDialog, type ReservationPayload } from '#/components/reservation-dialog'
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
import { StaffTabs } from '#/components/staff-tabs'
import { StatusBadge } from '#/components/status-badge'
import { SuccessModal } from '#/components/success-modal'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
import {
RESERVATION_STATUS_LABELS,
createReservation,
deleteReservation,
getReservations,
getTables,
updateReservation,
updateReservationStatus,
type ReservationStatus,
} from '#/lib/reservations'
import { getReservationSettings } from '#/lib/settings'
@@ -40,11 +37,8 @@ export const Route = createFileRoute('/staff/')({
},
})
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
dateStyle: 'medium',
})
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
timeStyle: 'short',
})
@@ -56,22 +50,45 @@ function isSameDay(a: Date, b: Date) {
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() {
const router = useRouter()
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 [successModal, setSuccessModal] = useState<{ title: string; message: 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<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 [tableFilter, setTableFilter] = useState('all')
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('today')
const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all')
const dialogOpen = viewingReservation !== null || isCreating
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('all')
const now = new Date()
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
@@ -80,7 +97,6 @@ function StaffDashboard() {
const filteredReservations = reservations.filter((r) => {
if (tableFilter !== 'all' && String(r.tableId) !== tableFilter) return false
if (statusFilter !== 'all' && r.status !== statusFilter) return false
const reservationTime = new Date(r.reservationTime)
if (timeFilter === 'today' && !isSameDay(reservationTime, now)) return false
@@ -89,34 +105,99 @@ function StaffDashboard() {
const query = searchQuery.trim().toLowerCase()
if (query) {
const haystack = `${r.customerName} ${r.customerEmail} ${r.customerPhone ?? ''} ${r.tableName} ${r.tableNumber ?? ''}`.toLowerCase()
const haystack = `${r.customerName} ${r.customerEmail} ${r.tableName}`.toLowerCase()
if (!haystack.includes(query)) return false
}
return true
})
function closeDialog() {
setViewingReservation(null)
setIsCreating(false)
// Other reservations already sitting on the currently selected table + date, so the
// time picker can block them instead of letting staff pick a slot the server will
// just reject as double-booked. A reservation occupies its whole configured
// duration, not just its exact start time, so every interval-grid slot that falls
// inside that window counts as taken too.
const [selectedDate] = form.reservationTime.split('T')
const otherReservationsOnSelection = reservations.filter(
(r) =>
r.id !== editingId &&
String(r.tableId) === String(form.tableId) &&
formatDateOnly(new Date(r.reservationTime)) === selectedDate,
)
const bookedTimesForForm = selectedDate
? generateTimeOptions(
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
settings.reservationIntervalMinutes,
).filter((option) => {
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
return otherReservationsOnSelection.some((r) => {
const otherStart = new Date(r.reservationTime).getTime()
const otherEnd = otherStart + r.durationMinutes * 60_000
return slotStart >= otherStart && slotStart < otherEnd
})
})
: []
function startEdit(reservation: ReservationRow) {
setEditingId(reservation.id)
setForm({
tableId: reservation.tableId,
customerName: reservation.customerName,
customerEmail: reservation.customerEmail,
partySize: reservation.partySize,
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
})
setError(null)
setSuccess(null)
}
async function handleCreate(payload: ReservationPayload) {
function cancelEdit() {
setEditingId(null)
setForm(emptyForm(tables[0]?.id ?? '', settings))
setError(null)
}
async function handleSubmit(event: React.FormEvent) {
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 })
setSuccessModal({
title: 'Reservierung angelegt',
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich angelegt.`,
})
await router.invalidate()
setSuccess('Reservierung wurde angelegt.')
}
async function handleUpdate(id: number, payload: ReservationPayload) {
await updateReservation({ data: { id, ...payload } })
setSuccessModal({
title: 'Reservierung aktualisiert',
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich aktualisiert.`,
})
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() {
@@ -124,32 +205,21 @@ function StaffDashboard() {
if (!reservation) return
setPendingDelete(null)
setDeletingId(reservation.id)
setError(null)
setSuccess(null)
try {
await deleteReservation({ data: { id: reservation.id } })
setSuccessModal({
title: 'Reservierung gelöscht',
message: `Die Reservierung von ${reservation.customerName} wurde gelöscht.`,
})
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
if (editingId === reservation.id) {
cancelEdit()
}
}
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))
setSuccess('Reservierung wurde gelöscht.')
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsUpdatingStatus(false)
setDeletingId(null)
}
}
@@ -182,12 +252,6 @@ function StaffDashboard() {
<main className="page staff-main">
<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="stat-card">
<span className="stat-value">{reservations.length}</span>
@@ -209,14 +273,96 @@ function StaffDashboard() {
<ReservationCharts reservations={reservations} tables={tables} />
<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>
<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">
{reservations.length > 0 && (
<div className="table-filters">
<input
@@ -231,7 +377,7 @@ function StaffDashboard() {
<option value="all">Alle Tische</option>
{tables.map((table) => (
<option key={table.id} value={String(table.id)}>
{table.tableNumber === null ? table.name : `${table.tableNumber} — ${table.name}`}
{table.name}
</option>
))}
</select>
@@ -245,18 +391,6 @@ function StaffDashboard() {
<option value="upcoming">Anstehend</option>
<option value="past">Vergangen</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as typeof statusFilter)}
aria-label="Nach Status filtern"
>
<option value="all">Alle Status</option>
{Object.entries(RESERVATION_STATUS_LABELS).map(([status, label]) => (
<option key={status} value={status}>
{label}
</option>
))}
</select>
</div>
)}
@@ -268,64 +402,41 @@ function StaffDashboard() {
</p>
) : (
<div className="table-scroll">
<table className="reservations-table reservations-table-clickable">
<table className="reservations-table">
<thead>
<tr>
{timeFilter !== 'today' && <th>Datum</th>}
<th>Start</th>
<th>Ende</th>
<th>Nr.</th>
<th>Datum &amp; Uhrzeit</th>
<th>Tisch</th>
<th>Kunde</th>
<th>Personen</th>
<th>Status</th>
<th>Nachricht</th>
<th></th>
</tr>
</thead>
<tbody>
{filteredReservations.map((reservation) => (
<tr key={reservation.id} onClick={() => setViewingReservation(reservation)}>
{timeFilter !== 'today' && (
<td data-label="Datum" className="cell-datetime">
{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">
<tr key={reservation.id} className={editingId === reservation.id ? 'is-editing' : undefined}>
<td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td>
<td>{reservation.tableName}</td>
<td>
{reservation.customerName}
<br />
<span className="muted">{reservation.customerEmail}</span>
{reservation.customerPhone && (
<>
<br />
<span className="muted">{reservation.customerPhone}</span>
</>
)}
</td>
<td data-label="Personen" className="cell-party">
{reservation.partySize}
</td>
<td data-label="Status" className="cell-status">
<StatusBadge status={reservation.status as ReservationStatus} />
</td>
<td data-label="Nachricht" className="message-cell">
{reservation.message || <span className="muted">—</span>}
<td>{reservation.partySize}</td>
<td>
<div className="table-actions">
<button type="button" className="btn btn-secondary" onClick={() => startEdit(reservation)}>
Bearbeiten
</button>
<button
type="button"
className="btn btn-danger"
disabled={deletingId === reservation.id}
onClick={() => setPendingDelete(reservation)}
>
{deletingId === reservation.id ? 'Löschen …' : 'Löschen'}
</button>
</div>
</td>
</tr>
))}
@@ -340,48 +451,15 @@ function StaffDashboard() {
open={pendingDelete !== null}
title="Reservierung löschen"
message={
pendingDelete && (
<>
Reservierung von <strong>{pendingDelete.customerName}</strong> wirklich löschen? Der Gast
erhält eine E-Mail zur Bestätigung der Stornierung.
</>
)
pendingDelete
? `Reservierung von ${pendingDelete.customerName} wirklich löschen? Der Gast erhält eine E-Mail zur Bestätigung der Stornierung. Dies kann nicht rückgängig gemacht werden.`
: ''
}
warning="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen"
danger
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
{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>
)
}
+1 -42
View File
@@ -18,35 +18,6 @@ function ArrowLeftIcon() {
)
}
function EyeIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
</svg>
)
}
function EyeOffIcon() {
return (
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
<path
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
<path d="M4 4l16 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
)
}
export const Route = createFileRoute('/staff/login')({
component: StaffLogin,
beforeLoad: async () => {
@@ -62,7 +33,6 @@ function StaffLogin() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
@@ -108,23 +78,12 @@ function StaffLogin() {
<label>
Passwort
<div className="password-field">
<input
type={showPassword ? 'text' : 'password'}
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button
type="button"
className="password-toggle"
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
aria-pressed={showPassword}
onClick={() => setShowPassword((show) => !show)}
>
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
</button>
</div>
</label>
<button type="submit" disabled={isSubmitting}>
+4 -202
View File
@@ -1,12 +1,10 @@
import { useRef, useState } from 'react'
import { useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { StaffTabs } from '#/components/staff-tabs'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { getTables, setTableMarker } from '#/lib/reservations'
import { getReservationSettings, updateReservationSettings } from '#/lib/settings'
import { ACCEPTED_IMAGE_TYPES, MAX_TABLE_IMAGE_BYTES, readImageAsDataUrl } from '#/lib/table-image'
export const Route = createFileRoute('/staff/settings')({
component: StaffSettings,
@@ -21,8 +19,8 @@ export const Route = createFileRoute('/staff/settings')({
return { staffUser }
},
loader: async ({ context }) => {
const [settings, tables] = await Promise.all([getReservationSettings(), getTables()])
return { staffUser: context.staffUser, settings, tables }
const settings = await getReservationSettings()
return { staffUser: context.staffUser, settings }
},
})
@@ -31,35 +29,21 @@ type FormState = {
reservationDurationMinutes: number
minLeadTimeMinutes: number
maxLeadTimeDays: number
tableDisplayMode: 'individual' | 'overview'
tableOverviewImageUrl: string | null
}
const MAX_IMAGE_MB = Math.floor(MAX_TABLE_IMAGE_BYTES / 1_000_000)
function StaffSettings() {
const router = useRouter()
const { staffUser, settings, tables } = Route.useLoaderData()
const { staffUser, settings } = Route.useLoaderData()
const [form, setForm] = useState<FormState>({
reservationIntervalMinutes: settings.reservationIntervalMinutes,
reservationDurationMinutes: settings.reservationDurationMinutes,
minLeadTimeMinutes: settings.minLeadTimeMinutes,
maxLeadTimeDays: settings.maxLeadTimeDays,
tableDisplayMode: settings.tableDisplayMode === 'overview' ? 'overview' : 'individual',
tableOverviewImageUrl: settings.tableOverviewImageUrl,
})
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const overviewFileInputRef = useRef<HTMLInputElement>(null)
const [markerTableId, setMarkerTableId] = useState<string>('')
const [isPlacingMarker, setIsPlacingMarker] = useState(false)
const [markerError, setMarkerError] = useState<string | null>(null)
const [markerSuccess, setMarkerSuccess] = useState<string | null>(null)
const markerTable = tables.find((table) => String(table.id) === markerTableId)
const tablesWithoutMarker = tables.filter((table) => table.markerX === null || table.markerY === null)
async function handleLogout() {
await authClient.signOut()
@@ -67,67 +51,6 @@ function StaffSettings() {
await router.invalidate()
}
async function handleOverviewImageChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
setError('Bild muss PNG, JPEG oder WebP sein')
return
}
if (file.size > MAX_TABLE_IMAGE_BYTES) {
setError(`Bild ist zu groß (max. ${MAX_IMAGE_MB} MB)`)
return
}
setError(null)
const dataUrl = await readImageAsDataUrl(file)
setForm((f) => ({ ...f, tableOverviewImageUrl: dataUrl }))
}
async function handlePlaceMarker(event: React.MouseEvent<HTMLDivElement>) {
if (!markerTable) {
setMarkerError('Bitte zuerst einen Tisch für den Marker auswählen')
return
}
const rect = event.currentTarget.getBoundingClientRect()
const x = Math.min(100, Math.max(0, ((event.clientX - rect.left) / rect.width) * 100))
const y = Math.min(100, Math.max(0, ((event.clientY - rect.top) / rect.height) * 100))
setMarkerError(null)
setMarkerSuccess(null)
setIsPlacingMarker(true)
try {
await setTableMarker({ data: { id: markerTable.id, marker: { x, y } } })
setMarkerSuccess(`Marker für ${markerTable.name} wurde gesetzt.`)
await router.invalidate()
} catch (err) {
setMarkerError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsPlacingMarker(false)
}
}
async function handleClearMarker() {
if (!markerTable) return
setMarkerError(null)
setMarkerSuccess(null)
setIsPlacingMarker(true)
try {
await setTableMarker({ data: { id: markerTable.id, marker: null } })
setMarkerSuccess(`Marker für ${markerTable.name} wurde entfernt.`)
await router.invalidate()
} catch (err) {
setMarkerError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsPlacingMarker(false)
}
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
@@ -238,62 +161,6 @@ function StaffSettings() {
Wie weit im Voraus reserviert werden kann, z. B. 60 für „höchstens 60 Tage im Voraus“.
</p>
<fieldset className="table-display-fieldset">
<legend>Tischfotos im Buchungsformular</legend>
<label className="radio-option">
<input
type="radio"
name="tableDisplayMode"
checked={form.tableDisplayMode === 'individual'}
onChange={() => setForm((f) => ({ ...f, tableDisplayMode: 'individual' }))}
/>
Foto des gewählten Tisches
</label>
<label className="radio-option">
<input
type="radio"
name="tableDisplayMode"
checked={form.tableDisplayMode === 'overview'}
onChange={() => setForm((f) => ({ ...f, tableDisplayMode: 'overview' }))}
/>
Ein Übersichtsfoto für alle Tische
</label>
</fieldset>
<p className="field-hint">
„Foto des gewählten Tisches“ zeigt das jeweilige Foto aus der Tischverwaltung, sobald ein
Tisch ausgewählt wird. „Übersichtsfoto“ zeigt stattdessen immer dasselbe Bild, z. B. eine
Aufnahme des ganzen Raums.
</p>
{form.tableDisplayMode === 'overview' && (
<>
<label>
Übersichtsfoto
<div className="table-image-field">
{form.tableOverviewImageUrl && (
<img className="table-image-preview" src={form.tableOverviewImageUrl} alt="" />
)}
<input
ref={overviewFileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleOverviewImageChange}
/>
{form.tableOverviewImageUrl && (
<button
type="button"
className="btn btn-secondary"
onClick={() => setForm((f) => ({ ...f, tableOverviewImageUrl: null }))}
>
Bild entfernen
</button>
)}
</div>
</label>
<p className="field-hint">PNG, JPEG oder WebP, max. {MAX_IMAGE_MB} MB.</p>
</>
)}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gespeichert …' : 'Speichern'}
</button>
@@ -302,71 +169,6 @@ function StaffSettings() {
{success && <p className="message message-success">{success}</p>}
</form>
</section>
{settings.tableDisplayMode === 'overview' && settings.tableOverviewImageUrl && (
<section className="staff-form-section">
<h2 className="section-heading staff-subheading">Tisch-Marker</h2>
<p className="field-hint">
Wählen Sie einen Tisch aus und klicken Sie dann auf das gespeicherte Übersichtsfoto, um
seinen Marker zu setzen oder zu verschieben.
</p>
<div className="reservation-form">
<label>
Marker setzen für
<select value={markerTableId} onChange={(e) => setMarkerTableId(e.target.value)}>
<option value="" disabled>
Tisch auswählen
</option>
{tables.map((table) => (
<option key={table.id} value={table.id}>
{table.tableNumber === null ? table.name : `${table.tableNumber} — ${table.name}`}
{table.markerX === null || table.markerY === null ? '' : ' ✓'}
</option>
))}
</select>
</label>
<div
className={`table-overview-wrap${isPlacingMarker ? ' is-busy' : ''}`}
onClick={handlePlaceMarker}
>
<img className="table-overview-image" src={settings.tableOverviewImageUrl} alt="" />
{tables
.filter((table) => table.markerX !== null && table.markerY !== null)
.map((table) => (
<span
key={table.id}
className={`table-marker${String(table.id) === markerTableId ? ' is-active' : ''}`}
style={{ left: `${table.markerX}%`, top: `${table.markerY}%` }}
>
<span className="table-marker-label">{table.tableNumber ?? '?'}</span>
</span>
))}
</div>
{markerTable && (markerTable.markerX !== null || markerTable.markerY !== null) && (
<button
type="button"
className="btn btn-secondary"
disabled={isPlacingMarker}
onClick={handleClearMarker}
>
Marker für {markerTable.name} entfernen
</button>
)}
{tablesWithoutMarker.length > 0 && (
<p className="field-hint">
Noch ohne Marker: {tablesWithoutMarker.map((table) => table.name).join(', ')}
</p>
)}
{markerError && <p className="message message-error">{markerError}</p>}
{markerSuccess && <p className="message message-success">{markerSuccess}</p>}
</div>
</section>
)}
</main>
</div>
)
-338
View File
@@ -1,338 +0,0 @@
import { useRef, useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { ConfirmDialog } from '#/components/confirm-dialog'
import { StaffTabs } from '#/components/staff-tabs'
import { TableDetailDialog } from '#/components/table-detail-dialog'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { createTable, deleteTable, getTables, updateTable } from '#/lib/reservations'
import { ACCEPTED_IMAGE_TYPES, MAX_TABLE_IMAGE_BYTES, readImageAsDataUrl } from '#/lib/table-image'
export const Route = createFileRoute('/staff/tables')({
component: StaffTables,
beforeLoad: async () => {
const staffUser = await getCurrentStaffUser()
if (!staffUser) {
throw redirect({ to: '/staff/login' })
}
if (staffUser.role !== 'admin') {
throw redirect({ to: '/staff' })
}
return { staffUser }
},
loader: async ({ context }) => {
const tables = await getTables()
return { staffUser: context.staffUser, tables }
},
})
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
type FormState = {
name: string
tableNumber: number
seats: number
imageUrl: string | null
}
function emptyForm(): FormState {
return { name: '', tableNumber: 1, seats: 2, imageUrl: null }
}
const MAX_IMAGE_MB = Math.floor(MAX_TABLE_IMAGE_BYTES / 1_000_000)
function StaffTables() {
const router = useRouter()
const { staffUser, tables } = Route.useLoaderData()
const [editingId, setEditingId] = useState<number | null>(null)
const [form, setForm] = useState<FormState>(emptyForm)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [pendingDelete, setPendingDelete] = useState<TableRow | null>(null)
const [viewingTable, setViewingTable] = useState<TableRow | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
function startEdit(table: TableRow) {
setEditingId(table.id)
setForm({
name: table.name,
tableNumber: table.tableNumber ?? 0,
seats: table.seats,
imageUrl: table.imageUrl,
})
setError(null)
setSuccess(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
function cancelEdit() {
setEditingId(null)
setForm(emptyForm())
setError(null)
if (fileInputRef.current) fileInputRef.current.value = ''
}
async function handleImageChange(event: React.ChangeEvent<HTMLInputElement>) {
const file = event.target.files?.[0]
event.target.value = ''
if (!file) return
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
setError('Bild muss PNG, JPEG oder WebP sein')
return
}
if (file.size > MAX_TABLE_IMAGE_BYTES) {
setError(`Bild ist zu groß (max. ${MAX_IMAGE_MB} MB)`)
return
}
setError(null)
const dataUrl = await readImageAsDataUrl(file)
setForm((f) => ({ ...f, imageUrl: dataUrl }))
}
async function handleSubmit(event: React.FormEvent) {
event.preventDefault()
setError(null)
setSuccess(null)
setIsSubmitting(true)
try {
const payload = {
name: form.name,
tableNumber: Number(form.tableNumber),
seats: Number(form.seats),
imageUrl: form.imageUrl,
}
if (editingId) {
await updateTable({ data: { id: editingId, ...payload } })
setSuccess('Tisch wurde aktualisiert.')
} else {
await createTable({ data: payload })
setSuccess('Tisch wurde angelegt.')
}
cancelEdit()
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setIsSubmitting(false)
}
}
async function confirmDelete() {
const table = pendingDelete
if (!table) return
setPendingDelete(null)
setDeletingId(table.id)
setError(null)
setSuccess(null)
try {
await deleteTable({ data: { id: table.id } })
if (editingId === table.id) cancelEdit()
setSuccess('Tisch wurde gelöscht.')
await router.invalidate()
} catch (err) {
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
} finally {
setDeletingId(null)
}
}
async function handleLogout() {
await authClient.signOut()
await router.navigate({ to: '/staff/login' })
await router.invalidate()
}
return (
<div className="staff-page">
<div className="staff-nav">
<header className="staff-topbar">
<Link className="wordmark" to="/">
Steakhaus <span>bei Mirko</span>
</Link>
<div className="staff-topbar-right">
<span>Angemeldet als {staffUser.name}</span>
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
Abmelden
</button>
</div>
</header>
<div className="staff-tabs-bar">
<StaffTabs role={staffUser.role} />
</div>
</div>
<main className="page staff-main">
<h1 className="section-heading">Tische</h1>
<section className="staff-form-section">
<h2 className="section-heading staff-subheading">
{editingId ? 'Tisch bearbeiten' : 'Neuer Tisch'}
</h2>
<form className="reservation-form" onSubmit={handleSubmit}>
<div className="form-row">
<label>
Tischnummer
<input
type="number"
inputMode="numeric"
min={1}
value={form.tableNumber}
onChange={(e) => setForm((f) => ({ ...f, tableNumber: Number(e.target.value) }))}
required
/>
</label>
<label>
Plätze
<input
type="number"
inputMode="numeric"
min={1}
max={50}
value={form.seats}
onChange={(e) => setForm((f) => ({ ...f, seats: Number(e.target.value) }))}
required
/>
</label>
</div>
<label>
Name
<input
type="text"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</label>
<p className="field-hint">
Die Tischnummer wird auf dem Marker angezeigt; der Name ist nur für die Verwaltung hier.
</p>
<label>
Foto
<div className="table-image-field">
{form.imageUrl && <img className="table-image-preview" src={form.imageUrl} alt="" />}
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleImageChange}
/>
{form.imageUrl && (
<button
type="button"
className="btn btn-secondary"
onClick={() => setForm((f) => ({ ...f, imageUrl: null }))}
>
Bild entfernen
</button>
)}
</div>
</label>
<p className="field-hint">
Zeigt Gästen im Buchungsformular, wie der Tisch aussieht. PNG, JPEG oder WebP, max.{' '}
{MAX_IMAGE_MB} MB.
</p>
<div className="form-actions">
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Wird gespeichert …' : editingId ? 'Speichern' : 'Anlegen'}
</button>
{editingId && (
<button type="button" className="btn btn-secondary" onClick={cancelEdit}>
Abbrechen
</button>
)}
</div>
{error && <p className="message message-error">{error}</p>}
{success && <p className="message message-success">{success}</p>}
</form>
</section>
<section className="staff-table-section">
{tables.length === 0 ? (
<p className="empty-state">Noch keine Tische vorhanden.</p>
) : (
<div className="table-scroll">
<table className="reservations-table reservations-table-clickable">
<thead>
<tr>
<th></th>
<th>Nr.</th>
<th>Name</th>
<th>Plätze</th>
<th></th>
</tr>
</thead>
<tbody>
{tables.map((table) => (
<tr
key={table.id}
className={editingId === table.id ? 'is-editing' : undefined}
onClick={() => setViewingTable(table)}
>
<td>
{table.imageUrl ? (
<img className="table-thumb" src={table.imageUrl} alt="" />
) : (
<span className="table-thumb table-thumb-empty" aria-hidden="true" />
)}
</td>
<td>{table.tableNumber ?? '—'}</td>
<td>{table.name}</td>
<td>{table.seats}</td>
<td onClick={(event) => event.stopPropagation()}>
<div className="table-actions">
<button type="button" className="btn btn-secondary" onClick={() => startEdit(table)}>
Bearbeiten
</button>
<button
type="button"
className="btn btn-danger"
disabled={deletingId === table.id}
onClick={() => setPendingDelete(table)}
>
{deletingId === table.id ? 'Löschen …' : 'Löschen'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
</main>
<ConfirmDialog
open={pendingDelete !== null}
title="Tisch löschen"
message={pendingDelete && <>Tisch <strong>{pendingDelete.name}</strong> wirklich löschen?</>}
warning="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Löschen"
danger
onConfirm={confirmDelete}
onCancel={() => setPendingDelete(null)}
/>
<TableDetailDialog table={viewingTable} onClose={() => setViewingTable(null)} />
</div>
)
}
+9 -91
View File
@@ -1,12 +1,9 @@
import { useState } from 'react'
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
import { ConfirmDialog } from '#/components/confirm-dialog'
import { StaffMemberDetailDialog } from '#/components/staff-member-detail-dialog'
import { StaffTabs } from '#/components/staff-tabs'
import { getCurrentStaffUser } from '#/lib/auth'
import { authClient } from '#/lib/auth-client'
import { phoneSchema } from '#/lib/phone'
import { listStaffMembers } from '#/lib/staff'
export const Route = createFileRoute('/staff/team')({
@@ -32,13 +29,12 @@ type StaffMember = Awaited<ReturnType<typeof listStaffMembers>>[number]
type FormState = {
name: string
email: string
phone: string
password: string
role: 'admin' | 'user'
}
function emptyForm(): FormState {
return { name: '', email: '', phone: '', password: '', role: 'user' }
return { name: '', email: '', password: '', role: 'user' }
}
function StaffTeam() {
@@ -50,20 +46,11 @@ function StaffTeam() {
const [success, setSuccess] = useState<string | null>(null)
const [isSubmitting, setIsSubmitting] = useState(false)
const [busyId, setBusyId] = useState<string | null>(null)
const [viewingMember, setViewingMember] = useState<StaffMember | null>(null)
const [pendingRemove, setPendingRemove] = useState<StaffMember | null>(null)
async function handleCreate(event: React.FormEvent) {
event.preventDefault()
setError(null)
setSuccess(null)
const phoneResult = phoneSchema.safeParse(form.phone)
if (!phoneResult.success) {
setError('Bitte geben Sie eine gültige Telefonnummer an')
return
}
setIsSubmitting(true)
const { error: createError } = await authClient.admin.createUser({
@@ -71,7 +58,6 @@ function StaffTeam() {
email: form.email,
password: form.password,
role: form.role,
data: { phone: phoneResult.data },
})
if (createError) {
@@ -86,11 +72,11 @@ function StaffTeam() {
await router.invalidate()
}
async function confirmRemove() {
const member = pendingRemove
if (!member) return
async function handleRemove(member: StaffMember) {
if (!window.confirm(`Zugang für ${member.name} wirklich entfernen?`)) {
return
}
setPendingRemove(null)
setBusyId(member.id)
setError(null)
setSuccess(null)
@@ -150,38 +136,6 @@ function StaffTeam() {
await router.invalidate()
}
async function handleSetPhone(member: StaffMember) {
const newPhone = window.prompt(`Telefonnummer für ${member.name}:`, member.phone ?? '')
if (newPhone === null) {
return
}
const phoneResult = phoneSchema.safeParse(newPhone)
if (!phoneResult.success) {
setError('Bitte geben Sie eine gültige Telefonnummer an')
return
}
setBusyId(member.id)
setError(null)
setSuccess(null)
const { error: phoneError } = await authClient.admin.updateUser({
userId: member.id,
data: { phone: phoneResult.data },
})
if (phoneError) {
setError(phoneError.message ?? 'Etwas ist schiefgelaufen')
} else {
setSuccess(`Telefonnummer für ${member.name} wurde aktualisiert.`)
await router.invalidate()
}
setBusyId(null)
}
async function handleSetPassword(member: StaffMember) {
const newPassword = window.prompt(`Neues Passwort für ${member.name} (mind. 8 Zeichen):`)
@@ -256,19 +210,6 @@ function StaffTeam() {
</label>
</div>
<label>
Telefon
<input
type="tel"
inputMode="tel"
pattern="[+]?[0-9()\-.\s]{6,30}"
title="Nur Ziffern, Leerzeichen und + ( ) - ."
value={form.phone}
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
required
/>
</label>
<div className="form-row">
<label>
Passwort
@@ -304,12 +245,11 @@ function StaffTeam() {
<section className="staff-table-section">
<div className="table-scroll">
<table className="reservations-table reservations-table-clickable">
<table className="reservations-table">
<thead>
<tr>
<th>Name</th>
<th>E-Mail</th>
<th>Telefon</th>
<th>Rolle</th>
<th>Status</th>
<th></th>
@@ -317,13 +257,12 @@ function StaffTeam() {
</thead>
<tbody>
{members.map((member) => (
<tr key={member.id} onClick={() => setViewingMember(member)}>
<tr key={member.id}>
<td>{member.name}</td>
<td>{member.email}</td>
<td>{member.phone || <span className="muted">—</span>}</td>
<td>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</td>
<td>{member.banned ? 'Gesperrt' : 'Aktiv'}</td>
<td onClick={(event) => event.stopPropagation()}>
<td>
<div className="table-actions">
<button
type="button"
@@ -333,14 +272,6 @@ function StaffTeam() {
>
{member.role === 'admin' ? 'Zu Mitarbeiter machen' : 'Zu Admin machen'}
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busyId === member.id}
onClick={() => handleSetPhone(member)}
>
Telefon bearbeiten
</button>
<button
type="button"
className="btn btn-secondary"
@@ -361,7 +292,7 @@ function StaffTeam() {
type="button"
className="btn btn-danger"
disabled={busyId === member.id || member.id === staffUser.id}
onClick={() => setPendingRemove(member)}
onClick={() => handleRemove(member)}
>
Entfernen
</button>
@@ -374,19 +305,6 @@ function StaffTeam() {
</div>
</section>
</main>
<StaffMemberDetailDialog member={viewingMember} onClose={() => setViewingMember(null)} />
<ConfirmDialog
open={pendingRemove !== null}
title="Zugang entfernen"
message={pendingRemove && <>Zugang für <strong>{pendingRemove.name}</strong> wirklich entfernen?</>}
warning="Diese Aktion kann nicht rückgängig gemacht werden."
confirmLabel="Entfernen"
danger
onConfirm={confirmRemove}
onCancel={() => setPendingRemove(null)}
/>
</div>
)
}
+21 -832
View File
@@ -80,6 +80,13 @@ a {
max-width: 120rem
}
@media (max-width: 30rem) {
.site-header-inner {
justify-content: center;
text-align: center;
}
}
.wordmark {
font-family: 'Fraunces', Georgia, serif;
font-size: 1.15rem;
@@ -142,10 +149,10 @@ a {
}
.hero-flame {
display: block;
width: 7rem;
height: 3.5rem;
width: 2.5rem;
height: 2.5rem;
margin: 0 auto 1.5rem;
color: var(--ember);
}
.hero h1 {
@@ -183,11 +190,6 @@ a {
border: 1px solid transparent;
}
.btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-primary {
background: var(--gold);
color: #201709;
@@ -208,17 +210,6 @@ a {
color: var(--text);
}
.btn-ghost {
background: none;
border-color: transparent;
color: var(--text-muted);
}
.btn-ghost:hover {
color: var(--text);
background: rgba(243, 234, 217, 0.06);
}
/* Sections */
.section {
@@ -385,46 +376,6 @@ a {
anchor-name: --anchor-date-time;
}
.password-field {
position: relative;
}
.password-field input {
padding-right: 2.5rem;
}
.password-toggle {
position: absolute;
top: 50%;
right: 0.3rem;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 1.9rem;
height: 1.9rem;
padding: 0;
border: none;
border-radius: 0.25rem;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.password-toggle:hover {
color: var(--text);
}
.password-toggle svg {
flex-shrink: 0;
width: 18px;
height: 18px;
}
.date-popover-backdrop {
display: contents;
}
.date-popover {
position: absolute;
position-anchor: --anchor-date-time;
@@ -438,52 +389,6 @@ a {
overflow: hidden;
}
@media (max-width: 30rem) {
.date-popover-backdrop {
display: flex;
position: fixed;
inset: 0;
z-index: 100;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.6);
}
.date-popover {
position: static;
width: 100%;
padding: 1rem;
box-shadow: 0 20px 45px rgba(0, 0, 0, 0.45);
animation: confirm-dialog-fade-in 0.15s ease-out;
}
.date-popover-header {
margin-bottom: 0.6rem;
font-size: 1rem;
}
.date-popover-header button {
width: 3rem;
height: 3rem;
font-size: 1.8rem;
}
.date-popover-weekdays span,
.date-popover-grid button {
font-size: 0.95rem;
display: flex;
align-items: center;
justify-content: center;
width: 100% !important;
}
.date-popover-grid button {
aspect-ratio: unset;
padding: 0.5rem 0;
}
}
.date-popover-header {
display: flex;
align-items: center;
@@ -507,7 +412,7 @@ a {
background: transparent;
color: var(--text-muted);
font: inherit;
font-size: 1.2rem;
font-size: 0.9rem;
cursor: pointer;
}
@@ -577,8 +482,7 @@ a {
}
.reservation-form input,
.reservation-form select,
.reservation-form textarea {
.reservation-form select {
width: 100%;
min-width: 0;
font-family: inherit;
@@ -590,80 +494,21 @@ a {
color: var(--text);
}
.reservation-form textarea {
resize: vertical;
min-height: 4.5rem;
}
.reservation-form input:focus,
.reservation-form select:focus,
.reservation-form textarea:focus {
.reservation-form select:focus {
outline: none;
border-color: var(--gold);
box-shadow: 0 0 0 2px rgba(201, 161, 90, 0.25);
}
/* Overrides the generic `.reservation-form input` box styling above — that rule
assumes text-like inputs and would otherwise stretch radios/checkboxes into
full-width bordered rectangles instead of native controls. */
.reservation-form input[type='radio'],
.reservation-form input[type='checkbox'] {
width: 1rem;
height: 1rem;
padding: 0;
border: none;
background: none;
accent-color: var(--gold);
}
.table-display-fieldset {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0;
padding: 0;
border: none;
}
.table-display-fieldset legend {
padding: 0;
margin-bottom: 0.35rem;
font-weight: 500;
font-size: 0.9rem;
color: var(--text-muted);
}
.reservation-form .radio-option {
flex-direction: row;
align-items: center;
gap: 0.5rem;
font-weight: 400;
font-size: 0.95rem;
color: var(--text);
}
/* `.radio-option` centers its input against a single-line label, which breaks
down for the consent checkbox's multi-line sentence — the checkbox needs to
sit at the top of the text instead of drifting to its vertical center. */
.reservation-form .consent-option {
align-items: flex-start;
}
.reservation-form .consent-option input {
margin-top: 0.2rem;
flex-shrink: 0;
}
/* `:not(.btn)` keeps this scoped to the plain submit buttons — anything already
using the `.btn` component (Abbrechen, Bearbeiten, Löschen, …) styles itself
and shouldn't be overridden here (a previous version of this rule beat
`.btn-secondary` on specificity and left its hover text unreadable).
`:not(.date-popover *):not(.date-picker-trigger):not(.table-marker):not(.password-toggle)`
keeps it off the calendar's own buttons, the overview-photo markers, and the
show-password toggle — they live inside this form too, and without this
exclusion this rule's specificity beats their own button styles. */
.reservation-form
button:not(.btn):not(.date-popover *):not(.date-picker-trigger):not(.table-marker):not(.password-toggle) {
`:not(.date-popover *):not(.date-picker-trigger)` keeps it off the
calendar's own buttons — they live inside this form too, and without this
exclusion this rule's specificity beats the calendar's button styles. */
.reservation-form button:not(.btn):not(.date-popover *):not(.date-picker-trigger) {
align-self: flex-start;
font: inherit;
font-weight: 600;
@@ -675,15 +520,11 @@ a {
cursor: pointer;
}
.reservation-form
button:not(.btn):not(.date-popover *):not(.date-picker-trigger):not(.table-marker):not(.password-toggle):hover:not(
:disabled
) {
.reservation-form button:not(.btn):not(.date-popover *):not(.date-picker-trigger):hover:not(:disabled) {
background: var(--gold-bright);
}
.reservation-form
button:not(.btn):not(.date-popover *):not(.date-picker-trigger):not(.table-marker):not(.password-toggle):disabled {
.reservation-form button:not(.btn):not(.date-popover *):not(.date-picker-trigger):disabled {
opacity: 0.6;
cursor: not-allowed;
}
@@ -718,13 +559,6 @@ a {
border: 1px solid #23502c;
}
.staff-page-messages {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1.25rem 0 0;
}
/* Contact */
.contact-list {
@@ -833,11 +667,6 @@ a {
max-width: 68rem;
margin: 0 auto;
padding: 0 1.5rem;
overflow: scroll hidden;
&::-webkit-scrollbar {
display: none;
}
}
.staff-tab {
@@ -881,10 +710,6 @@ a {
.confirm-dialog {
animation: none;
}
.reservation-confirmed-flame {
animation: none;
}
}
.staff-main {
@@ -1129,19 +954,6 @@ a {
margin-top: 2.5rem;
}
.table-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1.25rem;
}
.table-toolbar .staff-subheading {
margin: 0;
}
.table-filters {
display: flex;
flex-wrap: wrap;
@@ -1176,10 +988,6 @@ a {
overflow-x: auto;
border: 1px solid var(--line);
border-radius: 0.5rem;
&::-webkit-scrollbar {
display: none;
}
}
.reservations-table {
@@ -1212,171 +1020,11 @@ a {
background: rgba(243, 234, 217, 0.06);
}
.reservations-table-clickable tbody tr:hover {
background: rgba(243, 234, 217, 0.05);
cursor: pointer;
}
.reservations-table .muted {
color: var(--text-muted);
font-size: 0.8rem;
}
.reservations-table .message-cell {
max-width: 16rem;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 40rem) {
/* On mobile the column headers are hidden and each row becomes a card laid
out on a 2-column grid — related fields (table nr/name, party/status)
sit side by side, while date/time, customer and message span full width. */
.reservations-table thead {
display: none;
}
.reservations-table,
.reservations-table tbody {
display: block;
width: 100%;
}
.reservations-table tbody {
padding: 0.75rem;
}
.reservations-table tbody tr {
display: grid;
grid-template-columns: 1fr 1fr;
column-gap: 1rem;
row-gap: 0.6rem;
padding: 0.9rem 1rem;
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 0.5rem;
}
.reservations-table tbody tr + tr {
margin-top: 0.75rem;
}
.reservations-table td {
display: block;
max-width: none;
padding: 0;
white-space: normal;
word-break: break-word;
border-bottom: none;
}
.reservations-table td[data-label]::before {
content: attr(data-label);
display: block;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-muted);
margin-bottom: 0.15rem;
}
.reservations-table .cell-datetime,
.reservations-table .cell-customer,
.reservations-table .message-cell {
grid-column: 1 / -1;
}
.reservations-table .message-cell {
max-width: none;
overflow: visible;
text-overflow: unset;
}
}
.status-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 9.5rem;
padding: 0.2rem 0.6rem;
border-radius: 999px;
font: inherit;
font-size: 0.75rem;
font-weight: 600;
white-space: nowrap;
}
.status-badge.status-confirmed {
background: rgba(201, 161, 90, 0.15);
color: var(--gold-bright);
border: 1px solid rgba(201, 161, 90, 0.35);
}
.status-badge.status-no_show {
background: #3a1414;
color: #f3a9a9;
border: 1px solid #5c1f1f;
}
.status-badge.status-completed {
background: #17301c;
color: #a9d9b4;
border: 1px solid #23502c;
}
/* A single grouped 3-way toggle track, not three loose floating pills — the
active segment gets a solid fill, the other two stay flat text until hovered. */
.status-options {
display: flex;
gap: 0.25rem;
margin: 0 0 1.5rem;
padding: 0.25rem;
background: var(--bg);
border: 1px solid var(--line);
border-radius: 999px;
}
.status-option {
flex: 1;
cursor: pointer;
padding: 0.45rem 0.75rem;
transition:
background-color 0.15s ease,
color 0.15s ease;
}
/* `.status-badge.status-option` — matches `.status-badge.status-confirmed` (etc.)
on specificity so this actually wins and overrides their fixed `width`/`border`,
rather than losing to them by cascade order. */
.status-badge.status-option {
width: auto;
border: none;
}
.status-option:disabled {
cursor: not-allowed;
}
.status-option:hover:not(:disabled):not(.is-active) {
color: var(--text);
background: rgba(243, 234, 217, 0.06);
}
.status-option.is-active.status-confirmed {
background: var(--gold);
color: #201709;
}
.status-option.is-active.status-no_show {
background: var(--ember);
color: #fdeceb;
}
.status-option.is-active.status-completed {
background: #2f7a3c;
color: #eafcee;
}
.table-actions {
display: flex;
gap: 0.5rem;
@@ -1388,244 +1036,6 @@ a {
font-size: 0.8rem;
}
.action-menu-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
border-radius: 0.3rem;
background: transparent;
color: var(--text-muted);
cursor: pointer;
}
.action-menu-trigger:hover,
.action-menu-trigger[aria-expanded='true'] {
background: var(--line);
color: var(--text);
}
/* `top`/`right` are set inline per instance (src/components/action-menu.tsx) —
the panel is portaled to <body> and positioned from a measured rect so it
isn't clipped by `.table-scroll`'s (forced) `overflow-y: auto`. `z-index` is
above `.confirm-dialog-backdrop` (100) since the menu can now be opened from
inside the reservation detail modal, not just from a table row. */
.action-menu-panel {
position: fixed;
z-index: 110;
display: flex;
flex-direction: column;
gap: 0.35rem;
min-width: 11rem;
padding: 0.5rem;
background: var(--bg-raised);
border: 1px solid var(--line);
border-radius: 0.4rem;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.35);
}
.action-menu-panel .btn {
display: block;
width: 100%;
text-align: left;
font: inherit;
padding: 0.5rem 0.7rem;
font-size: 0.85rem;
}
.table-thumb {
width: 3rem;
height: 3rem;
border-radius: 0.3rem;
object-fit: cover;
display: block;
}
.table-thumb-empty {
background: var(--bg);
border: 1px dashed var(--line);
}
.table-image-field {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
.table-image-field input[type='file'] {
flex: 1 1 12rem;
min-width: 0;
padding: 0.4rem;
font-size: 0.85rem;
}
.table-image-preview {
width: 4.5rem;
height: 4.5rem;
border-radius: 0.3rem;
object-fit: cover;
flex-shrink: 0;
}
.table-preview {
width: 100%;
max-height: 14rem;
object-fit: cover;
border-radius: 0.4rem;
border: 1px solid var(--line);
}
/* A fixed aspect ratio (rather than sizing to the photo's own dimensions) so a
marker's percentage position lines up with the same spot on the image whether
it's rendered here, in the admin editor, or at a different screen width —
`object-fit: contain` (not `cover`) keeps the whole photo, and thus every
marker coordinate, on-screen instead of letting some get cropped away. */
.table-overview-wrap {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 0.4rem;
border: 1px solid var(--line);
background: var(--bg);
overflow: hidden;
cursor: pointer;
}
.table-overview-wrap.is-busy {
cursor: wait;
opacity: 0.7;
}
.table-overview-image {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
pointer-events: none;
}
.pdf-menu {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.pdf-menu-viewport {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 0.4rem;
border: 1px solid var(--line);
background: #fff;
box-shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 25%);
}
.pdf-menu-loading {
position: absolute;
top: 50%;
left: 50%;
width: 90%;
max-width: 20rem;
transform: translate(-50%, -50%);
display: flex;
flex-direction: column;
align-items: center;
gap: 0.75rem;
opacity: 0;
animation: pdf-menu-spinner-fade-in 0.15s ease 0.15s forwards;
}
.pdf-menu-loading p {
margin: 0;
text-align: center;
font-family: 'Fraunces', Georgia, serif;
font-style: italic;
color: #6b5f4d;
}
.pdf-menu-spinner {
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
border: 3px solid var(--line);
border-top-color: var(--gold);
animation: pdf-menu-spin 0.8s linear infinite;
}
@keyframes pdf-menu-spin {
to {
transform: rotate(360deg);
}
}
@keyframes pdf-menu-spinner-fade-in {
to {
opacity: 1;
}
}
.pdf-menu-page {
display: block;
width: 100%;
max-width: 100%;
aspect-ratio: 210 / 297;
opacity: 0;
}
.pdf-menu-pagination {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
color: var(--text-muted);
}
/* A classic map-pin: a square with three rounded corners (50% 50% 50% 0 leaves
the bottom-left one sharp), rotated -45deg around that exact sharp corner so
the corner itself — not the box center — lands on the `left`/`top` coordinate
and becomes the pin's tip, with the round "head" swinging up above it. */
.table-marker {
position: absolute;
width: 1.6rem;
height: 1.6rem;
transform: translate(0, -100%) rotate(-45deg);
transform-origin: 0 100%;
border: none;
border-radius: 50% 50% 50% 0;
padding: 0;
background: var(--bg-raised);
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.45), inset 0 0 0 1.5px var(--gold);
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.table-marker-label {
transform: rotate(45deg);
font: inherit;
font-size: 0.62rem;
font-weight: 700;
color: var(--text);
}
.table-marker.is-selected,
.table-marker.is-active {
background: var(--gold);
transform: translate(0, -100%) rotate(-45deg) scale(1.2);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5);
}
.table-marker.is-selected .table-marker-label,
.table-marker.is-active .table-marker-label {
color: #201709;
}
.btn-danger {
background: transparent;
border-color: #5c1f1f;
@@ -1679,62 +1089,9 @@ a {
margin: 0 0 0.75rem;
}
.confirm-dialog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
}
.confirm-dialog-header .confirm-dialog-title {
margin: 0;
}
.confirm-dialog-header-actions {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.confirm-dialog-header-actions .btn {
padding: 0.45rem 0.9rem;
font-size: 0.85rem;
}
.confirm-dialog-message {
margin: 0 0 1.25rem;
color: var(--text-muted);
}
.confirm-dialog-message strong {
color: var(--text);
font-weight: 700;
}
.confirm-dialog-warning {
display: flex;
align-items: flex-start;
gap: 0.6rem;
margin: 0 0 1.5rem;
padding: 0.75rem 0.9rem;
border: 1px solid #5c1f1f;
border-radius: 0.4rem;
background: rgba(168, 64, 44, 0.14);
color: #f3a9a9;
}
.confirm-dialog-warning svg {
flex-shrink: 0;
margin-top: 0.1rem;
color: #f3a9a9;
}
.confirm-dialog-warning span {
font-size: 0.85rem;
font-weight: 600;
line-height: 1.4;
color: var(--text-muted);
}
.confirm-dialog-actions {
@@ -1743,179 +1100,11 @@ a {
gap: 0.75rem;
}
/* Puts "Bearbeiten" and "Schließen" on opposite ends of the row instead of the
usual right-aligned button cluster, since they aren't a confirm/cancel pair. */
.confirm-dialog-actions-split {
justify-content: space-between;
}
.confirm-dialog-actions .btn {
padding: 0.6rem 1.25rem;
font-size: 0.9rem;
}
/* Beats the rule above on source order (same specificity), so it reads as a
plain text action instead of a button-shaped one. */
.confirm-dialog-actions .btn-ghost {
padding: 0;
font-size: 0.8rem;
text-decoration: underline;
}
.reservation-confirmed-dialog {
max-width: 26rem;
text-align: center;
}
.reservation-confirmed-flame {
width: 3.25rem;
height: 3.25rem;
margin: 0 auto 1rem;
color: var(--ember);
animation: reservation-confirmed-flicker 2.2s ease-in-out infinite;
}
@keyframes reservation-confirmed-flicker {
0%,
100% {
transform: rotate(0deg) scale(1);
color: var(--ember);
}
25% {
transform: rotate(-3deg) scale(1.05);
}
50% {
transform: rotate(2deg) scale(0.97);
color: var(--gold-bright);
}
75% {
transform: rotate(-2deg) scale(1.03);
}
}
.reservation-confirmed-title {
margin: 0 0 0.5rem;
font-size: 1.5rem;
color: var(--gold-bright);
}
.reservation-confirmed-message {
margin: 0 0 1.5rem;
color: var(--text-muted);
}
.reservation-confirmed-dialog .detail-list {
text-align: left;
}
.reservation-confirmed-footnote {
margin: 0 0 1.5rem;
font-size: 0.82rem;
color: var(--text-muted);
}
.reservation-confirmed-footnote strong {
color: var(--text);
font-weight: 600;
}
.reservation-confirmed-dialog .confirm-dialog-actions {
justify-content: center;
}
.success-modal {
max-width: 22rem;
text-align: center;
}
.success-modal-icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.75rem;
height: 2.75rem;
margin: 0 auto 1rem;
border: 1px solid var(--gold);
border-radius: 50%;
color: var(--gold-bright);
}
.success-modal .confirm-dialog-title {
margin-bottom: 0.5rem;
}
.success-modal .confirm-dialog-message {
margin-bottom: 1.5rem;
}
.success-modal .confirm-dialog-actions {
justify-content: center;
}
.reservation-detail-dialog {
max-width: 28rem;
}
.reservation-detail-dialog .table-preview {
margin-bottom: 1.25rem;
}
.reservation-dialog-editing {
max-width: 34rem;
}
/* The dialog card already provides the background/border/padding — strip
`.reservation-form`'s own so the form doesn't render as a card-in-a-card. */
.reservation-form-dialog {
background: none;
border: none;
padding: 0;
}
.detail-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin: 0 0 1.25rem;
}
.detail-list > div {
display: flex;
justify-content: space-between;
gap: 1rem;
padding-bottom: 0.6rem;
border-bottom: 1px solid var(--line);
}
.detail-list dt {
color: var(--text-muted);
font-size: 0.8rem;
}
.detail-list dd {
margin: 0;
text-align: right;
font-weight: 500;
}
.detail-message {
margin: 0 0 1.5rem;
padding-top: 0.6rem;
border-top: 1px solid var(--line);
}
.detail-message-label {
display: block;
margin-bottom: 0.35rem;
color: var(--text-muted);
font-size: 0.8rem;
}
.detail-message p {
margin: 0;
white-space: pre-wrap;
}
.empty-state {
color: var(--text-muted);
font-style: italic;
@@ -1967,7 +1156,7 @@ a {
/* Legal page */
.legal-page {
padding: 3.5rem 1rem 4rem;
padding: 3.5rem 0 4rem;
}
.legal-page h1 {
-3
View File
@@ -7,9 +7,6 @@ import viteReact from '@vitejs/plugin-react'
const config = defineConfig({
resolve: { tsconfigPaths: true },
plugins: [tanstackStart(), viteReact()],
preview: {
allowedHosts: ['localhost', '127.0.0.1', 'promo-steakhouse.lukas-harat.com'],
}
})
export default config