Compare commits
3
Commits
bb18170b3f
..
proof
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e19461e41d | ||
|
|
23fddd543f | ||
|
|
ecbc42846b |
@@ -0,0 +1,20 @@
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
.git
|
||||
.gitea
|
||||
.github
|
||||
.idea
|
||||
.vscode
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.local
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
.output
|
||||
.vinxi
|
||||
__unconfig*
|
||||
todos.json
|
||||
README.md
|
||||
+130
-64
@@ -1,42 +1,29 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ['**']
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- proof
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.lukas-harat.com
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
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.js
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
@@ -45,50 +32,129 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Type check
|
||||
- name: Typecheck
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Check that Drizzle migrations are up to date
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ secrets.USERNAME }}
|
||||
password: ${{ secrets.TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
|
||||
deploy:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Deploy to TrueNAS
|
||||
env:
|
||||
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
|
||||
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
|
||||
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
|
||||
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||
STAFF_SEED_EMAIL: ${{ secrets.STAFF_SEED_EMAIL }}
|
||||
STAFF_SEED_PASSWORD: ${{ secrets.STAFF_SEED_PASSWORD }}
|
||||
RESERVATIONS_FROM_EMAIL: ${{ secrets.RESERVATIONS_FROM_EMAIL }}
|
||||
|
||||
IMAGE_TAG: ${{ github.sha }}
|
||||
|
||||
run: |
|
||||
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
|
||||
set -euo pipefail
|
||||
|
||||
- name: Apply migrations
|
||||
run: npm run db:migrate
|
||||
mkdir -p ~/.ssh
|
||||
|
||||
- name: Seed baseline data
|
||||
run: npm run db:seed
|
||||
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||
chmod 600 ~/.ssh/deploy_key
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
ssh-keyscan \
|
||||
-p "${DEPLOY_PORT:-22}" \
|
||||
-H "$DEPLOY_HOST" \
|
||||
>> ~/.ssh/known_hosts
|
||||
|
||||
- name: Smoke test the production build
|
||||
run: |
|
||||
npx vite preview --port 4173 &
|
||||
PREVIEW_PID=$!
|
||||
echo "Creating deployment directory if needed..."
|
||||
|
||||
for i in $(seq 1 30); do
|
||||
curl -sf http://localhost:4173/ >/dev/null 2>&1 && break
|
||||
sleep 1
|
||||
done
|
||||
ssh \
|
||||
-i ~/.ssh/deploy_key \
|
||||
-p "${DEPLOY_PORT:-22}" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"mkdir -p '$DEPLOY_PATH'"
|
||||
|
||||
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"
|
||||
}
|
||||
echo "Uploading compose file..."
|
||||
|
||||
check / 200
|
||||
check /staff/login 200
|
||||
check /impressum 200
|
||||
scp \
|
||||
-i ~/.ssh/deploy_key \
|
||||
-P "${DEPLOY_PORT:-22}" \
|
||||
docker-compose.prod.yml \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
|
||||
|
||||
kill "$PREVIEW_PID"
|
||||
echo "Deploying..."
|
||||
|
||||
ssh \
|
||||
-i ~/.ssh/deploy_key \
|
||||
-p "${DEPLOY_PORT:-22}" \
|
||||
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||
"POSTGRES_PASSWORD=$(printf '%q' "$POSTGRES_PASSWORD") \
|
||||
BETTER_AUTH_SECRET=$(printf '%q' "$BETTER_AUTH_SECRET") \
|
||||
BETTER_AUTH_URL=$(printf '%q' "$BETTER_AUTH_URL") \
|
||||
RESEND_API_KEY=$(printf '%q' "$RESEND_API_KEY") \
|
||||
RESERVATIONS_FROM_EMAIL=$(printf '%q' "$RESERVATIONS_FROM_EMAIL") \
|
||||
STAFF_SEED_EMAIL=$(printf '%q' "$STAFF_SEED_EMAIL") \
|
||||
STAFF_SEED_PASSWORD=$(printf '%q' "$STAFF_SEED_PASSWORD") \
|
||||
IMAGE_TAG=$(printf '%q' "$IMAGE_TAG") \
|
||||
bash -c '
|
||||
set -euo pipefail
|
||||
|
||||
cd \"$DEPLOY_PATH\"
|
||||
|
||||
echo \"Pulling images...\"
|
||||
docker compose pull
|
||||
|
||||
echo \"Starting PostgreSQL...\"
|
||||
docker compose up -d postgres
|
||||
|
||||
echo "Running database migrations..."
|
||||
docker compose run --rm app npx drizzle-kit migrate
|
||||
|
||||
echo "Seeding database..."
|
||||
docker compose run --rm app npm run db:seed
|
||||
|
||||
echo "Starting application..."
|
||||
docker compose up -d
|
||||
|
||||
echo \"Cleaning unused images...\"
|
||||
docker image prune -f
|
||||
|
||||
echo \"Deployment complete.\"
|
||||
'"
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Dummy values so the build-time env checks in src/db/index.ts and
|
||||
# src/lib/auth-instance.ts don't throw while bundling. The real values are
|
||||
# supplied at container runtime.
|
||||
ENV DATABASE_URL=postgres://build:build@localhost:5432/build \
|
||||
BETTER_AUTH_SECRET=build-time-placeholder \
|
||||
BETTER_AUTH_URL=http://localhost:3000
|
||||
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
# This build has no standalone Node server entrypoint — dist/server/server.js
|
||||
# only exports a fetch handler. `vite preview` (via the tanstackStart plugin)
|
||||
# is the supported way to serve it, and it re-resolves the app's router entry
|
||||
# from src/ at startup, so the full source tree (not just dist/) has to ship.
|
||||
COPY --from=builder /app ./
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["npm", "run", "preview", "--", "--host", "0.0.0.0", "--port", "3000"]
|
||||
@@ -0,0 +1,35 @@
|
||||
services:
|
||||
app:
|
||||
image: git.lukas-harat.com/lukas/promo_steakhouse:${IMAGE_TAG:-latest}
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- '3003:3000'
|
||||
environment:
|
||||
DATABASE_URL: postgres://steakhouse:${POSTGRES_PASSWORD}@postgres:5432/steakhouse
|
||||
BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET}
|
||||
BETTER_AUTH_URL: ${BETTER_AUTH_URL}
|
||||
STAFF_SEED_EMAIL: ${STAFF_SEED_EMAIL}
|
||||
STAFF_SEED_PASSWORD: ${STAFF_SEED_PASSWORD}
|
||||
RESEND_API_KEY: ${RESEND_API_KEY}
|
||||
RESERVATIONS_FROM_EMAIL: ${RESERVATIONS_FROM_EMAIL}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
POSTGRES_USER: steakhouse
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: steakhouse
|
||||
volumes:
|
||||
- steakhouse-postgres-data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U steakhouse -d steakhouse"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
steakhouse-postgres-data:
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "restaurant_tables" ADD COLUMN "image_url" text;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "restaurant_settings" ADD COLUMN "table_display_mode" text DEFAULT 'individual' NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "restaurant_settings" ADD COLUMN "table_overview_image_url" text;
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "restaurant_tables" ADD COLUMN "marker_x" double precision;--> statement-breakpoint
|
||||
ALTER TABLE "restaurant_tables" ADD COLUMN "marker_y" double precision;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "restaurant_tables" ADD COLUMN "table_number" integer;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "reservations" ADD COLUMN "message" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "reservations" ADD COLUMN "customer_phone" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "user" ADD COLUMN "phone" text;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "reservations" ADD COLUMN "status" text DEFAULT 'confirmed' NOT NULL;
|
||||
@@ -0,0 +1,533 @@
|
||||
{
|
||||
"id": "05eac8a1-b172-4e4d-90d2-a409ecde610a",
|
||||
"prevId": "d03b9103-cb6a-4968-a016-c04270ec176a",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.reservations": {
|
||||
"name": "reservations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"table_id": {
|
||||
"name": "table_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_name": {
|
||||
"name": "customer_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_email": {
|
||||
"name": "customer_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"party_size": {
|
||||
"name": "party_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_time": {
|
||||
"name": "reservation_time",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration_minutes": {
|
||||
"name": "duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"cancel_token": {
|
||||
"name": "cancel_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"reservations_table_id_restaurant_tables_id_fk": {
|
||||
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||
"tableFrom": "reservations",
|
||||
"tableTo": "restaurant_tables",
|
||||
"columnsFrom": [
|
||||
"table_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"reservations_cancel_token_unique": {
|
||||
"name": "reservations_cancel_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"cancel_token"
|
||||
]
|
||||
},
|
||||
"reservations_table_id_reservation_time_unique": {
|
||||
"name": "reservations_table_id_reservation_time_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"table_id",
|
||||
"reservation_time"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_settings": {
|
||||
"name": "restaurant_settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_interval_minutes": {
|
||||
"name": "reservation_interval_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 15
|
||||
},
|
||||
"reservation_duration_minutes": {
|
||||
"name": "reservation_duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"min_lead_time_minutes": {
|
||||
"name": "min_lead_time_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"max_lead_time_days": {
|
||||
"name": "max_lead_time_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_tables": {
|
||||
"name": "restaurant_tables",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"seats": {
|
||||
"name": "seats",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"impersonated_by": {
|
||||
"name": "impersonated_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"banned": {
|
||||
"name": "banned",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_reason": {
|
||||
"name": "ban_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_expires": {
|
||||
"name": "ban_expires",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
{
|
||||
"id": "71bc6bc2-516c-4af1-8c61-3df7814e9976",
|
||||
"prevId": "05eac8a1-b172-4e4d-90d2-a409ecde610a",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.reservations": {
|
||||
"name": "reservations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"table_id": {
|
||||
"name": "table_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_name": {
|
||||
"name": "customer_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_email": {
|
||||
"name": "customer_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"party_size": {
|
||||
"name": "party_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_time": {
|
||||
"name": "reservation_time",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration_minutes": {
|
||||
"name": "duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"cancel_token": {
|
||||
"name": "cancel_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"reservations_table_id_restaurant_tables_id_fk": {
|
||||
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||
"tableFrom": "reservations",
|
||||
"tableTo": "restaurant_tables",
|
||||
"columnsFrom": [
|
||||
"table_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"reservations_cancel_token_unique": {
|
||||
"name": "reservations_cancel_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"cancel_token"
|
||||
]
|
||||
},
|
||||
"reservations_table_id_reservation_time_unique": {
|
||||
"name": "reservations_table_id_reservation_time_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"table_id",
|
||||
"reservation_time"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_settings": {
|
||||
"name": "restaurant_settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_interval_minutes": {
|
||||
"name": "reservation_interval_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 15
|
||||
},
|
||||
"reservation_duration_minutes": {
|
||||
"name": "reservation_duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"min_lead_time_minutes": {
|
||||
"name": "min_lead_time_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"max_lead_time_days": {
|
||||
"name": "max_lead_time_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"table_display_mode": {
|
||||
"name": "table_display_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'individual'"
|
||||
},
|
||||
"table_overview_image_url": {
|
||||
"name": "table_overview_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_tables": {
|
||||
"name": "restaurant_tables",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"seats": {
|
||||
"name": "seats",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"impersonated_by": {
|
||||
"name": "impersonated_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"banned": {
|
||||
"name": "banned",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_reason": {
|
||||
"name": "ban_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_expires": {
|
||||
"name": "ban_expires",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
{
|
||||
"id": "cc3da65b-b162-4866-ac44-b7d177588316",
|
||||
"prevId": "71bc6bc2-516c-4af1-8c61-3df7814e9976",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.reservations": {
|
||||
"name": "reservations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"table_id": {
|
||||
"name": "table_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_name": {
|
||||
"name": "customer_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_email": {
|
||||
"name": "customer_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"party_size": {
|
||||
"name": "party_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_time": {
|
||||
"name": "reservation_time",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration_minutes": {
|
||||
"name": "duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"cancel_token": {
|
||||
"name": "cancel_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"reservations_table_id_restaurant_tables_id_fk": {
|
||||
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||
"tableFrom": "reservations",
|
||||
"tableTo": "restaurant_tables",
|
||||
"columnsFrom": [
|
||||
"table_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"reservations_cancel_token_unique": {
|
||||
"name": "reservations_cancel_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"cancel_token"
|
||||
]
|
||||
},
|
||||
"reservations_table_id_reservation_time_unique": {
|
||||
"name": "reservations_table_id_reservation_time_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"table_id",
|
||||
"reservation_time"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_settings": {
|
||||
"name": "restaurant_settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_interval_minutes": {
|
||||
"name": "reservation_interval_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 15
|
||||
},
|
||||
"reservation_duration_minutes": {
|
||||
"name": "reservation_duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"min_lead_time_minutes": {
|
||||
"name": "min_lead_time_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"max_lead_time_days": {
|
||||
"name": "max_lead_time_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"table_display_mode": {
|
||||
"name": "table_display_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'individual'"
|
||||
},
|
||||
"table_overview_image_url": {
|
||||
"name": "table_overview_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_tables": {
|
||||
"name": "restaurant_tables",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"seats": {
|
||||
"name": "seats",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_x": {
|
||||
"name": "marker_x",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_y": {
|
||||
"name": "marker_y",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"impersonated_by": {
|
||||
"name": "impersonated_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"banned": {
|
||||
"name": "banned",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_reason": {
|
||||
"name": "ban_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_expires": {
|
||||
"name": "ban_expires",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,564 @@
|
||||
{
|
||||
"id": "0914058c-53fc-4e93-b703-a907e0ae72a8",
|
||||
"prevId": "cc3da65b-b162-4866-ac44-b7d177588316",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.reservations": {
|
||||
"name": "reservations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"table_id": {
|
||||
"name": "table_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_name": {
|
||||
"name": "customer_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_email": {
|
||||
"name": "customer_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"party_size": {
|
||||
"name": "party_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_time": {
|
||||
"name": "reservation_time",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration_minutes": {
|
||||
"name": "duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"cancel_token": {
|
||||
"name": "cancel_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"reservations_table_id_restaurant_tables_id_fk": {
|
||||
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||
"tableFrom": "reservations",
|
||||
"tableTo": "restaurant_tables",
|
||||
"columnsFrom": [
|
||||
"table_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"reservations_cancel_token_unique": {
|
||||
"name": "reservations_cancel_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"cancel_token"
|
||||
]
|
||||
},
|
||||
"reservations_table_id_reservation_time_unique": {
|
||||
"name": "reservations_table_id_reservation_time_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"table_id",
|
||||
"reservation_time"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_settings": {
|
||||
"name": "restaurant_settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_interval_minutes": {
|
||||
"name": "reservation_interval_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 15
|
||||
},
|
||||
"reservation_duration_minutes": {
|
||||
"name": "reservation_duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"min_lead_time_minutes": {
|
||||
"name": "min_lead_time_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"max_lead_time_days": {
|
||||
"name": "max_lead_time_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"table_display_mode": {
|
||||
"name": "table_display_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'individual'"
|
||||
},
|
||||
"table_overview_image_url": {
|
||||
"name": "table_overview_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_tables": {
|
||||
"name": "restaurant_tables",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"seats": {
|
||||
"name": "seats",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"table_number": {
|
||||
"name": "table_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_x": {
|
||||
"name": "marker_x",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_y": {
|
||||
"name": "marker_y",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"impersonated_by": {
|
||||
"name": "impersonated_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"banned": {
|
||||
"name": "banned",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_reason": {
|
||||
"name": "ban_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_expires": {
|
||||
"name": "ban_expires",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,570 @@
|
||||
{
|
||||
"id": "c95ecb29-7866-4794-9aad-8a6fba8e7bc4",
|
||||
"prevId": "0914058c-53fc-4e93-b703-a907e0ae72a8",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.reservations": {
|
||||
"name": "reservations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"table_id": {
|
||||
"name": "table_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_name": {
|
||||
"name": "customer_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_email": {
|
||||
"name": "customer_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"party_size": {
|
||||
"name": "party_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_time": {
|
||||
"name": "reservation_time",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration_minutes": {
|
||||
"name": "duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"cancel_token": {
|
||||
"name": "cancel_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"reservations_table_id_restaurant_tables_id_fk": {
|
||||
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||
"tableFrom": "reservations",
|
||||
"tableTo": "restaurant_tables",
|
||||
"columnsFrom": [
|
||||
"table_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"reservations_cancel_token_unique": {
|
||||
"name": "reservations_cancel_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"cancel_token"
|
||||
]
|
||||
},
|
||||
"reservations_table_id_reservation_time_unique": {
|
||||
"name": "reservations_table_id_reservation_time_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"table_id",
|
||||
"reservation_time"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_settings": {
|
||||
"name": "restaurant_settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_interval_minutes": {
|
||||
"name": "reservation_interval_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 15
|
||||
},
|
||||
"reservation_duration_minutes": {
|
||||
"name": "reservation_duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"min_lead_time_minutes": {
|
||||
"name": "min_lead_time_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"max_lead_time_days": {
|
||||
"name": "max_lead_time_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"table_display_mode": {
|
||||
"name": "table_display_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'individual'"
|
||||
},
|
||||
"table_overview_image_url": {
|
||||
"name": "table_overview_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_tables": {
|
||||
"name": "restaurant_tables",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"seats": {
|
||||
"name": "seats",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"table_number": {
|
||||
"name": "table_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_x": {
|
||||
"name": "marker_x",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_y": {
|
||||
"name": "marker_y",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"impersonated_by": {
|
||||
"name": "impersonated_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"banned": {
|
||||
"name": "banned",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_reason": {
|
||||
"name": "ban_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_expires": {
|
||||
"name": "ban_expires",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
{
|
||||
"id": "69162a80-7a1b-480b-85c2-7f11e2f1725c",
|
||||
"prevId": "c95ecb29-7866-4794-9aad-8a6fba8e7bc4",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.reservations": {
|
||||
"name": "reservations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"table_id": {
|
||||
"name": "table_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_name": {
|
||||
"name": "customer_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_email": {
|
||||
"name": "customer_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_phone": {
|
||||
"name": "customer_phone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"party_size": {
|
||||
"name": "party_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_time": {
|
||||
"name": "reservation_time",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration_minutes": {
|
||||
"name": "duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"cancel_token": {
|
||||
"name": "cancel_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"reservations_table_id_restaurant_tables_id_fk": {
|
||||
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||
"tableFrom": "reservations",
|
||||
"tableTo": "restaurant_tables",
|
||||
"columnsFrom": [
|
||||
"table_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"reservations_cancel_token_unique": {
|
||||
"name": "reservations_cancel_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"cancel_token"
|
||||
]
|
||||
},
|
||||
"reservations_table_id_reservation_time_unique": {
|
||||
"name": "reservations_table_id_reservation_time_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"table_id",
|
||||
"reservation_time"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_settings": {
|
||||
"name": "restaurant_settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_interval_minutes": {
|
||||
"name": "reservation_interval_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 15
|
||||
},
|
||||
"reservation_duration_minutes": {
|
||||
"name": "reservation_duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"min_lead_time_minutes": {
|
||||
"name": "min_lead_time_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"max_lead_time_days": {
|
||||
"name": "max_lead_time_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"table_display_mode": {
|
||||
"name": "table_display_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'individual'"
|
||||
},
|
||||
"table_overview_image_url": {
|
||||
"name": "table_overview_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_tables": {
|
||||
"name": "restaurant_tables",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"seats": {
|
||||
"name": "seats",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"table_number": {
|
||||
"name": "table_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_x": {
|
||||
"name": "marker_x",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_y": {
|
||||
"name": "marker_y",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"impersonated_by": {
|
||||
"name": "impersonated_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"banned": {
|
||||
"name": "banned",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_reason": {
|
||||
"name": "ban_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_expires": {
|
||||
"name": "ban_expires",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
{
|
||||
"id": "b5488545-3b1f-4145-a565-f4fcdc5b3748",
|
||||
"prevId": "69162a80-7a1b-480b-85c2-7f11e2f1725c",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.reservations": {
|
||||
"name": "reservations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"table_id": {
|
||||
"name": "table_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_name": {
|
||||
"name": "customer_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_email": {
|
||||
"name": "customer_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_phone": {
|
||||
"name": "customer_phone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"party_size": {
|
||||
"name": "party_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_time": {
|
||||
"name": "reservation_time",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration_minutes": {
|
||||
"name": "duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"cancel_token": {
|
||||
"name": "cancel_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"reservations_table_id_restaurant_tables_id_fk": {
|
||||
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||
"tableFrom": "reservations",
|
||||
"tableTo": "restaurant_tables",
|
||||
"columnsFrom": [
|
||||
"table_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"reservations_cancel_token_unique": {
|
||||
"name": "reservations_cancel_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"cancel_token"
|
||||
]
|
||||
},
|
||||
"reservations_table_id_reservation_time_unique": {
|
||||
"name": "reservations_table_id_reservation_time_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"table_id",
|
||||
"reservation_time"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_settings": {
|
||||
"name": "restaurant_settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_interval_minutes": {
|
||||
"name": "reservation_interval_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 15
|
||||
},
|
||||
"reservation_duration_minutes": {
|
||||
"name": "reservation_duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"min_lead_time_minutes": {
|
||||
"name": "min_lead_time_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"max_lead_time_days": {
|
||||
"name": "max_lead_time_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"table_display_mode": {
|
||||
"name": "table_display_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'individual'"
|
||||
},
|
||||
"table_overview_image_url": {
|
||||
"name": "table_overview_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_tables": {
|
||||
"name": "restaurant_tables",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"seats": {
|
||||
"name": "seats",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"table_number": {
|
||||
"name": "table_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_x": {
|
||||
"name": "marker_x",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_y": {
|
||||
"name": "marker_y",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"impersonated_by": {
|
||||
"name": "impersonated_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"banned": {
|
||||
"name": "banned",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_reason": {
|
||||
"name": "ban_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_expires": {
|
||||
"name": "ban_expires",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,589 @@
|
||||
{
|
||||
"id": "b91348a4-3a6f-4c6b-a7a0-c60c06eeb9f9",
|
||||
"prevId": "b5488545-3b1f-4145-a565-f4fcdc5b3748",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.account": {
|
||||
"name": "account",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"account_id": {
|
||||
"name": "account_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"provider_id": {
|
||||
"name": "provider_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"access_token": {
|
||||
"name": "access_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token": {
|
||||
"name": "refresh_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"id_token": {
|
||||
"name": "id_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"access_token_expires_at": {
|
||||
"name": "access_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"refresh_token_expires_at": {
|
||||
"name": "refresh_token_expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"scope": {
|
||||
"name": "scope",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"password": {
|
||||
"name": "password",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"account_user_id_user_id_fk": {
|
||||
"name": "account_user_id_user_id_fk",
|
||||
"tableFrom": "account",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.reservations": {
|
||||
"name": "reservations",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"table_id": {
|
||||
"name": "table_id",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_name": {
|
||||
"name": "customer_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_email": {
|
||||
"name": "customer_email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"customer_phone": {
|
||||
"name": "customer_phone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"message": {
|
||||
"name": "message",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"party_size": {
|
||||
"name": "party_size",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'confirmed'"
|
||||
},
|
||||
"reservation_time": {
|
||||
"name": "reservation_time",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"duration_minutes": {
|
||||
"name": "duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"cancel_token": {
|
||||
"name": "cancel_token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"reservations_table_id_restaurant_tables_id_fk": {
|
||||
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||
"tableFrom": "reservations",
|
||||
"tableTo": "restaurant_tables",
|
||||
"columnsFrom": [
|
||||
"table_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"reservations_cancel_token_unique": {
|
||||
"name": "reservations_cancel_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"cancel_token"
|
||||
]
|
||||
},
|
||||
"reservations_table_id_reservation_time_unique": {
|
||||
"name": "reservations_table_id_reservation_time_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"table_id",
|
||||
"reservation_time"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_settings": {
|
||||
"name": "restaurant_settings",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"reservation_interval_minutes": {
|
||||
"name": "reservation_interval_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 15
|
||||
},
|
||||
"reservation_duration_minutes": {
|
||||
"name": "reservation_duration_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 90
|
||||
},
|
||||
"min_lead_time_minutes": {
|
||||
"name": "min_lead_time_minutes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"max_lead_time_days": {
|
||||
"name": "max_lead_time_days",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": 60
|
||||
},
|
||||
"table_display_mode": {
|
||||
"name": "table_display_mode",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'individual'"
|
||||
},
|
||||
"table_overview_image_url": {
|
||||
"name": "table_overview_image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.restaurant_tables": {
|
||||
"name": "restaurant_tables",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "serial",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"seats": {
|
||||
"name": "seats",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"table_number": {
|
||||
"name": "table_number",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"image_url": {
|
||||
"name": "image_url",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_x": {
|
||||
"name": "marker_x",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"marker_y": {
|
||||
"name": "marker_y",
|
||||
"type": "double precision",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.session": {
|
||||
"name": "session",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token": {
|
||||
"name": "token",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"ip_address": {
|
||||
"name": "ip_address",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"impersonated_by": {
|
||||
"name": "impersonated_by",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {
|
||||
"session_user_id_user_id_fk": {
|
||||
"name": "session_user_id_user_id_fk",
|
||||
"tableFrom": "session",
|
||||
"tableTo": "user",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"session_token_unique": {
|
||||
"name": "session_token_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.user": {
|
||||
"name": "user",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"email_verified": {
|
||||
"name": "email_verified",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"image": {
|
||||
"name": "image",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"banned": {
|
||||
"name": "banned",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_reason": {
|
||||
"name": "ban_reason",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ban_expires": {
|
||||
"name": "ban_expires",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"phone": {
|
||||
"name": "phone",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"user_email_unique": {
|
||||
"name": "user_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.verification": {
|
||||
"name": "verification",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"identifier": {
|
||||
"name": "identifier",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"value": {
|
||||
"name": "value",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,62 @@
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+278
@@ -10,6 +10,7 @@
|
||||
"@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",
|
||||
@@ -1310,6 +1311,271 @@
|
||||
"@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",
|
||||
@@ -3732,6 +3998,18 @@
|
||||
"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",
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#17120f"/>
|
||||
<rect x="2" y="2" width="60" height="60" rx="12" fill="none" stroke="#c9a15a" stroke-width="2"/>
|
||||
<text x="32" y="44" font-family="Georgia, 'Times New Roman', serif" font-size="34" font-weight="600" text-anchor="middle" fill="#e0bb78">M</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 371 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 500"><path d="M 79.47,45.64 C 71.12,45.78 60.80,48.79 55.89,52.64 C 50.98,56.49 48.85,62.75 50.00,68.74 C 51.15,74.74 56.86,81.99 62.81,88.62 C 68.75,95.24 77.19,102.34 85.70,108.49 C 94.20,114.64 104.30,120.74 113.83,125.51 C 123.36,130.27 133.30,134.13 142.89,137.07 C 152.48,140.00 161.90,141.73 171.36,143.12 C 180.83,144.51 190.19,144.97 199.69,145.40 C 209.20,145.83 218.68,145.63 228.38,145.70 C 238.08,145.76 247.88,145.49 257.92,145.78 C 267.95,146.06 278.42,146.34 288.57,147.41 C 298.73,148.47 309.21,149.81 318.83,152.17 C 328.44,154.53 337.98,157.42 346.24,161.57 C 354.50,165.73 362.29,170.66 368.39,177.11 C 374.49,183.56 379.31,191.38 382.83,200.28 C 386.35,209.19 386.77,220.92 389.51,230.55 C 392.24,240.17 395.02,249.65 399.25,258.04 C 403.47,266.43 409.89,273.06 414.85,280.88 C 419.80,288.70 425.82,296.22 428.98,304.97 C 432.14,313.71 433.03,323.66 433.82,333.38 C 434.62,343.09 433.47,353.42 433.75,363.25 C 434.04,373.07 433.68,382.96 435.52,392.31 C 437.35,401.67 440.01,411.11 444.78,419.37 C 449.55,427.63 456.73,436.04 464.14,441.88 C 471.56,447.71 480.75,452.58 489.28,454.36 C 497.81,456.14 507.05,455.10 515.32,452.56 C 523.60,450.02 531.95,444.80 538.93,439.13 C 545.90,433.45 552.25,426.02 557.18,418.50 C 562.11,410.98 565.65,402.67 568.51,394.01 C 571.36,385.34 572.93,376.03 574.30,366.50 C 575.67,356.97 575.77,346.70 576.73,336.83 C 577.68,326.96 577.97,316.66 580.02,307.28 C 582.06,297.89 584.88,288.90 588.99,280.55 C 593.09,272.19 599.37,265.01 604.64,257.15 C 609.90,249.28 616.07,241.81 620.59,233.35 C 625.11,224.89 628.08,215.26 631.74,206.39 C 635.41,197.51 637.83,187.81 642.59,180.10 C 647.35,172.40 652.72,165.26 660.30,160.18 C 667.89,155.10 678.09,152.07 688.10,149.61 C 698.10,147.15 709.79,146.33 720.34,145.43 C 730.89,144.53 741.33,144.43 751.41,144.20 C 761.48,143.97 771.14,144.17 780.77,144.04 C 790.40,143.91 799.75,143.96 809.20,143.44 C 818.65,142.91 827.94,142.31 837.47,140.89 C 846.99,139.47 856.56,137.70 866.35,134.89 C 876.14,132.08 886.65,128.54 896.23,124.04 C 905.80,119.54 915.88,114.14 923.78,107.87 C 931.68,101.61 939.26,94.04 943.63,86.46 C 948.00,78.89 951.03,69.06 950.00,62.42 C 948.97,55.78 943.45,49.15 937.43,46.64 C 931.42,44.12 922.45,45.15 913.92,47.34 C 905.39,49.53 895.66,55.24 886.23,59.77 C 876.79,64.29 866.85,70.36 857.32,74.49 C 847.79,78.63 838.46,82.23 829.04,84.56 C 819.61,86.89 810.31,88.20 800.77,88.46 C 791.24,88.71 781.58,87.55 771.84,86.11 C 762.10,84.67 752.23,82.10 742.35,79.81 C 732.47,77.52 722.50,74.57 712.58,72.36 C 702.65,70.15 692.68,67.75 682.79,66.54 C 672.91,65.34 663.05,64.73 653.27,65.12 C 643.50,65.52 633.80,67.12 624.15,68.91 C 614.49,70.71 604.90,73.41 595.33,75.88 C 585.76,78.36 576.24,81.36 566.73,83.76 C 557.21,86.17 547.73,88.72 538.24,90.29 C 528.75,91.86 519.27,93.10 509.77,93.19 C 500.26,93.28 490.75,92.26 481.22,90.82 C 471.68,89.39 462.13,86.92 452.56,84.58 C 442.98,82.23 433.39,79.25 423.76,76.77 C 414.14,74.29 404.50,71.56 394.82,69.72 C 385.14,67.88 375.44,66.28 365.70,65.75 C 355.96,65.22 346.18,65.68 336.39,66.54 C 326.60,67.41 316.78,69.20 306.95,70.92 C 297.12,72.64 287.26,74.96 277.41,76.86 C 267.56,78.77 257.70,80.94 247.85,82.35 C 238.00,83.77 228.13,85.07 218.32,85.38 C 208.51,85.68 198.66,85.47 188.99,84.19 C 179.32,82.91 169.69,80.84 160.31,77.69 C 150.93,74.54 141.76,69.61 132.71,65.30 C 123.65,60.99 114.85,55.10 105.97,51.83 C 97.10,48.55 87.82,45.51 79.47,45.64 Z" fill="#7B2D2F"/></svg>
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
Binary file not shown.
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
type Props = {
|
||||
label?: string
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
function DotsIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="currentColor" aria-hidden="true">
|
||||
<circle cx="12" cy="5" r="1.8" />
|
||||
<circle cx="12" cy="12" r="1.8" />
|
||||
<circle cx="12" cy="19" r="1.8" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
type Position = { top: number; right: number }
|
||||
|
||||
// A small "⋮" menu that reveals its children (typically a stack of `.btn`s) in a
|
||||
// dropdown — used to tuck row actions away instead of listing every button inline.
|
||||
// The panel is portaled to <body> and positioned with `position: fixed` from a
|
||||
// measured rect rather than anchored with `position: absolute` in place, since
|
||||
// rows live inside `.table-scroll` (`overflow-x: auto`, which per the CSS spec
|
||||
// forces `overflow-y` to clip too) — an in-place dropdown on the last row or two
|
||||
// would otherwise get cut off at the table's bottom edge.
|
||||
export function ActionMenu({ label = 'Aktionen', children }: Props) {
|
||||
const [position, setPosition] = useState<Position | null>(null)
|
||||
const triggerRef = useRef<HTMLButtonElement>(null)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const open = position !== null
|
||||
|
||||
function close() {
|
||||
setPosition(null)
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (open) {
|
||||
close()
|
||||
return
|
||||
}
|
||||
|
||||
const rect = triggerRef.current?.getBoundingClientRect()
|
||||
if (!rect) return
|
||||
setPosition({ top: rect.bottom + 4, right: window.innerWidth - rect.right })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
function handleOutsideClick(event: MouseEvent) {
|
||||
const target = event.target as Node
|
||||
if (!triggerRef.current?.contains(target) && !panelRef.current?.contains(target)) {
|
||||
close()
|
||||
}
|
||||
}
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') close()
|
||||
}
|
||||
// The menu's position is measured once, on open — rather than track scroll to
|
||||
// keep it glued to the trigger, just close it, matching how most native and
|
||||
// web dropdowns behave when the page moves under them.
|
||||
function handleScroll() {
|
||||
close()
|
||||
}
|
||||
|
||||
document.addEventListener('mousedown', handleOutsideClick)
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
window.addEventListener('scroll', handleScroll, true)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleOutsideClick)
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
window.removeEventListener('scroll', handleScroll, true)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className="action-menu-trigger"
|
||||
aria-label={label}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={open}
|
||||
onClick={toggle}
|
||||
>
|
||||
<DotsIcon />
|
||||
</button>
|
||||
|
||||
{open &&
|
||||
position &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="action-menu-panel"
|
||||
role="menu"
|
||||
style={{ top: position.top, right: position.right }}
|
||||
onClick={close}
|
||||
>
|
||||
{children}
|
||||
</div>,
|
||||
document.body,
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -3,7 +3,11 @@ import { useEffect, useRef } from 'react'
|
||||
type Props = {
|
||||
open: boolean
|
||||
title: string
|
||||
message: string
|
||||
message: React.ReactNode
|
||||
// A final, visually distinct callout for what irreversible actions this
|
||||
// confirms — kept separate from `message` so callers don't have to hand-roll
|
||||
// the emphasis every time (e.g. "This cannot be undone.").
|
||||
warning?: React.ReactNode
|
||||
confirmLabel?: string
|
||||
cancelLabel?: string
|
||||
danger?: boolean
|
||||
@@ -11,10 +15,26 @@ 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,
|
||||
@@ -54,6 +74,12 @@ 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}
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'
|
||||
|
||||
type Props = {
|
||||
src: string
|
||||
}
|
||||
|
||||
// pdfjs-dist touches the DOM/Canvas at import time, so it's loaded lazily inside
|
||||
// the effect below instead of at module scope — this file also runs during SSR.
|
||||
export function PdfMenu({ src }: Props) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const pdfRef = useRef<PDFDocumentProxy | null>(null)
|
||||
const directionRef = useRef(1)
|
||||
const hasRenderedOnceRef = useRef(false)
|
||||
const [error, setError] = useState(false)
|
||||
const [numPages, setNumPages] = useState(0)
|
||||
const [pageNumber, setPageNumber] = useState(1)
|
||||
const [isBusy, setIsBusy] = useState(false)
|
||||
|
||||
function goToPage(next: number) {
|
||||
directionRef.current = next > pageNumber ? 1 : -1
|
||||
setPageNumber(next)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setError(false)
|
||||
setNumPages(0)
|
||||
setPageNumber(1)
|
||||
pdfRef.current = null
|
||||
hasRenderedOnceRef.current = false
|
||||
|
||||
async function load() {
|
||||
const pdfjsLib = await import('pdfjs-dist')
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).href
|
||||
|
||||
try {
|
||||
const pdf = await pdfjsLib.getDocument({ url: src }).promise
|
||||
if (cancelled) return
|
||||
pdfRef.current = pdf
|
||||
setNumPages(pdf.numPages)
|
||||
} catch (err) {
|
||||
console.error('PdfMenu failed to load', err)
|
||||
if (!cancelled) setError(true)
|
||||
}
|
||||
}
|
||||
|
||||
load()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [src])
|
||||
|
||||
useEffect(() => {
|
||||
const pdf = pdfRef.current
|
||||
const canvas = canvasRef.current
|
||||
const container = containerRef.current
|
||||
if (!pdf || !canvas || !container || numPages === 0) return
|
||||
|
||||
let cancelled = false
|
||||
let renderTask: RenderTask | undefined
|
||||
let exitTimeout: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise<void>((resolve) => {
|
||||
exitTimeout = setTimeout(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
async function render(pdf: PDFDocumentProxy, canvas: HTMLCanvasElement, container: HTMLDivElement) {
|
||||
const direction = directionRef.current
|
||||
setIsBusy(true)
|
||||
|
||||
if (hasRenderedOnceRef.current) {
|
||||
canvas.style.transition = 'opacity 0.18s ease, transform 0.18s ease'
|
||||
canvas.style.opacity = '0'
|
||||
canvas.style.transform = `translateX(${direction * -2}rem)`
|
||||
await wait(180)
|
||||
if (cancelled) return
|
||||
}
|
||||
|
||||
try {
|
||||
const page = await pdf.getPage(pageNumber)
|
||||
if (cancelled) return
|
||||
const unscaledViewport = page.getViewport({ scale: 1 })
|
||||
const scale = container.clientWidth / unscaledViewport.width
|
||||
const viewport = page.getViewport({ scale })
|
||||
|
||||
const pixelRatio = window.devicePixelRatio || 1
|
||||
canvas.width = viewport.width * pixelRatio
|
||||
canvas.height = viewport.height * pixelRatio
|
||||
canvas.style.width = `${viewport.width}px`
|
||||
canvas.style.height = `${viewport.height}px`
|
||||
|
||||
const context = canvas.getContext('2d')
|
||||
if (!context) return
|
||||
context.scale(pixelRatio, pixelRatio)
|
||||
|
||||
renderTask = page.render({ canvasContext: context, viewport, canvas })
|
||||
await renderTask.promise
|
||||
if (cancelled) return
|
||||
|
||||
// Jump to the entry offset with transitions off, then force the browser to
|
||||
// commit that position before re-enabling them — otherwise the two style
|
||||
// writes land in the same frame and the entry animation never paints.
|
||||
canvas.style.transition = 'none'
|
||||
canvas.style.transform = `translateX(${direction * 2}rem)`
|
||||
canvas.offsetHeight
|
||||
canvas.style.transition = 'opacity 0.22s ease, transform 0.22s ease'
|
||||
canvas.style.opacity = '1'
|
||||
canvas.style.transform = 'translateX(0)'
|
||||
hasRenderedOnceRef.current = true
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error('PdfMenu failed to render page', err)
|
||||
setError(true)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
render(pdf, canvas, container)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(exitTimeout)
|
||||
renderTask?.cancel()
|
||||
}
|
||||
}, [numPages, pageNumber])
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<p className="field-hint">
|
||||
Die Speisekarte konnte nicht geladen werden.{' '}
|
||||
<a href={src} target="_blank" rel="noopener noreferrer">
|
||||
Als PDF öffnen
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pdf-menu" ref={containerRef}>
|
||||
<div className="pdf-menu-viewport">
|
||||
<canvas className="pdf-menu-page" ref={canvasRef} />
|
||||
{numPages === 0 && (
|
||||
<div className="pdf-menu-loading" role="status">
|
||||
<div className="pdf-menu-spinner" aria-hidden="true" />
|
||||
<p>Die Speisekarte wird aufgetischt …</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{numPages > 1 && (
|
||||
<div className="pdf-menu-pagination">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => goToPage(pageNumber - 1)}
|
||||
disabled={isBusy || pageNumber <= 1}
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<span>
|
||||
Seite {pageNumber} von {numPages}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => goToPage(pageNumber + 1)}
|
||||
disabled={isBusy || pageNumber >= numPages}
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<p className="field-hint">
|
||||
Speisekarte nicht geladen?{' '}
|
||||
<a href={src} target="_blank" rel="noopener noreferrer">
|
||||
Klick hier
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
customerName: string
|
||||
customerEmail: string
|
||||
tableName: string
|
||||
date: string
|
||||
startTime: string
|
||||
endTime: string
|
||||
partySize: number
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function FlameBadgeIcon() {
|
||||
return (
|
||||
<svg className="reservation-confirmed-flame" viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M12 2c1 3-3 4-3 7.5A3.5 3.5 0 0 0 12 13a3.5 3.5 0 0 0 3-5.3c1.5 1 2.5 3 2.5 5a5.5 5.5 0 1 1-11 0C6.5 8.5 9.5 6 12 2Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function ReservationConfirmedModal({
|
||||
open,
|
||||
customerName,
|
||||
customerEmail,
|
||||
tableName,
|
||||
date,
|
||||
startTime,
|
||||
endTime,
|
||||
partySize,
|
||||
onClose,
|
||||
}: Props) {
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
closeRef.current?.focus()
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="confirm-dialog reservation-confirmed-dialog"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reservation-confirmed-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<FlameBadgeIcon />
|
||||
|
||||
<h2 id="reservation-confirmed-title" className="reservation-confirmed-title">
|
||||
Tisch reserviert!
|
||||
</h2>
|
||||
<p className="reservation-confirmed-message">
|
||||
Bis bald, {customerName}! Wir halten Ihnen den Platz warm und legen schon mal Kohle nach.
|
||||
</p>
|
||||
|
||||
<dl className="detail-list">
|
||||
<div>
|
||||
<dt>Tisch</dt>
|
||||
<dd>{tableName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Datum</dt>
|
||||
<dd>{date}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Uhrzeit</dt>
|
||||
<dd>
|
||||
{startTime} – {endTime}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Personen</dt>
|
||||
<dd>{partySize}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<p className="reservation-confirmed-footnote">
|
||||
Eine Bestätigung ist unterwegs an <strong>{customerEmail}</strong>. Dort finden Sie auch einen
|
||||
Link, falls sich doch noch etwas ändert.
|
||||
</p>
|
||||
|
||||
<div className="confirm-dialog-actions">
|
||||
<button ref={closeRef} type="button" className="btn btn-primary" onClick={onClose}>
|
||||
Alles klar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -14,6 +14,9 @@ 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
|
||||
@@ -101,6 +104,7 @@ export function ReservationDateTimeField({
|
||||
bookedTimes = [],
|
||||
intervalMinutes = RESERVATION_INTERVAL_MINUTES,
|
||||
minLeadTimeMinutes = 0,
|
||||
durationMinutes = RESERVATION_INTERVAL_MINUTES,
|
||||
maxLeadTimeDays,
|
||||
}: Props) {
|
||||
const { date, time } = splitValue(value)
|
||||
@@ -133,9 +137,44 @@ 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.
|
||||
@@ -154,7 +193,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)
|
||||
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes, durationMinutes)
|
||||
}
|
||||
|
||||
function isSelectableDate(dateStr: string) {
|
||||
@@ -250,45 +289,53 @@ export function ReservationDateTimeField({
|
||||
</button>
|
||||
|
||||
{isCalendarOpen && (
|
||||
<div className="date-popover" role="dialog" aria-label="Datum auswählen">
|
||||
<div className="date-popover-header">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Vorheriger Monat"
|
||||
disabled={isAtEarliestMonth}
|
||||
onClick={() => goToMonth(-1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span>{monthLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Nächster Monat"
|
||||
disabled={isAtLatestMonth}
|
||||
onClick={() => goToMonth(1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<div className="date-popover-weekdays">
|
||||
{WEEKDAY_HEADER_LABELS.map((label) => (
|
||||
<span key={label}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="date-popover-grid">
|
||||
{getMonthGrid(view.year, view.month)
|
||||
.flat()
|
||||
.map((cell, index) => (
|
||||
<button
|
||||
key={cell?.dateStr ?? `blank-${index}`}
|
||||
type="button"
|
||||
className={cell?.dateStr === date ? 'is-selected' : undefined}
|
||||
disabled={!cell || !isSelectableDate(cell.dateStr)}
|
||||
onClick={() => cell && handleDayPick(cell.dateStr)}
|
||||
>
|
||||
{cell?.day ?? ''}
|
||||
</button>
|
||||
<div className="date-popover-backdrop" onClick={() => setIsCalendarOpen(false)}>
|
||||
<div
|
||||
className="date-popover"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Datum auswählen"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="date-popover-header">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Vorheriger Monat"
|
||||
disabled={isAtEarliestMonth}
|
||||
onClick={() => goToMonth(-1)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
<span>{monthLabel}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Nächster Monat"
|
||||
disabled={isAtLatestMonth}
|
||||
onClick={() => goToMonth(1)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
</div>
|
||||
<div className="date-popover-weekdays">
|
||||
{WEEKDAY_HEADER_LABELS.map((label) => (
|
||||
<span key={label}>{label}</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="date-popover-grid">
|
||||
{getMonthGrid(view.year, view.month)
|
||||
.flat()
|
||||
.map((cell, index) => (
|
||||
<button
|
||||
key={cell?.dateStr ?? `blank-${index}`}
|
||||
type="button"
|
||||
className={cell?.dateStr === date ? 'is-selected' : undefined}
|
||||
disabled={!cell || !isSelectableDate(cell.dateStr)}
|
||||
onClick={() => cell && handleDayPick(cell.dateStr)}
|
||||
>
|
||||
{cell?.day ?? ''}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { ActionMenu } from '#/components/action-menu'
|
||||
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
|
||||
import { formatDateOnly, formatDateTimeLocal } from '#/lib/datetime'
|
||||
import { generateTimeOptions, getNextAvailableSlot, getOpeningRangesForWeekday } from '#/lib/opening-hours'
|
||||
import {
|
||||
RESERVATION_STATUS_LABELS,
|
||||
type ReservationStatus,
|
||||
type getReservations,
|
||||
type getTables,
|
||||
} from '#/lib/reservations'
|
||||
import { type getReservationSettings } from '#/lib/settings'
|
||||
|
||||
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
|
||||
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
|
||||
type SettingsRow = Awaited<ReturnType<typeof getReservationSettings>>
|
||||
|
||||
export type ReservationPayload = {
|
||||
tableId: number
|
||||
customerName: string
|
||||
customerEmail: string
|
||||
customerPhone: string
|
||||
message: string
|
||||
partySize: number
|
||||
reservationTime: string
|
||||
}
|
||||
|
||||
type FormState = {
|
||||
tableId: number | string
|
||||
customerName: string
|
||||
customerEmail: string
|
||||
customerPhone: string
|
||||
message: string
|
||||
partySize: number
|
||||
reservationTime: string
|
||||
}
|
||||
|
||||
type Props = {
|
||||
// `null` reservation with `open: true` is "create" mode.
|
||||
open: boolean
|
||||
reservation: ReservationRow | null
|
||||
tables: TableRow[]
|
||||
settings: SettingsRow
|
||||
reservations: ReservationRow[]
|
||||
onClose: () => void
|
||||
onCreate: (payload: ReservationPayload) => Promise<void>
|
||||
onUpdate: (id: number, payload: ReservationPayload) => Promise<void>
|
||||
onStatusChange: (status: ReservationStatus) => void
|
||||
isUpdatingStatus?: boolean
|
||||
onDelete: () => void
|
||||
statusError?: string | null
|
||||
}
|
||||
|
||||
const STATUS_OPTIONS = Object.keys(RESERVATION_STATUS_LABELS) as ReservationStatus[]
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'full',
|
||||
})
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
|
||||
const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
|
||||
const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
|
||||
|
||||
function emptyForm(defaultTableId: number | string, settings: SettingsRow): FormState {
|
||||
const slot = getNextAvailableSlot(
|
||||
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.reservationDurationMinutes,
|
||||
)
|
||||
|
||||
return {
|
||||
tableId: defaultTableId,
|
||||
customerName: '',
|
||||
customerEmail: '',
|
||||
customerPhone: '',
|
||||
message: '',
|
||||
partySize: 2,
|
||||
reservationTime: slot ? `${slot.date}T${slot.time}` : '',
|
||||
}
|
||||
}
|
||||
|
||||
function formFromReservation(reservation: ReservationRow): FormState {
|
||||
return {
|
||||
tableId: reservation.tableId,
|
||||
customerName: reservation.customerName,
|
||||
customerEmail: reservation.customerEmail,
|
||||
customerPhone: reservation.customerPhone ?? '',
|
||||
message: reservation.message ?? '',
|
||||
partySize: reservation.partySize,
|
||||
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
|
||||
}
|
||||
}
|
||||
|
||||
// Rendered with a `key` (see staff.index.tsx) so a fresh instance mounts every time
|
||||
// the dialog opens — that's what lets this component safely derive its initial
|
||||
// state from props once, instead of syncing it via effects.
|
||||
export function ReservationDialog({
|
||||
open,
|
||||
reservation,
|
||||
tables,
|
||||
settings,
|
||||
reservations,
|
||||
onClose,
|
||||
onCreate,
|
||||
onUpdate,
|
||||
onStatusChange,
|
||||
isUpdatingStatus,
|
||||
onDelete,
|
||||
statusError,
|
||||
}: Props) {
|
||||
const [isEditing, setIsEditing] = useState(reservation === null)
|
||||
const [form, setForm] = useState<FormState>(() =>
|
||||
reservation ? formFromReservation(reservation) : emptyForm(tables[0]?.id ?? '', settings),
|
||||
)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
// Other reservations already sitting on the currently selected table + date, so the
|
||||
// time picker can block them instead of letting staff pick a slot the server will
|
||||
// just reject as double-booked. A reservation occupies its whole configured
|
||||
// duration, not just its exact start time, so every interval-grid slot that falls
|
||||
// inside that window counts as taken too. `minLeadTimeMinutes` is reused here as a
|
||||
// table turnover buffer, extending the taken window on both sides so back-to-back
|
||||
// bookings on the same table always leave that much of a gap.
|
||||
const [selectedDate] = form.reservationTime.split('T')
|
||||
const otherReservationsOnSelection = reservations.filter(
|
||||
(r) =>
|
||||
r.id !== reservation?.id &&
|
||||
String(r.tableId) === String(form.tableId) &&
|
||||
formatDateOnly(new Date(r.reservationTime)) === selectedDate,
|
||||
)
|
||||
const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
|
||||
const bookedTimesForForm = selectedDate
|
||||
? generateTimeOptions(
|
||||
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.reservationDurationMinutes,
|
||||
).filter((option) => {
|
||||
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
|
||||
return otherReservationsOnSelection.some((r) => {
|
||||
const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
|
||||
const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
|
||||
return slotStart >= otherStart && slotStart < otherEnd
|
||||
})
|
||||
})
|
||||
: []
|
||||
|
||||
// Mirrors the form fields' own `required`/`type`/`pattern`/`min`/`max` constraints,
|
||||
// so the submit button is disabled until they'd all actually pass — instead of only
|
||||
// catching an invalid table/time on submit and leaving everything else to the
|
||||
// browser's native validation popup.
|
||||
const isFormValid =
|
||||
Boolean(form.tableId) &&
|
||||
form.customerName.trim().length > 0 &&
|
||||
EMAIL_PATTERN.test(form.customerEmail) &&
|
||||
PHONE_PATTERN.test(form.customerPhone) &&
|
||||
form.partySize >= 1 &&
|
||||
form.partySize <= 20 &&
|
||||
Boolean(form.reservationTime.split('T')[1])
|
||||
|
||||
function startEditing() {
|
||||
if (reservation) setForm(formFromReservation(reservation))
|
||||
setError(null)
|
||||
setIsEditing(true)
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (!reservation) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
setForm(formFromReservation(reservation))
|
||||
setError(null)
|
||||
setIsEditing(false)
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
|
||||
if (!form.tableId) {
|
||||
setError('Bitte wählen Sie einen Tisch aus')
|
||||
return
|
||||
}
|
||||
|
||||
if (!form.reservationTime.split('T')[1]) {
|
||||
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const payload: ReservationPayload = {
|
||||
tableId: Number(form.tableId),
|
||||
customerName: form.customerName,
|
||||
customerEmail: form.customerEmail,
|
||||
customerPhone: form.customerPhone,
|
||||
message: form.message,
|
||||
partySize: Number(form.partySize),
|
||||
reservationTime: new Date(form.reservationTime).toISOString(),
|
||||
}
|
||||
|
||||
if (reservation) {
|
||||
await onUpdate(reservation.id, payload)
|
||||
} else {
|
||||
await onCreate(payload)
|
||||
}
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const title = reservation
|
||||
? isEditing
|
||||
? `Reservierung von ${reservation.customerName} bearbeiten`
|
||||
: `Reservierung von ${reservation.customerName}`
|
||||
: 'Neue Reservierung'
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className={`confirm-dialog reservation-detail-dialog${isEditing ? ' reservation-dialog-editing' : ''}`}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="reservation-dialog-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="confirm-dialog-header">
|
||||
<h2 id="reservation-dialog-title" className="confirm-dialog-title">
|
||||
{title}
|
||||
</h2>
|
||||
{reservation && !isEditing && (
|
||||
<div className="confirm-dialog-header-actions">
|
||||
<ActionMenu label={`Weitere Aktionen für Reservierung von ${reservation.customerName}`}>
|
||||
<button type="button" className="btn btn-danger" onClick={onDelete}>
|
||||
Löschen
|
||||
</button>
|
||||
</ActionMenu>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<form className="reservation-form reservation-form-dialog" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Tisch
|
||||
<select
|
||||
value={form.tableId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tableId: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="" disabled>
|
||||
Tisch auswählen
|
||||
</option>
|
||||
{tables.map((table) => (
|
||||
<option key={table.id} value={table.id}>
|
||||
{table.name} ({table.seats} Plätze)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={form.customerName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, customerName: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
E-Mail
|
||||
<input
|
||||
type="email"
|
||||
value={form.customerEmail}
|
||||
onChange={(e) => setForm((f) => ({ ...f, customerEmail: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Telefon
|
||||
<input
|
||||
type="tel"
|
||||
inputMode="tel"
|
||||
pattern={PHONE_PATTERN_SOURCE}
|
||||
title="Nur Ziffern, Leerzeichen und + ( ) - ."
|
||||
value={form.customerPhone}
|
||||
onChange={(e) => setForm((f) => ({ ...f, customerPhone: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Anzahl Personen
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={20}
|
||||
value={form.partySize}
|
||||
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Datum & Uhrzeit
|
||||
<ReservationDateTimeField
|
||||
value={form.reservationTime}
|
||||
onChange={(reservationTime) => setForm((f) => ({ ...f, reservationTime }))}
|
||||
bookedTimes={bookedTimesForForm}
|
||||
intervalMinutes={settings.reservationIntervalMinutes}
|
||||
minLeadTimeMinutes={settings.minLeadTimeMinutes}
|
||||
durationMinutes={settings.reservationDurationMinutes}
|
||||
maxLeadTimeDays={settings.maxLeadTimeDays}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Nachricht (optional)
|
||||
<textarea
|
||||
rows={2}
|
||||
maxLength={500}
|
||||
value={form.message}
|
||||
onChange={(e) => setForm((f) => ({ ...f, message: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{error && <p className="message message-error">{error}</p>}
|
||||
|
||||
<div className="confirm-dialog-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={cancelEditing}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className="btn btn-primary" disabled={isSubmitting || !isFormValid}>
|
||||
{isSubmitting ? 'Wird gespeichert …' : reservation ? 'Speichern' : 'Anlegen'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
reservation && (
|
||||
<>
|
||||
<div className="status-options">
|
||||
{STATUS_OPTIONS.map((status) => (
|
||||
<button
|
||||
key={status}
|
||||
type="button"
|
||||
className={`status-badge status-option status-${status}${
|
||||
reservation.status === status ? ' is-active' : ''
|
||||
}`}
|
||||
disabled={isUpdatingStatus}
|
||||
onClick={() => onStatusChange(status)}
|
||||
>
|
||||
{RESERVATION_STATUS_LABELS[status]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<dl className="detail-list">
|
||||
{reservation.tableNumber !== null && (
|
||||
<div>
|
||||
<dt>Tischnummer</dt>
|
||||
<dd>{reservation.tableNumber}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt>Tisch</dt>
|
||||
<dd>{reservation.tableName}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Datum</dt>
|
||||
<dd>{dateFormatter.format(new Date(reservation.reservationTime))}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Start</dt>
|
||||
<dd>{timeFormatter.format(new Date(reservation.reservationTime))}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Ende</dt>
|
||||
<dd>
|
||||
{timeFormatter.format(
|
||||
new Date(new Date(reservation.reservationTime).getTime() + reservation.durationMinutes * 60_000),
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Personen</dt>
|
||||
<dd>{reservation.partySize}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>E-Mail</dt>
|
||||
<dd>
|
||||
<a href={`mailto:${reservation.customerEmail}`}>{reservation.customerEmail}</a>
|
||||
</dd>
|
||||
</div>
|
||||
{reservation.customerPhone && (
|
||||
<div>
|
||||
<dt>Telefon</dt>
|
||||
<dd>
|
||||
<a href={`tel:${reservation.customerPhone}`}>{reservation.customerPhone}</a>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
{reservation.message && (
|
||||
<div className="detail-message">
|
||||
<span className="detail-message-label">Nachricht</span>
|
||||
<p>{reservation.message}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{statusError && <p className="message message-error">{statusError}</p>}
|
||||
|
||||
<div className="confirm-dialog-actions confirm-dialog-actions-split">
|
||||
<button type="button" className="btn btn-ghost" onClick={startEditing}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import type { listStaffMembers } from '#/lib/staff'
|
||||
|
||||
type StaffMember = Awaited<ReturnType<typeof listStaffMembers>>[number]
|
||||
|
||||
type Props = {
|
||||
member: StaffMember | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
export function StaffMemberDetailDialog({ member, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (!member) return
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [member, onClose])
|
||||
|
||||
if (!member) return null
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="confirm-dialog reservation-detail-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="staff-member-detail-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 id="staff-member-detail-title" className="confirm-dialog-title">
|
||||
{member.name}
|
||||
</h2>
|
||||
|
||||
<dl className="detail-list">
|
||||
<div>
|
||||
<dt>E-Mail</dt>
|
||||
<dd>
|
||||
<a href={`mailto:${member.email}`}>{member.email}</a>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Telefon</dt>
|
||||
<dd>{member.phone ? <a href={`tel:${member.phone}`}>{member.phone}</a> : '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Rolle</dt>
|
||||
<dd>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Status</dt>
|
||||
<dd>{member.banned ? 'Gesperrt' : 'Aktiv'}</dd>
|
||||
</div>
|
||||
{member.banned && member.banReason && (
|
||||
<div>
|
||||
<dt>Sperrgrund</dt>
|
||||
<dd>{member.banReason}</dd>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<dt>Angelegt am</dt>
|
||||
<dd>{dateFormatter.format(new Date(member.createdAt))}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="confirm-dialog-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -16,6 +16,16 @@ 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"
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { RESERVATION_STATUS_LABELS, type ReservationStatus } from '#/lib/reservations'
|
||||
|
||||
export function StatusBadge({ status }: { status: ReservationStatus }) {
|
||||
return <span className={`status-badge status-${status}`}>{RESERVATION_STATUS_LABELS[status]}</span>
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
title: string
|
||||
message: React.ReactNode
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
function CheckIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="none" aria-hidden="true">
|
||||
<circle cx="12" cy="12" r="9" stroke="currentColor" strokeWidth="1.6" />
|
||||
<path
|
||||
d="m8.2 12.3 2.6 2.6 5-5.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export function SuccessModal({ open, title, message, onClose }: Props) {
|
||||
const closeRef = useRef<HTMLButtonElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
|
||||
closeRef.current?.focus()
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [open, onClose])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="confirm-dialog success-modal"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="success-modal-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="success-modal-icon">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
<h2 id="success-modal-title" className="confirm-dialog-title">
|
||||
{title}
|
||||
</h2>
|
||||
<p className="confirm-dialog-message">{message}</p>
|
||||
<div className="confirm-dialog-actions">
|
||||
<button ref={closeRef} type="button" className="btn btn-primary" onClick={onClose}>
|
||||
OK
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import type { getTables } from '#/lib/reservations'
|
||||
|
||||
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
|
||||
|
||||
type Props = {
|
||||
table: TableRow | null
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function TableDetailDialog({ table, onClose }: Props) {
|
||||
useEffect(() => {
|
||||
if (!table) return
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') onClose()
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||
}, [table, onClose])
|
||||
|
||||
if (!table) return null
|
||||
|
||||
return (
|
||||
<div className="confirm-dialog-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="confirm-dialog reservation-detail-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="table-detail-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<h2 id="table-detail-title" className="confirm-dialog-title">
|
||||
{table.name}
|
||||
</h2>
|
||||
|
||||
{table.imageUrl && <img className="table-preview" src={table.imageUrl} alt="" />}
|
||||
|
||||
<dl className="detail-list">
|
||||
<div>
|
||||
<dt>Tischnummer</dt>
|
||||
<dd>{table.tableNumber ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
<dd>{table.name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Plätze</dt>
|
||||
<dd>{table.seats}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Marker</dt>
|
||||
<dd>{table.markerX !== null && table.markerY !== null ? 'Gesetzt' : 'Nicht gesetzt'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<div className="confirm-dialog-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+36
-1
@@ -1,10 +1,24 @@
|
||||
import { sql } from 'drizzle-orm'
|
||||
import { boolean, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
|
||||
import { boolean, doublePrecision, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
|
||||
|
||||
export const restaurantTables = pgTable('restaurant_tables', {
|
||||
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(
|
||||
@@ -16,7 +30,20 @@ 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
|
||||
@@ -38,6 +65,11 @@ 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
|
||||
@@ -54,6 +86,9 @@ 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', {
|
||||
|
||||
@@ -24,6 +24,19 @@ 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,
|
||||
},
|
||||
|
||||
@@ -42,6 +42,7 @@ function getResendClient() {
|
||||
type ReservationEmailInfo = {
|
||||
customerName: string
|
||||
customerEmail: string
|
||||
message?: string | null
|
||||
partySize: number
|
||||
reservationTime: Date
|
||||
cancelToken: string
|
||||
@@ -131,6 +132,19 @@ 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
|
||||
? `
|
||||
@@ -190,6 +204,7 @@ function renderReservationEmailText(
|
||||
`Tisch: ${reservation.table.name}`,
|
||||
`Datum & Uhrzeit: ${formattedTime}`,
|
||||
`Personen: ${reservation.partySize}`,
|
||||
...(reservation.message ? [`Nachricht: ${reservation.message}`] : []),
|
||||
'',
|
||||
...(showActions
|
||||
? [
|
||||
|
||||
@@ -57,12 +57,16 @@ export function isOnReservationInterval(date: Date, intervalMinutes: number = RE
|
||||
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
|
||||
}
|
||||
|
||||
export function isWithinOpeningHours(date: Date) {
|
||||
// `durationMinutes` requires the whole reservation — not just its start — to fit
|
||||
// before closing, so a slot too close to close time is rejected outright.
|
||||
export function isWithinOpeningHours(date: Date, durationMinutes = 0) {
|
||||
const { weekday, minutesSinceMidnight } = getBerlinTimeParts(date)
|
||||
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
|
||||
|
||||
return ranges.some(
|
||||
(range) => minutesSinceMidnight >= toMinutes(range.start) && minutesSinceMidnight < toMinutes(range.end),
|
||||
(range) =>
|
||||
minutesSinceMidnight >= toMinutes(range.start) &&
|
||||
minutesSinceMidnight + durationMinutes <= toMinutes(range.end),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -89,9 +93,15 @@ export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
|
||||
return OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
|
||||
}
|
||||
|
||||
// `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[] = []
|
||||
|
||||
@@ -99,7 +109,7 @@ export function generateTimeOptions(
|
||||
const startMinutes = toMinutes(range.start)
|
||||
const endMinutes = toMinutes(range.end)
|
||||
|
||||
for (let minutes = startMinutes; minutes < endMinutes; minutes += intervalMinutes) {
|
||||
for (let minutes = startMinutes; minutes + durationMinutes <= endMinutes; minutes += intervalMinutes) {
|
||||
const hours = Math.floor(minutes / 60)
|
||||
const remainder = minutes % 60
|
||||
options.push(`${String(hours).padStart(2, '0')}:${String(remainder).padStart(2, '0')}`)
|
||||
@@ -112,13 +122,14 @@ 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)
|
||||
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes)
|
||||
|
||||
for (const time of options) {
|
||||
const slot = new Date(`${dateStr}T${time}`)
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Shared between the customer reservation form (lib/reservations.ts) and the
|
||||
// staff phone field (routes/staff.team.tsx), so "a valid phone number" means
|
||||
// the same thing everywhere in the app.
|
||||
export const phoneSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(30, 'Enter a valid phone number')
|
||||
// Optional leading `+` plus digits and common separators (spaces, dashes,
|
||||
// dots, parentheses) — permissive about international formats, but the
|
||||
// character class alone would still accept e.g. "()---" or "+", so the
|
||||
// refine below requires it to actually contain a real number of digits.
|
||||
.regex(/^\+?[0-9()\-.\s]+$/, 'Enter a valid phone number')
|
||||
.refine((value) => (value.match(/\d/g)?.length ?? 0) >= 6, 'Enter a valid phone number')
|
||||
+200
-10
@@ -11,7 +11,10 @@ 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) {
|
||||
@@ -26,6 +29,7 @@ function assertValidReservationSlot(
|
||||
intervalMinutes: number,
|
||||
minLeadTimeMinutes: number,
|
||||
maxLeadTimeDays: number,
|
||||
durationMinutes: number,
|
||||
) {
|
||||
const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000
|
||||
|
||||
@@ -47,28 +51,33 @@ function assertValidReservationSlot(
|
||||
throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`)
|
||||
}
|
||||
|
||||
if (!isWithinOpeningHours(reservationTime)) {
|
||||
throw new Error('Zu dieser Zeit haben wir leider geschlossen')
|
||||
if (!isWithinOpeningHours(reservationTime, durationMinutes)) {
|
||||
throw new Error('Zu dieser Zeit reicht die verbleibende Zeit bis zum Schließen nicht mehr aus')
|
||||
}
|
||||
}
|
||||
|
||||
// A table is occupied for its own configured duration, not just the exact minute it
|
||||
// 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.
|
||||
// older booking keeps whatever duration was in effect when it was made. `bufferMinutes`
|
||||
// (the current minLeadTimeMinutes setting, reused here as a table turnover gap) is
|
||||
// added on both sides of the existing reservation, so back-to-back bookings need at
|
||||
// least that much breathing room between one guest leaving and the next arriving.
|
||||
async function findOverlappingReservation(
|
||||
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()
|
||||
const existingEnd = existingStart + r.durationMinutes * 60_000
|
||||
const existingStart = r.reservationTime.getTime() - bufferMs
|
||||
const existingEnd = existingStart + r.durationMinutes * 60_000 + 2 * bufferMs
|
||||
return existingStart < end && start.getTime() < existingEnd
|
||||
})
|
||||
}
|
||||
@@ -77,6 +86,120 @@ export const getTables = createServerFn({ method: 'GET' }).handler(async () => {
|
||||
return db.select().from(restaurantTables).orderBy(restaurantTables.name)
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
@@ -85,9 +208,13 @@ 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,
|
||||
})
|
||||
@@ -96,10 +223,30 @@ 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',
|
||||
@@ -107,7 +254,7 @@ const createReservationSchema = z.object({
|
||||
})
|
||||
|
||||
export const createReservation = createServerFn({ method: 'POST' })
|
||||
.validator(createReservationSchema)
|
||||
.validator(zodValidator(createReservationSchema))
|
||||
.handler(async ({ data }) => {
|
||||
const settings = await loadReservationSettings()
|
||||
const reservationTime = new Date(data.reservationTime)
|
||||
@@ -117,6 +264,7 @@ export const createReservation = createServerFn({ method: 'POST' })
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
settings.maxLeadTimeDays,
|
||||
settings.reservationDurationMinutes,
|
||||
)
|
||||
|
||||
const [table] = await db
|
||||
@@ -136,6 +284,7 @@ export const createReservation = createServerFn({ method: 'POST' })
|
||||
data.tableId,
|
||||
reservationTime,
|
||||
settings.reservationDurationMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
)
|
||||
|
||||
if (conflict) {
|
||||
@@ -148,6 +297,8 @@ 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,
|
||||
@@ -157,6 +308,7 @@ 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,
|
||||
@@ -171,7 +323,7 @@ const updateReservationSchema = createReservationSchema.extend({
|
||||
})
|
||||
|
||||
export const updateReservation = createServerFn({ method: 'POST' })
|
||||
.validator(updateReservationSchema)
|
||||
.validator(zodValidator(updateReservationSchema))
|
||||
.handler(async ({ data }) => {
|
||||
await requireStaffSession()
|
||||
|
||||
@@ -183,6 +335,7 @@ export const updateReservation = createServerFn({ method: 'POST' })
|
||||
settings.reservationIntervalMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
settings.maxLeadTimeDays,
|
||||
settings.reservationDurationMinutes,
|
||||
)
|
||||
|
||||
const [table] = await db
|
||||
@@ -202,6 +355,7 @@ export const updateReservation = createServerFn({ method: 'POST' })
|
||||
data.tableId,
|
||||
reservationTime,
|
||||
settings.reservationDurationMinutes,
|
||||
settings.minLeadTimeMinutes,
|
||||
data.id,
|
||||
)
|
||||
|
||||
@@ -215,6 +369,8 @@ 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,
|
||||
@@ -229,6 +385,7 @@ 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,
|
||||
@@ -238,12 +395,45 @@ 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(deleteReservationSchema)
|
||||
.validator(zodValidator(deleteReservationSchema))
|
||||
.handler(async ({ data }) => {
|
||||
await requireStaffSession()
|
||||
|
||||
@@ -277,7 +467,7 @@ const getReservationByCancelTokenSchema = z.object({
|
||||
})
|
||||
|
||||
export const getReservationByCancelToken = createServerFn({ method: 'GET' })
|
||||
.validator(getReservationByCancelTokenSchema)
|
||||
.validator(zodValidator(getReservationByCancelTokenSchema))
|
||||
.handler(async ({ data }) => {
|
||||
const [reservation] = await db
|
||||
.select({
|
||||
@@ -298,7 +488,7 @@ const cancelReservationByTokenSchema = z.object({
|
||||
})
|
||||
|
||||
export const cancelReservationByToken = createServerFn({ method: 'POST' })
|
||||
.validator(cancelReservationByTokenSchema)
|
||||
.validator(zodValidator(cancelReservationByTokenSchema))
|
||||
.handler(async ({ data }) => {
|
||||
const [deleted] = await db
|
||||
.delete(reservations)
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { z, ZodType } from 'zod'
|
||||
|
||||
// `createServerFn().validator(schema)` passes a zod schema straight through to
|
||||
// TanStack Start, which on failure throws `new Error(JSON.stringify(issues))` —
|
||||
// the raw issue array, not a message meant for a user to read. Wrapping the
|
||||
// schema in a plain function (`.validator(zodValidator(schema))`) routes
|
||||
// validation through this instead, so a rejected field surfaces its own zod
|
||||
// `.message` (e.g. "Enter a valid phone number") rather than a JSON blob.
|
||||
export function zodValidator<Schema extends ZodType>(schema: Schema) {
|
||||
return (input: unknown): z.infer<Schema> => {
|
||||
const result = schema.safeParse(input)
|
||||
if (!result.success) {
|
||||
throw new Error(result.error.issues[0]?.message ?? 'Invalid input')
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -5,6 +5,8 @@ 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
|
||||
@@ -31,10 +33,12 @@ 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(updateReservationSettingsSchema)
|
||||
.validator(zodValidator(updateReservationSettingsSchema))
|
||||
.handler(async ({ data }) => {
|
||||
console.log('[settings] updateReservationSettings called with', data)
|
||||
|
||||
|
||||
+5
-1
@@ -11,5 +11,9 @@ export const listStaffMembers = createServerFn({ method: 'GET' }).handler(async
|
||||
query: { sortBy: 'createdAt', sortDirection: 'asc', limit: 100 },
|
||||
})
|
||||
|
||||
return users
|
||||
// `listUsers`'s return type doesn't pick up `user.additionalFields` from the
|
||||
// auth config the way the actual response body does — `phone` is present at
|
||||
// runtime (Better Auth includes every configured additional field on every
|
||||
// user-returning endpoint) but missing from admin.d.ts's `UserWithRole` type.
|
||||
return users as Array<(typeof users)[number] & { phone: string | null }>
|
||||
})
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
// Generous enough for a photo, small enough to keep rows (and the customer-facing
|
||||
// page that loads them up front) from bloating — these are data: URLs stored
|
||||
// inline, not uploads to disk-backed storage, which this app doesn't have.
|
||||
export const MAX_TABLE_IMAGE_BYTES = 2_000_000
|
||||
|
||||
export const ACCEPTED_IMAGE_TYPES = ['image/png', 'image/jpeg', 'image/webp']
|
||||
|
||||
export const dataUrlImageSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.max(Math.ceil((MAX_TABLE_IMAGE_BYTES * 4) / 3) + 100)
|
||||
.regex(/^data:image\/(png|jpeg|jpg|webp);base64,/, 'Bild muss als PNG, JPEG oder WebP hochgeladen werden')
|
||||
.nullable()
|
||||
|
||||
export function readImageAsDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => resolve(reader.result as string)
|
||||
reader.onerror = () => reject(reader.error ?? new Error('Datei konnte nicht gelesen werden'))
|
||||
reader.readAsDataURL(file)
|
||||
})
|
||||
}
|
||||
@@ -14,6 +14,7 @@ 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'
|
||||
|
||||
@@ -42,6 +43,11 @@ 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',
|
||||
@@ -58,6 +64,7 @@ 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
|
||||
@@ -67,6 +74,7 @@ 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
|
||||
@@ -77,6 +85,7 @@ 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
|
||||
@@ -88,6 +97,7 @@ export interface FileRouteTypes {
|
||||
| '/impressum'
|
||||
| '/staff/login'
|
||||
| '/staff/settings'
|
||||
| '/staff/tables'
|
||||
| '/staff/team'
|
||||
| '/stornieren/$token'
|
||||
| '/staff/'
|
||||
@@ -97,6 +107,7 @@ export interface FileRouteTypes {
|
||||
| '/impressum'
|
||||
| '/staff/login'
|
||||
| '/staff/settings'
|
||||
| '/staff/tables'
|
||||
| '/staff/team'
|
||||
| '/stornieren/$token'
|
||||
| '/staff'
|
||||
@@ -106,6 +117,7 @@ export interface FileRouteTypes {
|
||||
| '/impressum'
|
||||
| '/staff/login'
|
||||
| '/staff/settings'
|
||||
| '/staff/tables'
|
||||
| '/staff/team'
|
||||
| '/stornieren/$token'
|
||||
| '/staff/'
|
||||
@@ -116,6 +128,7 @@ export interface RootRouteChildren {
|
||||
ImpressumRoute: typeof ImpressumRoute
|
||||
StaffLoginRoute: typeof StaffLoginRoute
|
||||
StaffSettingsRoute: typeof StaffSettingsRoute
|
||||
StaffTablesRoute: typeof StaffTablesRoute
|
||||
StaffTeamRoute: typeof StaffTeamRoute
|
||||
StornierenTokenRoute: typeof StornierenTokenRoute
|
||||
StaffIndexRoute: typeof StaffIndexRoute
|
||||
@@ -158,6 +171,13 @@ 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'
|
||||
@@ -180,6 +200,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ImpressumRoute: ImpressumRoute,
|
||||
StaffLoginRoute: StaffLoginRoute,
|
||||
StaffSettingsRoute: StaffSettingsRoute,
|
||||
StaffTablesRoute: StaffTablesRoute,
|
||||
StaffTeamRoute: StaffTeamRoute,
|
||||
StornierenTokenRoute: StornierenTokenRoute,
|
||||
StaffIndexRoute: StaffIndexRoute,
|
||||
|
||||
@@ -26,6 +26,11 @@ export const Route = createRootRoute({
|
||||
rel: 'stylesheet',
|
||||
href: appCss,
|
||||
},
|
||||
{
|
||||
rel: 'icon',
|
||||
href: '/favicon.svg',
|
||||
type: 'image/svg+xml',
|
||||
},
|
||||
],
|
||||
}),
|
||||
shellComponent: RootDocument,
|
||||
|
||||
@@ -57,7 +57,7 @@ function Impressum() {
|
||||
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
|
||||
</p>
|
||||
|
||||
<h2>Datenschutz</h2>
|
||||
<h2 id="datenschutz">Datenschutz</h2>
|
||||
<p>
|
||||
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
|
||||
oben genannte Drenica & Mousa GbR.
|
||||
|
||||
+211
-37
@@ -1,61 +1,120 @@
|
||||
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 { getNextAvailableSlot, getOpeningHoursForDisplay, getTodayLabelBerlin } from '#/lib/opening-hours'
|
||||
import { createReservation, getTables } from '#/lib/reservations'
|
||||
import { formatDateOnly } from '#/lib/datetime'
|
||||
import {
|
||||
generateTimeOptions,
|
||||
getNextAvailableSlot,
|
||||
getOpeningHoursForDisplay,
|
||||
getOpeningRangesForWeekday,
|
||||
getTodayLabelBerlin,
|
||||
} from '#/lib/opening-hours'
|
||||
import { createReservation, getReservationAvailability, getTables } from '#/lib/reservations'
|
||||
import { getReservationSettings } from '#/lib/settings'
|
||||
|
||||
export const Route = createFileRoute('/')({
|
||||
component: Home,
|
||||
loader: async () => {
|
||||
const [tables, settings] = await Promise.all([getTables(), getReservationSettings()])
|
||||
return { tables, settings }
|
||||
const [tables, settings, availability] = await Promise.all([
|
||||
getTables(),
|
||||
getReservationSettings(),
|
||||
getReservationAvailability(),
|
||||
])
|
||||
return { tables, settings, availability }
|
||||
},
|
||||
})
|
||||
|
||||
const OPENING_HOURS = getOpeningHoursForDisplay()
|
||||
|
||||
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>
|
||||
)
|
||||
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 Home() {
|
||||
const router = useRouter()
|
||||
const { tables, settings } = Route.useLoaderData()
|
||||
const { tables, settings, availability } = 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 [success, setSuccess] = useState<string | null>(null)
|
||||
const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | 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')
|
||||
@@ -67,22 +126,42 @@ function Home() {
|
||||
return
|
||||
}
|
||||
|
||||
if (!acceptsTerms) {
|
||||
setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu')
|
||||
return
|
||||
}
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await createReservation({
|
||||
const created = await createReservation({
|
||||
data: {
|
||||
tableId: Number(tableId),
|
||||
customerName,
|
||||
customerEmail,
|
||||
customerPhone,
|
||||
message,
|
||||
partySize: Number(partySize),
|
||||
reservationTime: new Date(reservationTime).toISOString(),
|
||||
},
|
||||
})
|
||||
|
||||
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
|
||||
const start = new Date(created.reservationTime)
|
||||
const end = new Date(start.getTime() + created.durationMinutes * 60_000)
|
||||
setConfirmedReservation({
|
||||
customerName: created.customerName,
|
||||
customerEmail: created.customerEmail,
|
||||
tableName: selectedTable?.name ?? '',
|
||||
date: confirmedDateFormatter.format(start),
|
||||
startTime: confirmedTimeFormatter.format(start),
|
||||
endTime: confirmedTimeFormatter.format(end),
|
||||
partySize: created.partySize,
|
||||
})
|
||||
setCustomerName('')
|
||||
setCustomerEmail('')
|
||||
setCustomerPhone('')
|
||||
setMessage('')
|
||||
setPartySize(2)
|
||||
setAcceptsTerms(false)
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
@@ -96,13 +175,13 @@ function Home() {
|
||||
<SiteHeader />
|
||||
|
||||
<section className="hero" id="top">
|
||||
<FlameIcon />
|
||||
<img className="hero-flame" src="/logo.svg" alt="" aria-hidden="true" />
|
||||
<h1>
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
|
||||
</h1>
|
||||
<p>
|
||||
Steaks vom Grill, gute Gesellschaft und ein gemütlicher Abend mitten in Meppen. Reservieren
|
||||
Sie Ihren Tisch – wir freuen uns auf Sie.
|
||||
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.
|
||||
</p>
|
||||
<div className="hero-actions">
|
||||
<a className="btn btn-primary" href="#reservieren">
|
||||
@@ -136,6 +215,16 @@ 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>
|
||||
@@ -158,6 +247,36 @@ 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
|
||||
@@ -181,6 +300,19 @@ 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
|
||||
@@ -193,30 +325,58 @@ function Home() {
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Datum & Uhrzeit
|
||||
<ReservationDateTimeField
|
||||
value={reservationTime}
|
||||
onChange={setReservationTime}
|
||||
intervalMinutes={settings.reservationIntervalMinutes}
|
||||
minLeadTimeMinutes={settings.minLeadTimeMinutes}
|
||||
maxLeadTimeDays={settings.maxLeadTimeDays}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Datum & Uhrzeit
|
||||
<ReservationDateTimeField
|
||||
value={reservationTime}
|
||||
onChange={setReservationTime}
|
||||
bookedTimes={bookedTimesForForm}
|
||||
intervalMinutes={settings.reservationIntervalMinutes}
|
||||
minLeadTimeMinutes={settings.minLeadTimeMinutes}
|
||||
durationMinutes={settings.reservationDurationMinutes}
|
||||
maxLeadTimeDays={settings.maxLeadTimeDays}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Nachricht (optional)
|
||||
<textarea
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
placeholder="Allergien, ein besonderer Anlass, Sitzwünsche …"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<p className="field-hint">
|
||||
Verfügbare Uhrzeiten richten sich nach unseren{' '}
|
||||
<a href="#oeffnungszeiten">Öffnungszeiten</a>.
|
||||
</p>
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
<label className="radio-option consent-option">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acceptsTerms}
|
||||
onChange={(e) => setAcceptsTerms(e.target.checked)}
|
||||
required
|
||||
/>
|
||||
<span>
|
||||
Ich stimme der Verarbeitung meiner Daten gemäß der{' '}
|
||||
<a href="/impressum#datenschutz" target="_blank" rel="noopener noreferrer">
|
||||
Datenschutzerklärung
|
||||
</a>{' '}
|
||||
zu.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={isSubmitting || !isFormValid}>
|
||||
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
|
||||
</button>
|
||||
|
||||
{error && <p className="message message-error">{error}</p>}
|
||||
{success && <p className="message message-success">{success}</p>}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
@@ -250,6 +410,20 @@ 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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
+162
-240
@@ -3,18 +3,21 @@ import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-rout
|
||||
|
||||
import { ConfirmDialog } from '#/components/confirm-dialog'
|
||||
import { ReservationCharts } from '#/components/reservation-charts'
|
||||
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
|
||||
import { ReservationDialog, type ReservationPayload } from '#/components/reservation-dialog'
|
||||
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'
|
||||
|
||||
@@ -37,8 +40,11 @@ export const Route = createFileRoute('/staff/')({
|
||||
},
|
||||
})
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
})
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||
timeStyle: 'short',
|
||||
})
|
||||
|
||||
@@ -50,45 +56,22 @@ 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 [success, setSuccess] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [successModal, setSuccessModal] = useState<{ title: string; message: string } | 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'>('all')
|
||||
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('today')
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all')
|
||||
|
||||
const dialogOpen = viewingReservation !== null || isCreating
|
||||
|
||||
const now = new Date()
|
||||
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
|
||||
@@ -97,6 +80,7 @@ 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
|
||||
@@ -105,99 +89,34 @@ function StaffDashboard() {
|
||||
|
||||
const query = searchQuery.trim().toLowerCase()
|
||||
if (query) {
|
||||
const haystack = `${r.customerName} ${r.customerEmail} ${r.tableName}`.toLowerCase()
|
||||
const haystack = `${r.customerName} ${r.customerEmail} ${r.customerPhone ?? ''} ${r.tableName} ${r.tableNumber ?? ''}`.toLowerCase()
|
||||
if (!haystack.includes(query)) return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// Other reservations already sitting on the currently selected table + date, so the
|
||||
// time picker can block them instead of letting staff pick a slot the server will
|
||||
// just reject as double-booked. A reservation occupies its whole configured
|
||||
// duration, not just its exact start time, so every interval-grid slot that falls
|
||||
// inside that window counts as taken too.
|
||||
const [selectedDate] = form.reservationTime.split('T')
|
||||
const otherReservationsOnSelection = reservations.filter(
|
||||
(r) =>
|
||||
r.id !== editingId &&
|
||||
String(r.tableId) === String(form.tableId) &&
|
||||
formatDateOnly(new Date(r.reservationTime)) === selectedDate,
|
||||
)
|
||||
const bookedTimesForForm = selectedDate
|
||||
? generateTimeOptions(
|
||||
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
|
||||
settings.reservationIntervalMinutes,
|
||||
).filter((option) => {
|
||||
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
|
||||
return otherReservationsOnSelection.some((r) => {
|
||||
const otherStart = new Date(r.reservationTime).getTime()
|
||||
const otherEnd = otherStart + r.durationMinutes * 60_000
|
||||
return slotStart >= otherStart && slotStart < otherEnd
|
||||
})
|
||||
})
|
||||
: []
|
||||
function closeDialog() {
|
||||
setViewingReservation(null)
|
||||
setIsCreating(false)
|
||||
}
|
||||
|
||||
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)),
|
||||
async function handleCreate(payload: ReservationPayload) {
|
||||
await createReservation({ data: payload })
|
||||
setSuccessModal({
|
||||
title: 'Reservierung angelegt',
|
||||
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich angelegt.`,
|
||||
})
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
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 })
|
||||
setSuccess('Reservierung wurde angelegt.')
|
||||
}
|
||||
|
||||
setEditingId(null)
|
||||
setForm(emptyForm(tables[0]?.id ?? '', settings))
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
async function handleUpdate(id: number, payload: ReservationPayload) {
|
||||
await updateReservation({ data: { id, ...payload } })
|
||||
setSuccessModal({
|
||||
title: 'Reservierung aktualisiert',
|
||||
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich aktualisiert.`,
|
||||
})
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
@@ -205,21 +124,32 @@ function StaffDashboard() {
|
||||
if (!reservation) return
|
||||
|
||||
setPendingDelete(null)
|
||||
setDeletingId(reservation.id)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
await deleteReservation({ data: { id: reservation.id } })
|
||||
if (editingId === reservation.id) {
|
||||
cancelEdit()
|
||||
}
|
||||
setSuccess('Reservierung wurde gelöscht.')
|
||||
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')
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) {
|
||||
setIsUpdatingStatus(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await updateReservationStatus({ data: { id: reservation.id, status } })
|
||||
setViewingReservation((current) => (current ? { ...current, status } : current))
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
setIsUpdatingStatus(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +182,12 @@ 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>
|
||||
@@ -273,96 +209,14 @@ function StaffDashboard() {
|
||||
|
||||
<ReservationCharts reservations={reservations} tables={tables} />
|
||||
|
||||
<section className="staff-form-section">
|
||||
<h2 className="section-heading staff-subheading">
|
||||
{editingId ? 'Reservierung bearbeiten' : 'Neue Reservierung'}
|
||||
</h2>
|
||||
|
||||
<form className="reservation-form" onSubmit={handleSubmit}>
|
||||
<label>
|
||||
Tisch
|
||||
<select
|
||||
value={form.tableId}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tableId: e.target.value }))}
|
||||
required
|
||||
>
|
||||
<option value="" disabled>
|
||||
Tisch auswählen
|
||||
</option>
|
||||
{tables.map((table) => (
|
||||
<option key={table.id} value={table.id}>
|
||||
{table.name} ({table.seats} Plätze)
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={form.customerName}
|
||||
onChange={(e) => setForm((f) => ({ ...f, customerName: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
E-Mail
|
||||
<input
|
||||
type="email"
|
||||
value={form.customerEmail}
|
||||
onChange={(e) => setForm((f) => ({ ...f, customerEmail: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Anzahl Personen
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={20}
|
||||
value={form.partySize}
|
||||
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Datum & 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">
|
||||
<div className="table-toolbar">
|
||||
<h2 className="section-heading staff-subheading">Alle Reservierungen</h2>
|
||||
<button type="button" className="btn btn-primary" onClick={() => setIsCreating(true)}>
|
||||
+ Neue Reservierung
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{reservations.length > 0 && (
|
||||
<div className="table-filters">
|
||||
<input
|
||||
@@ -377,7 +231,7 @@ function StaffDashboard() {
|
||||
<option value="all">Alle Tische</option>
|
||||
{tables.map((table) => (
|
||||
<option key={table.id} value={String(table.id)}>
|
||||
{table.name}
|
||||
{table.tableNumber === null ? table.name : `${table.tableNumber} — ${table.name}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -391,6 +245,18 @@ 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>
|
||||
)}
|
||||
|
||||
@@ -402,41 +268,64 @@ function StaffDashboard() {
|
||||
</p>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table className="reservations-table">
|
||||
<table className="reservations-table reservations-table-clickable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Datum & Uhrzeit</th>
|
||||
{timeFilter !== 'today' && <th>Datum</th>}
|
||||
<th>Start</th>
|
||||
<th>Ende</th>
|
||||
<th>Nr.</th>
|
||||
<th>Tisch</th>
|
||||
<th>Kunde</th>
|
||||
<th>Personen</th>
|
||||
<th></th>
|
||||
<th>Status</th>
|
||||
<th>Nachricht</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredReservations.map((reservation) => (
|
||||
<tr key={reservation.id} className={editingId === reservation.id ? 'is-editing' : undefined}>
|
||||
<td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td>
|
||||
<td>{reservation.tableName}</td>
|
||||
<td>
|
||||
<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">
|
||||
{reservation.customerName}
|
||||
<br />
|
||||
<span className="muted">{reservation.customerEmail}</span>
|
||||
{reservation.customerPhone && (
|
||||
<>
|
||||
<br />
|
||||
<span className="muted">{reservation.customerPhone}</span>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
<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 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>
|
||||
</tr>
|
||||
))}
|
||||
@@ -451,15 +340,48 @@ function StaffDashboard() {
|
||||
open={pendingDelete !== null}
|
||||
title="Reservierung löschen"
|
||||
message={
|
||||
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.`
|
||||
: ''
|
||||
pendingDelete && (
|
||||
<>
|
||||
Reservierung von <strong>{pendingDelete.customerName}</strong> wirklich löschen? Der Gast
|
||||
erhält eine E-Mail zur Bestätigung der Stornierung.
|
||||
</>
|
||||
)
|
||||
}
|
||||
warning="Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
confirmLabel="Löschen"
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,35 @@ function ArrowLeftIcon() {
|
||||
)
|
||||
}
|
||||
|
||||
function EyeIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function EyeOffIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||
<path
|
||||
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path d="M4 4l16 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
export const Route = createFileRoute('/staff/login')({
|
||||
component: StaffLogin,
|
||||
beforeLoad: async () => {
|
||||
@@ -33,6 +62,7 @@ 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)
|
||||
|
||||
@@ -78,12 +108,23 @@ function StaffLogin() {
|
||||
|
||||
<label>
|
||||
Passwort
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="password-field">
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="password-toggle"
|
||||
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||
aria-pressed={showPassword}
|
||||
onClick={() => setShowPassword((show) => !show)}
|
||||
>
|
||||
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||
</button>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState } from 'react'
|
||||
import { useRef, 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,
|
||||
@@ -19,8 +21,8 @@ export const Route = createFileRoute('/staff/settings')({
|
||||
return { staffUser }
|
||||
},
|
||||
loader: async ({ context }) => {
|
||||
const settings = await getReservationSettings()
|
||||
return { staffUser: context.staffUser, settings }
|
||||
const [settings, tables] = await Promise.all([getReservationSettings(), getTables()])
|
||||
return { staffUser: context.staffUser, settings, tables }
|
||||
},
|
||||
})
|
||||
|
||||
@@ -29,21 +31,35 @@ 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 } = Route.useLoaderData()
|
||||
const { staffUser, settings, tables } = 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()
|
||||
@@ -51,6 +67,67 @@ 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)
|
||||
@@ -161,6 +238,62 @@ 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>
|
||||
@@ -169,6 +302,71 @@ 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>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import { useRef, useState } from 'react'
|
||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||
|
||||
import { ConfirmDialog } from '#/components/confirm-dialog'
|
||||
import { StaffTabs } from '#/components/staff-tabs'
|
||||
import { TableDetailDialog } from '#/components/table-detail-dialog'
|
||||
import { getCurrentStaffUser } from '#/lib/auth'
|
||||
import { authClient } from '#/lib/auth-client'
|
||||
import { createTable, deleteTable, getTables, updateTable } from '#/lib/reservations'
|
||||
import { ACCEPTED_IMAGE_TYPES, MAX_TABLE_IMAGE_BYTES, readImageAsDataUrl } from '#/lib/table-image'
|
||||
|
||||
export const Route = createFileRoute('/staff/tables')({
|
||||
component: StaffTables,
|
||||
beforeLoad: async () => {
|
||||
const staffUser = await getCurrentStaffUser()
|
||||
if (!staffUser) {
|
||||
throw redirect({ to: '/staff/login' })
|
||||
}
|
||||
if (staffUser.role !== 'admin') {
|
||||
throw redirect({ to: '/staff' })
|
||||
}
|
||||
return { staffUser }
|
||||
},
|
||||
loader: async ({ context }) => {
|
||||
const tables = await getTables()
|
||||
return { staffUser: context.staffUser, tables }
|
||||
},
|
||||
})
|
||||
|
||||
type TableRow = Awaited<ReturnType<typeof getTables>>[number]
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
tableNumber: number
|
||||
seats: number
|
||||
imageUrl: string | null
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return { name: '', tableNumber: 1, seats: 2, imageUrl: null }
|
||||
}
|
||||
|
||||
const MAX_IMAGE_MB = Math.floor(MAX_TABLE_IMAGE_BYTES / 1_000_000)
|
||||
|
||||
function StaffTables() {
|
||||
const router = useRouter()
|
||||
const { staffUser, tables } = Route.useLoaderData()
|
||||
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [form, setForm] = useState<FormState>(emptyForm)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
||||
const [pendingDelete, setPendingDelete] = useState<TableRow | null>(null)
|
||||
const [viewingTable, setViewingTable] = useState<TableRow | null>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
function startEdit(table: TableRow) {
|
||||
setEditingId(table.id)
|
||||
setForm({
|
||||
name: table.name,
|
||||
tableNumber: table.tableNumber ?? 0,
|
||||
seats: table.seats,
|
||||
imageUrl: table.imageUrl,
|
||||
})
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null)
|
||||
setForm(emptyForm())
|
||||
setError(null)
|
||||
if (fileInputRef.current) fileInputRef.current.value = ''
|
||||
}
|
||||
|
||||
async function handleImageChange(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0]
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
|
||||
if (!ACCEPTED_IMAGE_TYPES.includes(file.type)) {
|
||||
setError('Bild muss PNG, JPEG oder WebP sein')
|
||||
return
|
||||
}
|
||||
|
||||
if (file.size > MAX_TABLE_IMAGE_BYTES) {
|
||||
setError(`Bild ist zu groß (max. ${MAX_IMAGE_MB} MB)`)
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
const dataUrl = await readImageAsDataUrl(file)
|
||||
setForm((f) => ({ ...f, imageUrl: dataUrl }))
|
||||
}
|
||||
|
||||
async function handleSubmit(event: React.FormEvent) {
|
||||
event.preventDefault()
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
setIsSubmitting(true)
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
tableNumber: Number(form.tableNumber),
|
||||
seats: Number(form.seats),
|
||||
imageUrl: form.imageUrl,
|
||||
}
|
||||
|
||||
if (editingId) {
|
||||
await updateTable({ data: { id: editingId, ...payload } })
|
||||
setSuccess('Tisch wurde aktualisiert.')
|
||||
} else {
|
||||
await createTable({ data: payload })
|
||||
setSuccess('Tisch wurde angelegt.')
|
||||
}
|
||||
|
||||
cancelEdit()
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
const table = pendingDelete
|
||||
if (!table) return
|
||||
|
||||
setPendingDelete(null)
|
||||
setDeletingId(table.id)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
|
||||
try {
|
||||
await deleteTable({ data: { id: table.id } })
|
||||
if (editingId === table.id) cancelEdit()
|
||||
setSuccess('Tisch wurde gelöscht.')
|
||||
await router.invalidate()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||
} finally {
|
||||
setDeletingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authClient.signOut()
|
||||
await router.navigate({ to: '/staff/login' })
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="staff-page">
|
||||
<div className="staff-nav">
|
||||
<header className="staff-topbar">
|
||||
<Link className="wordmark" to="/">
|
||||
Steakhaus <span>bei Mirko</span>
|
||||
</Link>
|
||||
<div className="staff-topbar-right">
|
||||
<span>Angemeldet als {staffUser.name}</span>
|
||||
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="staff-tabs-bar">
|
||||
<StaffTabs role={staffUser.role} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main className="page staff-main">
|
||||
<h1 className="section-heading">Tische</h1>
|
||||
|
||||
<section className="staff-form-section">
|
||||
<h2 className="section-heading staff-subheading">
|
||||
{editingId ? 'Tisch bearbeiten' : 'Neuer Tisch'}
|
||||
</h2>
|
||||
|
||||
<form className="reservation-form" onSubmit={handleSubmit}>
|
||||
<div className="form-row">
|
||||
<label>
|
||||
Tischnummer
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
value={form.tableNumber}
|
||||
onChange={(e) => setForm((f) => ({ ...f, tableNumber: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Plätze
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={1}
|
||||
max={50}
|
||||
value={form.seats}
|
||||
onChange={(e) => setForm((f) => ({ ...f, seats: Number(e.target.value) }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
type="text"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<p className="field-hint">
|
||||
Die Tischnummer wird auf dem Marker angezeigt; der Name ist nur für die Verwaltung hier.
|
||||
</p>
|
||||
|
||||
<label>
|
||||
Foto
|
||||
<div className="table-image-field">
|
||||
{form.imageUrl && <img className="table-image-preview" src={form.imageUrl} alt="" />}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
{form.imageUrl && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setForm((f) => ({ ...f, imageUrl: null }))}
|
||||
>
|
||||
Bild entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
<p className="field-hint">
|
||||
Zeigt Gästen im Buchungsformular, wie der Tisch aussieht. PNG, JPEG oder WebP, max.{' '}
|
||||
{MAX_IMAGE_MB} MB.
|
||||
</p>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="submit" disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Wird gespeichert …' : editingId ? 'Speichern' : 'Anlegen'}
|
||||
</button>
|
||||
{editingId && (
|
||||
<button type="button" className="btn btn-secondary" onClick={cancelEdit}>
|
||||
Abbrechen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <p className="message message-error">{error}</p>}
|
||||
{success && <p className="message message-success">{success}</p>}
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="staff-table-section">
|
||||
{tables.length === 0 ? (
|
||||
<p className="empty-state">Noch keine Tische vorhanden.</p>
|
||||
) : (
|
||||
<div className="table-scroll">
|
||||
<table className="reservations-table reservations-table-clickable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th></th>
|
||||
<th>Nr.</th>
|
||||
<th>Name</th>
|
||||
<th>Plätze</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tables.map((table) => (
|
||||
<tr
|
||||
key={table.id}
|
||||
className={editingId === table.id ? 'is-editing' : undefined}
|
||||
onClick={() => setViewingTable(table)}
|
||||
>
|
||||
<td>
|
||||
{table.imageUrl ? (
|
||||
<img className="table-thumb" src={table.imageUrl} alt="" />
|
||||
) : (
|
||||
<span className="table-thumb table-thumb-empty" aria-hidden="true" />
|
||||
)}
|
||||
</td>
|
||||
<td>{table.tableNumber ?? '—'}</td>
|
||||
<td>{table.name}</td>
|
||||
<td>{table.seats}</td>
|
||||
<td onClick={(event) => event.stopPropagation()}>
|
||||
<div className="table-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => startEdit(table)}>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={deletingId === table.id}
|
||||
onClick={() => setPendingDelete(table)}
|
||||
>
|
||||
{deletingId === table.id ? 'Löschen …' : 'Löschen'}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
title="Tisch löschen"
|
||||
message={pendingDelete && <>Tisch <strong>{pendingDelete.name}</strong> wirklich löschen?</>}
|
||||
warning="Diese Aktion kann nicht rückgängig gemacht werden."
|
||||
confirmLabel="Löschen"
|
||||
danger
|
||||
onConfirm={confirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
|
||||
<TableDetailDialog table={viewingTable} onClose={() => setViewingTable(null)} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
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')({
|
||||
@@ -29,12 +32,13 @@ 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: '', password: '', role: 'user' }
|
||||
return { name: '', email: '', phone: '', password: '', role: 'user' }
|
||||
}
|
||||
|
||||
function StaffTeam() {
|
||||
@@ -46,11 +50,20 @@ 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({
|
||||
@@ -58,6 +71,7 @@ function StaffTeam() {
|
||||
email: form.email,
|
||||
password: form.password,
|
||||
role: form.role,
|
||||
data: { phone: phoneResult.data },
|
||||
})
|
||||
|
||||
if (createError) {
|
||||
@@ -72,11 +86,11 @@ function StaffTeam() {
|
||||
await router.invalidate()
|
||||
}
|
||||
|
||||
async function handleRemove(member: StaffMember) {
|
||||
if (!window.confirm(`Zugang für ${member.name} wirklich entfernen?`)) {
|
||||
return
|
||||
}
|
||||
async function confirmRemove() {
|
||||
const member = pendingRemove
|
||||
if (!member) return
|
||||
|
||||
setPendingRemove(null)
|
||||
setBusyId(member.id)
|
||||
setError(null)
|
||||
setSuccess(null)
|
||||
@@ -136,6 +150,38 @@ 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):`)
|
||||
|
||||
@@ -210,6 +256,19 @@ 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
|
||||
@@ -245,11 +304,12 @@ function StaffTeam() {
|
||||
|
||||
<section className="staff-table-section">
|
||||
<div className="table-scroll">
|
||||
<table className="reservations-table">
|
||||
<table className="reservations-table reservations-table-clickable">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>E-Mail</th>
|
||||
<th>Telefon</th>
|
||||
<th>Rolle</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
@@ -257,12 +317,13 @@ function StaffTeam() {
|
||||
</thead>
|
||||
<tbody>
|
||||
{members.map((member) => (
|
||||
<tr key={member.id}>
|
||||
<tr key={member.id} onClick={() => setViewingMember(member)}>
|
||||
<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>
|
||||
<td onClick={(event) => event.stopPropagation()}>
|
||||
<div className="table-actions">
|
||||
<button
|
||||
type="button"
|
||||
@@ -272,6 +333,14 @@ 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"
|
||||
@@ -292,7 +361,7 @@ function StaffTeam() {
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={busyId === member.id || member.id === staffUser.id}
|
||||
onClick={() => handleRemove(member)}
|
||||
onClick={() => setPendingRemove(member)}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
@@ -305,6 +374,19 @@ 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>
|
||||
)
|
||||
}
|
||||
|
||||
+832
-21
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,9 @@ 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
|
||||
|
||||
Reference in New Issue
Block a user