Compare commits
1
Commits
master
..
ecbc42846b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
@@ -3,3 +3,5 @@ BETTER_AUTH_SECRET=replace-with-a-long-random-string
|
|||||||
BETTER_AUTH_URL=http://localhost:3000
|
BETTER_AUTH_URL=http://localhost:3000
|
||||||
STAFF_SEED_EMAIL=admin@steakhaus-meppen.de
|
STAFF_SEED_EMAIL=admin@steakhaus-meppen.de
|
||||||
STAFF_SEED_PASSWORD=replace-with-a-strong-password
|
STAFF_SEED_PASSWORD=replace-with-a-strong-password
|
||||||
|
RESEND_API_KEY=replace-with-your-resend-api-key
|
||||||
|
RESERVATIONS_FROM_EMAIL=Steakhaus bei Mirko <reservierungen@steakhaus-meppen.de>
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- proof
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: git.lukas-harat.com
|
||||||
|
IMAGE_NAME: ${{ github.repository }}
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Typecheck
|
||||||
|
run: npx tsc --noEmit
|
||||||
|
|
||||||
|
build:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Log in to registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ env.REGISTRY }}
|
||||||
|
username: ${{ secrets.USERNAME }}
|
||||||
|
password: ${{ secrets.TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
tags: |
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||||
|
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||||
|
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy to TrueNAS
|
||||||
|
env:
|
||||||
|
DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
|
||||||
|
DEPLOY_PORT: ${{ secrets.DEPLOY_PORT }}
|
||||||
|
DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
|
||||||
|
DEPLOY_PATH: ${{ secrets.DEPLOY_PATH }}
|
||||||
|
DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||||
|
|
||||||
|
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
|
||||||
|
BETTER_AUTH_SECRET: ${{ secrets.BETTER_AUTH_SECRET }}
|
||||||
|
BETTER_AUTH_URL: ${{ secrets.BETTER_AUTH_URL }}
|
||||||
|
RESEND_API_KEY: ${{ secrets.RESEND_API_KEY }}
|
||||||
|
STAFF_SEED_EMAIL: ${{ secrets.STAFF_SEED_EMAIL }}
|
||||||
|
STAFF_SEED_PASSWORD: ${{ secrets.STAFF_SEED_PASSWORD }}
|
||||||
|
RESERVATIONS_FROM_EMAIL: ${{ secrets.RESERVATIONS_FROM_EMAIL }}
|
||||||
|
|
||||||
|
IMAGE_TAG: ${{ github.sha }}
|
||||||
|
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
|
||||||
|
printf '%s\n' "$DEPLOY_SSH_KEY" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
|
||||||
|
ssh-keyscan \
|
||||||
|
-p "${DEPLOY_PORT:-22}" \
|
||||||
|
-H "$DEPLOY_HOST" \
|
||||||
|
>> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
echo "Creating deployment directory if needed..."
|
||||||
|
|
||||||
|
ssh \
|
||||||
|
-i ~/.ssh/deploy_key \
|
||||||
|
-p "${DEPLOY_PORT:-22}" \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST" \
|
||||||
|
"mkdir -p '$DEPLOY_PATH'"
|
||||||
|
|
||||||
|
echo "Uploading compose file..."
|
||||||
|
|
||||||
|
scp \
|
||||||
|
-i ~/.ssh/deploy_key \
|
||||||
|
-P "${DEPLOY_PORT:-22}" \
|
||||||
|
docker-compose.prod.yml \
|
||||||
|
"$DEPLOY_USER@$DEPLOY_HOST:$DEPLOY_PATH/docker-compose.yml"
|
||||||
|
|
||||||
|
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.\"
|
||||||
|
'"
|
||||||
Generated
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||||
|
<data-source source="LOCAL" name="postgres@localhost" uuid="40aa621c-e69e-45d6-a5a3-1ce067327512">
|
||||||
|
<driver-ref>postgresql</driver-ref>
|
||||||
|
<synchronize>true</synchronize>
|
||||||
|
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||||
|
<jdbc-url>jdbc:postgresql://localhost:5432/postgres</jdbc-url>
|
||||||
|
<working-dir>$ProjectFileDir$</working-dir>
|
||||||
|
</data-source>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="JsonSchemaCatalogProjectConfiguration">
|
||||||
|
<enabled>false</enabled>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
+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,73 @@
|
|||||||
|
CREATE TABLE "account" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"account_id" text NOT NULL,
|
||||||
|
"provider_id" text NOT NULL,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"access_token" text,
|
||||||
|
"refresh_token" text,
|
||||||
|
"id_token" text,
|
||||||
|
"access_token_expires_at" timestamp,
|
||||||
|
"refresh_token_expires_at" timestamp,
|
||||||
|
"scope" text,
|
||||||
|
"password" text,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "reservations" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"table_id" integer NOT NULL,
|
||||||
|
"customer_name" text NOT NULL,
|
||||||
|
"customer_email" text NOT NULL,
|
||||||
|
"party_size" integer NOT NULL,
|
||||||
|
"reservation_time" timestamp with time zone NOT NULL,
|
||||||
|
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT "reservations_table_id_reservation_time_unique" UNIQUE("table_id","reservation_time")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "restaurant_tables" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"seats" integer NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "session" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"token" text NOT NULL,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL,
|
||||||
|
"ip_address" text,
|
||||||
|
"user_agent" text,
|
||||||
|
"user_id" text NOT NULL,
|
||||||
|
"impersonated_by" text,
|
||||||
|
CONSTRAINT "session_token_unique" UNIQUE("token")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "user" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"name" text NOT NULL,
|
||||||
|
"email" text NOT NULL,
|
||||||
|
"email_verified" boolean NOT NULL,
|
||||||
|
"image" text,
|
||||||
|
"created_at" timestamp NOT NULL,
|
||||||
|
"updated_at" timestamp NOT NULL,
|
||||||
|
"role" text,
|
||||||
|
"banned" boolean,
|
||||||
|
"ban_reason" text,
|
||||||
|
"ban_expires" timestamp,
|
||||||
|
CONSTRAINT "user_email_unique" UNIQUE("email")
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE TABLE "verification" (
|
||||||
|
"id" text PRIMARY KEY NOT NULL,
|
||||||
|
"identifier" text NOT NULL,
|
||||||
|
"value" text NOT NULL,
|
||||||
|
"expires_at" timestamp NOT NULL,
|
||||||
|
"created_at" timestamp,
|
||||||
|
"updated_at" timestamp
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "account" ADD CONSTRAINT "account_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reservations" ADD CONSTRAINT "reservations_table_id_restaurant_tables_id_fk" FOREIGN KEY ("table_id") REFERENCES "public"."restaurant_tables"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||||
|
ALTER TABLE "session" ADD CONSTRAINT "session_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "reservations" ADD COLUMN "cancel_token" text DEFAULT gen_random_uuid() NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "reservations" ADD CONSTRAINT "reservations_cancel_token_unique" UNIQUE("cancel_token");
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
CREATE TABLE "restaurant_settings" (
|
||||||
|
"id" serial PRIMARY KEY NOT NULL,
|
||||||
|
"reservation_interval_minutes" integer DEFAULT 15 NOT NULL,
|
||||||
|
"reservation_duration_minutes" integer DEFAULT 90 NOT NULL,
|
||||||
|
"min_lead_time_minutes" integer DEFAULT 60 NOT NULL
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
ALTER TABLE "reservations" ADD COLUMN "duration_minutes" integer DEFAULT 90 NOT NULL;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "restaurant_settings" ADD COLUMN "max_lead_time_days" integer DEFAULT 60 NOT NULL;
|
||||||
@@ -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,459 @@
|
|||||||
|
{
|
||||||
|
"id": "fc9539d6-3015-4ae3-bbf2-7a1a9f4de482",
|
||||||
|
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_user_id_user_id_fk": {
|
||||||
|
"name": "account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.reservations": {
|
||||||
|
"name": "reservations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"table_id": {
|
||||||
|
"name": "table_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"customer_name": {
|
||||||
|
"name": "customer_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"customer_email": {
|
||||||
|
"name": "customer_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"party_size": {
|
||||||
|
"name": "party_size",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"reservation_time": {
|
||||||
|
"name": "reservation_time",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"reservations_table_id_restaurant_tables_id_fk": {
|
||||||
|
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||||
|
"tableFrom": "reservations",
|
||||||
|
"tableTo": "restaurant_tables",
|
||||||
|
"columnsFrom": [
|
||||||
|
"table_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"reservations_table_id_reservation_time_unique": {
|
||||||
|
"name": "reservations_table_id_reservation_time_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"table_id",
|
||||||
|
"reservation_time"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.restaurant_tables": {
|
||||||
|
"name": "restaurant_tables",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"seats": {
|
||||||
|
"name": "seats",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"impersonated_by": {
|
||||||
|
"name": "impersonated_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"banned": {
|
||||||
|
"name": "banned",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ban_reason": {
|
||||||
|
"name": "ban_reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ban_expires": {
|
||||||
|
"name": "ban_expires",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,473 @@
|
|||||||
|
{
|
||||||
|
"id": "65d581ab-b3c7-4ffb-8a79-afcf26be5af5",
|
||||||
|
"prevId": "fc9539d6-3015-4ae3-bbf2-7a1a9f4de482",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_user_id_user_id_fk": {
|
||||||
|
"name": "account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.reservations": {
|
||||||
|
"name": "reservations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"table_id": {
|
||||||
|
"name": "table_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"customer_name": {
|
||||||
|
"name": "customer_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"customer_email": {
|
||||||
|
"name": "customer_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"party_size": {
|
||||||
|
"name": "party_size",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"reservation_time": {
|
||||||
|
"name": "reservation_time",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"cancel_token": {
|
||||||
|
"name": "cancel_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"reservations_table_id_restaurant_tables_id_fk": {
|
||||||
|
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||||
|
"tableFrom": "reservations",
|
||||||
|
"tableTo": "restaurant_tables",
|
||||||
|
"columnsFrom": [
|
||||||
|
"table_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"reservations_cancel_token_unique": {
|
||||||
|
"name": "reservations_cancel_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"cancel_token"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reservations_table_id_reservation_time_unique": {
|
||||||
|
"name": "reservations_table_id_reservation_time_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"table_id",
|
||||||
|
"reservation_time"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.restaurant_tables": {
|
||||||
|
"name": "restaurant_tables",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"seats": {
|
||||||
|
"name": "seats",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"impersonated_by": {
|
||||||
|
"name": "impersonated_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"banned": {
|
||||||
|
"name": "banned",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ban_reason": {
|
||||||
|
"name": "ban_reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ban_expires": {
|
||||||
|
"name": "ban_expires",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,520 @@
|
|||||||
|
{
|
||||||
|
"id": "5e900bf2-030d-4eb3-a8ab-3b4c7d85e203",
|
||||||
|
"prevId": "65d581ab-b3c7-4ffb-8a79-afcf26be5af5",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_user_id_user_id_fk": {
|
||||||
|
"name": "account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.reservations": {
|
||||||
|
"name": "reservations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"table_id": {
|
||||||
|
"name": "table_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"customer_name": {
|
||||||
|
"name": "customer_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"customer_email": {
|
||||||
|
"name": "customer_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"party_size": {
|
||||||
|
"name": "party_size",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"reservation_time": {
|
||||||
|
"name": "reservation_time",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"duration_minutes": {
|
||||||
|
"name": "duration_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"cancel_token": {
|
||||||
|
"name": "cancel_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"reservations_table_id_restaurant_tables_id_fk": {
|
||||||
|
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||||
|
"tableFrom": "reservations",
|
||||||
|
"tableTo": "restaurant_tables",
|
||||||
|
"columnsFrom": [
|
||||||
|
"table_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"reservations_cancel_token_unique": {
|
||||||
|
"name": "reservations_cancel_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"cancel_token"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reservations_table_id_reservation_time_unique": {
|
||||||
|
"name": "reservations_table_id_reservation_time_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"table_id",
|
||||||
|
"reservation_time"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.restaurant_settings": {
|
||||||
|
"name": "restaurant_settings",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"reservation_interval_minutes": {
|
||||||
|
"name": "reservation_interval_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 15
|
||||||
|
},
|
||||||
|
"reservation_duration_minutes": {
|
||||||
|
"name": "reservation_duration_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"min_lead_time_minutes": {
|
||||||
|
"name": "min_lead_time_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.restaurant_tables": {
|
||||||
|
"name": "restaurant_tables",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"seats": {
|
||||||
|
"name": "seats",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"impersonated_by": {
|
||||||
|
"name": "impersonated_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"banned": {
|
||||||
|
"name": "banned",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ban_reason": {
|
||||||
|
"name": "ban_reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ban_expires": {
|
||||||
|
"name": "ban_expires",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,527 @@
|
|||||||
|
{
|
||||||
|
"id": "d03b9103-cb6a-4968-a016-c04270ec176a",
|
||||||
|
"prevId": "5e900bf2-030d-4eb3-a8ab-3b4c7d85e203",
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"tables": {
|
||||||
|
"public.account": {
|
||||||
|
"name": "account",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"account_id": {
|
||||||
|
"name": "account_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"provider_id": {
|
||||||
|
"name": "provider_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"access_token": {
|
||||||
|
"name": "access_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token": {
|
||||||
|
"name": "refresh_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"id_token": {
|
||||||
|
"name": "id_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"access_token_expires_at": {
|
||||||
|
"name": "access_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"refresh_token_expires_at": {
|
||||||
|
"name": "refresh_token_expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"scope": {
|
||||||
|
"name": "scope",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"password": {
|
||||||
|
"name": "password",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"account_user_id_user_id_fk": {
|
||||||
|
"name": "account_user_id_user_id_fk",
|
||||||
|
"tableFrom": "account",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.reservations": {
|
||||||
|
"name": "reservations",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"table_id": {
|
||||||
|
"name": "table_id",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"customer_name": {
|
||||||
|
"name": "customer_name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"customer_email": {
|
||||||
|
"name": "customer_email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"party_size": {
|
||||||
|
"name": "party_size",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"reservation_time": {
|
||||||
|
"name": "reservation_time",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"duration_minutes": {
|
||||||
|
"name": "duration_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"cancel_token": {
|
||||||
|
"name": "cancel_token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "gen_random_uuid()"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp with time zone",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": "now()"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"reservations_table_id_restaurant_tables_id_fk": {
|
||||||
|
"name": "reservations_table_id_restaurant_tables_id_fk",
|
||||||
|
"tableFrom": "reservations",
|
||||||
|
"tableTo": "restaurant_tables",
|
||||||
|
"columnsFrom": [
|
||||||
|
"table_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"reservations_cancel_token_unique": {
|
||||||
|
"name": "reservations_cancel_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"cancel_token"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"reservations_table_id_reservation_time_unique": {
|
||||||
|
"name": "reservations_table_id_reservation_time_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"table_id",
|
||||||
|
"reservation_time"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.restaurant_settings": {
|
||||||
|
"name": "restaurant_settings",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"reservation_interval_minutes": {
|
||||||
|
"name": "reservation_interval_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 15
|
||||||
|
},
|
||||||
|
"reservation_duration_minutes": {
|
||||||
|
"name": "reservation_duration_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 90
|
||||||
|
},
|
||||||
|
"min_lead_time_minutes": {
|
||||||
|
"name": "min_lead_time_minutes",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 60
|
||||||
|
},
|
||||||
|
"max_lead_time_days": {
|
||||||
|
"name": "max_lead_time_days",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true,
|
||||||
|
"default": 60
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.restaurant_tables": {
|
||||||
|
"name": "restaurant_tables",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "serial",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"seats": {
|
||||||
|
"name": "seats",
|
||||||
|
"type": "integer",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.session": {
|
||||||
|
"name": "session",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"token": {
|
||||||
|
"name": "token",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"ip_address": {
|
||||||
|
"name": "ip_address",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_agent": {
|
||||||
|
"name": "user_agent",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"user_id": {
|
||||||
|
"name": "user_id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"impersonated_by": {
|
||||||
|
"name": "impersonated_by",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {
|
||||||
|
"session_user_id_user_id_fk": {
|
||||||
|
"name": "session_user_id_user_id_fk",
|
||||||
|
"tableFrom": "session",
|
||||||
|
"tableTo": "user",
|
||||||
|
"columnsFrom": [
|
||||||
|
"user_id"
|
||||||
|
],
|
||||||
|
"columnsTo": [
|
||||||
|
"id"
|
||||||
|
],
|
||||||
|
"onDelete": "cascade",
|
||||||
|
"onUpdate": "no action"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"session_token_unique": {
|
||||||
|
"name": "session_token_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"token"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.user": {
|
||||||
|
"name": "user",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"name": "name",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"name": "email",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"email_verified": {
|
||||||
|
"name": "email_verified",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"image": {
|
||||||
|
"name": "image",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"name": "role",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"banned": {
|
||||||
|
"name": "banned",
|
||||||
|
"type": "boolean",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ban_reason": {
|
||||||
|
"name": "ban_reason",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"ban_expires": {
|
||||||
|
"name": "ban_expires",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {
|
||||||
|
"user_email_unique": {
|
||||||
|
"name": "user_email_unique",
|
||||||
|
"nullsNotDistinct": false,
|
||||||
|
"columns": [
|
||||||
|
"email"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
},
|
||||||
|
"public.verification": {
|
||||||
|
"name": "verification",
|
||||||
|
"schema": "",
|
||||||
|
"columns": {
|
||||||
|
"id": {
|
||||||
|
"name": "id",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": true,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"identifier": {
|
||||||
|
"name": "identifier",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"value": {
|
||||||
|
"name": "value",
|
||||||
|
"type": "text",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"expires_at": {
|
||||||
|
"name": "expires_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": true
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"name": "created_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
},
|
||||||
|
"updated_at": {
|
||||||
|
"name": "updated_at",
|
||||||
|
"type": "timestamp",
|
||||||
|
"primaryKey": false,
|
||||||
|
"notNull": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"indexes": {},
|
||||||
|
"foreignKeys": {},
|
||||||
|
"compositePrimaryKeys": {},
|
||||||
|
"uniqueConstraints": {},
|
||||||
|
"policies": {},
|
||||||
|
"checkConstraints": {},
|
||||||
|
"isRLSEnabled": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enums": {},
|
||||||
|
"schemas": {},
|
||||||
|
"sequences": {},
|
||||||
|
"roles": {},
|
||||||
|
"policies": {},
|
||||||
|
"views": {},
|
||||||
|
"_meta": {
|
||||||
|
"columns": {},
|
||||||
|
"schemas": {},
|
||||||
|
"tables": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787335013744,
|
||||||
|
"tag": "0000_milky_squadron_sinister",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 1,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787338587505,
|
||||||
|
"tag": "0001_melted_supernaut",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 2,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787604850455,
|
||||||
|
"tag": "0002_deep_dorian_gray",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 3,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787646264161,
|
||||||
|
"tag": "0003_big_jack_flag",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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
+52
-91
@@ -13,6 +13,7 @@
|
|||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"resend": "^6.21.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -453,7 +454,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -470,7 +470,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -487,7 +486,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -504,7 +502,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -521,7 +518,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -538,7 +534,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -555,7 +550,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -572,7 +566,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -589,7 +582,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -606,7 +598,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -623,7 +614,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -640,7 +630,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -657,7 +646,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -674,7 +662,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -691,7 +678,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -708,7 +694,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -725,7 +710,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -742,7 +726,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -759,7 +742,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -776,7 +758,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -793,7 +774,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -810,7 +790,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -877,7 +856,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -894,7 +872,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -911,7 +888,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -928,7 +904,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -945,7 +920,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -962,7 +936,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -979,7 +952,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -996,7 +968,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1013,7 +984,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1030,7 +1000,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1047,7 +1016,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1064,7 +1032,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1081,7 +1048,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1098,7 +1064,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1115,7 +1080,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1132,7 +1096,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1149,7 +1112,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1166,7 +1128,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1183,7 +1144,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1200,7 +1160,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1217,7 +1176,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1234,7 +1192,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1251,7 +1208,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1268,7 +1224,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1285,7 +1240,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1302,7 +1256,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1455,7 +1408,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1472,7 +1424,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1489,7 +1440,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1506,7 +1456,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1523,7 +1472,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1540,7 +1488,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1557,7 +1504,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1574,7 +1520,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1591,7 +1536,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1608,7 +1552,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1625,7 +1568,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1642,7 +1584,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1659,7 +1600,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1676,7 +1616,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -1693,6 +1632,12 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@stablelib/base64": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -2158,7 +2103,7 @@
|
|||||||
"version": "22.20.1",
|
"version": "22.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~6.21.0"
|
"undici-types": "~6.21.0"
|
||||||
@@ -2621,7 +2566,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2638,7 +2582,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2655,7 +2598,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2672,7 +2614,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2689,7 +2630,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2706,7 +2646,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2723,7 +2662,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2740,7 +2678,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2757,7 +2694,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2774,7 +2710,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2791,7 +2726,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2808,7 +2742,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"loong64"
|
"loong64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2825,7 +2758,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"mips64el"
|
"mips64el"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2842,7 +2774,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2859,7 +2790,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"riscv64"
|
"riscv64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2876,7 +2806,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2893,7 +2822,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2910,7 +2838,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2927,7 +2854,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2944,7 +2870,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2961,7 +2886,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2978,7 +2902,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -2995,7 +2918,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3012,7 +2934,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3029,7 +2950,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"ia32"
|
"ia32"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3046,7 +2966,6 @@
|
|||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"os": [
|
"os": [
|
||||||
@@ -3293,6 +3212,12 @@
|
|||||||
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
|
"integrity": "sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-sha256": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
|
||||||
|
"license": "Unlicense"
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -3320,7 +3245,6 @@
|
|||||||
"version": "2.3.3",
|
"version": "2.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||||
"dev": true,
|
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -3826,6 +3750,12 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postal-mime": {
|
||||||
|
"version": "2.7.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.5.tgz",
|
||||||
|
"integrity": "sha512-GNEXKvWFQnbgO5NlrGzVa0FmWzBZ24PersAWErttSg1Hjpf0ATxTwS5DOMGaOpTG6bUh5cTr7xi0jAD942wCJA==",
|
||||||
|
"license": "MIT-0"
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.26",
|
"version": "8.5.26",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz",
|
||||||
@@ -3927,6 +3857,27 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/resend": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/resend/-/resend-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-XT1eP2iA8rZ51NSO2CJlL1ZE2baBoPpOGMgz2p0o2uluz/YU3XfItpGymXDlL0Sp2Mc+y9+Xxsfdccfi4f2FzQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"postal-mime": "2.7.5",
|
||||||
|
"standardwebhooks": "1.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@react-email/render": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@react-email/render": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-pkg-maps": {
|
"node_modules/resolve-pkg-maps": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||||
@@ -4070,6 +4021,16 @@
|
|||||||
"node": ">=20.16.0"
|
"node": ">=20.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/standardwebhooks": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@stablelib/base64": "^1.0.0",
|
||||||
|
"fast-sha256": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string-width": {
|
"node_modules/string-width": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
@@ -4157,7 +4118,7 @@
|
|||||||
"version": "6.21.0",
|
"version": "6.21.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
"dev": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/unplugin": {
|
"node_modules/unplugin": {
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,7 @@
|
|||||||
"#/*": "./src/*"
|
"#/*": "./src/*"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev --port 3000",
|
"dev": "vite dev --port 3000 --host",
|
||||||
"generate-routes": "tsr generate",
|
"generate-routes": "tsr generate",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
@@ -24,6 +24,7 @@
|
|||||||
"postgres": "^3.4.9",
|
"postgres": "^3.4.9",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
"resend": "^6.21.0",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { Link, useRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { authClient } from '#/lib/auth-client'
|
||||||
|
|
||||||
|
type StaffUser = { name: string; role?: string | null }
|
||||||
|
type SectionLink = { label: string; hash: string }
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
staffUser?: StaffUser | null
|
||||||
|
sectionLinks?: SectionLink[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function MenuIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" aria-hidden="true">
|
||||||
|
<path d="M4 6h16M4 12h16M4 18h16" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppSidebar({ staffUser, sectionLinks }: Props) {
|
||||||
|
const router = useRouter()
|
||||||
|
const [isOpen, setIsOpen] = useState(false)
|
||||||
|
const navRef = useRef<HTMLElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return
|
||||||
|
|
||||||
|
function handleOutsideClick(event: MouseEvent) {
|
||||||
|
if (navRef.current && !navRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') setIsOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleOutsideClick)
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleOutsideClick)
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await authClient.signOut()
|
||||||
|
await router.navigate({ to: '/staff/login' })
|
||||||
|
await router.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="app-sidebar" aria-label="Hauptnavigation" ref={navRef}>
|
||||||
|
<div className="app-sidebar-bar">
|
||||||
|
<Link className="wordmark app-sidebar-brand" to="/" onClick={() => setIsOpen(false)}>
|
||||||
|
Steakhaus <span>bei Mirko</span>
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="app-sidebar-toggle"
|
||||||
|
aria-label={isOpen ? 'Menü schließen' : 'Menü öffnen'}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
onClick={() => setIsOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={isOpen ? 'app-sidebar-panel is-open' : 'app-sidebar-panel'} onClick={() => setIsOpen(false)}>
|
||||||
|
<ul className="app-sidebar-nav">
|
||||||
|
{sectionLinks && sectionLinks.length > 0 && (
|
||||||
|
<>
|
||||||
|
{sectionLinks.map((section) => (
|
||||||
|
<li key={section.hash}>
|
||||||
|
<Link to="/" hash={section.hash}>
|
||||||
|
{section.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
<li className="app-sidebar-divider" role="separator" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{staffUser && (
|
||||||
|
<li>
|
||||||
|
<Link to="/staff" activeOptions={{ exact: true }} activeProps={{ className: 'is-active' }}>
|
||||||
|
Reservierungen
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{staffUser?.role === 'admin' && (
|
||||||
|
<li>
|
||||||
|
<Link to="/staff/team" activeProps={{ className: 'is-active' }}>
|
||||||
|
Mitarbeiter
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{staffUser && <li className="app-sidebar-divider" role="separator" />}
|
||||||
|
<li>
|
||||||
|
<Link to="/" activeOptions={{ exact: true }} activeProps={{ className: 'is-active' }}>
|
||||||
|
Startseite
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link to="/impressum" activeProps={{ className: 'is-active' }}>
|
||||||
|
Impressum
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className="app-sidebar-footer">
|
||||||
|
{staffUser ? (
|
||||||
|
<>
|
||||||
|
<span className="app-sidebar-user">Angemeldet als {staffUser.name}</span>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Link className="btn btn-secondary" to="/staff/login">
|
||||||
|
Mitarbeiter-Login
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { useEffect, useRef } from 'react'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean
|
||||||
|
title: string
|
||||||
|
message: React.ReactNode
|
||||||
|
// A final, visually distinct callout for what irreversible actions this
|
||||||
|
// confirms — kept separate from `message` so callers don't have to hand-roll
|
||||||
|
// the emphasis every time (e.g. "This cannot be undone.").
|
||||||
|
warning?: React.ReactNode
|
||||||
|
confirmLabel?: string
|
||||||
|
cancelLabel?: string
|
||||||
|
danger?: boolean
|
||||||
|
onConfirm: () => void
|
||||||
|
onCancel: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
function WarningIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M12 3.5 21.5 20h-19L12 3.5Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M12 9.5v4.2" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
<circle cx="12" cy="16.7" r="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
warning,
|
||||||
|
confirmLabel = 'Bestätigen',
|
||||||
|
cancelLabel = 'Abbrechen',
|
||||||
|
danger,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: Props) {
|
||||||
|
const confirmRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return
|
||||||
|
|
||||||
|
confirmRef.current?.focus()
|
||||||
|
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') onCancel()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [open, onCancel])
|
||||||
|
|
||||||
|
if (!open) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="confirm-dialog-backdrop" onClick={onCancel}>
|
||||||
|
<div
|
||||||
|
className="confirm-dialog"
|
||||||
|
role="alertdialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="confirm-dialog-title"
|
||||||
|
aria-describedby="confirm-dialog-message"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h2 id="confirm-dialog-title" className="confirm-dialog-title">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<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}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ref={confirmRef}
|
||||||
|
type="button"
|
||||||
|
className={danger ? 'btn btn-danger' : 'btn btn-primary'}
|
||||||
|
onClick={onConfirm}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
import { getOpeningRangesForWeekday, type OpeningRange } from '#/lib/opening-hours'
|
||||||
|
|
||||||
|
type ReservationLike = {
|
||||||
|
tableId: number
|
||||||
|
reservationTime: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
type TableLike = {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
reservations: ReservationLike[]
|
||||||
|
tables: TableLike[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type BarDatum = {
|
||||||
|
label: string
|
||||||
|
value: number
|
||||||
|
tooltip: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Granularity = 'today' | 'week' | 'month'
|
||||||
|
type DateBucketGranularity = 'week' | 'month'
|
||||||
|
|
||||||
|
const GRANULARITY_LABELS: Record<Granularity, string> = {
|
||||||
|
today: 'Heute',
|
||||||
|
week: 'Woche',
|
||||||
|
month: 'Monat',
|
||||||
|
}
|
||||||
|
|
||||||
|
// How many buckets — and how far out — each granularity covers: enough weeks/months
|
||||||
|
// to spot a trend without the axis becoming unreadable.
|
||||||
|
const BUCKET_COUNTS: Record<DateBucketGranularity, number> = {
|
||||||
|
week: 8,
|
||||||
|
month: 6,
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEK_LABEL_FORMATTER = new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' })
|
||||||
|
const MONTH_AXIS_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'short' })
|
||||||
|
const MONTH_TOOLTIP_FORMATTER = new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' })
|
||||||
|
|
||||||
|
function reservationsLabel(count: number) {
|
||||||
|
return count === 1 ? 'Reservierung' : 'Reservierungen'
|
||||||
|
}
|
||||||
|
|
||||||
|
function addBucketUnit(date: Date, granularity: DateBucketGranularity, amount: number) {
|
||||||
|
const d = new Date(date)
|
||||||
|
if (granularity === 'week') d.setDate(d.getDate() + amount * 7)
|
||||||
|
else d.setMonth(d.getMonth() + amount)
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// The start of the first bucket: the Monday of this week, or the 1st of this month —
|
||||||
|
// so the current, still-in-progress bucket covers the whole period, not just what's
|
||||||
|
// left of it (the caller is expected to pass in ALL reservations, not just upcoming
|
||||||
|
// ones, so this week's/month's earlier days are still counted).
|
||||||
|
function bucketRangeStart(granularity: DateBucketGranularity) {
|
||||||
|
const now = new Date()
|
||||||
|
now.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
if (granularity === 'month') {
|
||||||
|
now.setDate(1)
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
const mondayOffset = (now.getDay() + 6) % 7
|
||||||
|
now.setDate(now.getDate() - mondayOffset)
|
||||||
|
return now
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBucketLabel(start: Date, granularity: DateBucketGranularity) {
|
||||||
|
if (granularity === 'month') return MONTH_AXIS_FORMATTER.format(start)
|
||||||
|
return WEEK_LABEL_FORMATTER.format(start)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBucketTooltip(start: Date, end: Date, granularity: DateBucketGranularity, count: number) {
|
||||||
|
const countLabel = `${count} ${reservationsLabel(count)}`
|
||||||
|
|
||||||
|
if (granularity === 'month') return `${MONTH_TOOLTIP_FORMATTER.format(start)}: ${countLabel}`
|
||||||
|
|
||||||
|
const lastDayOfWeek = new Date(end.getTime() - 24 * 60 * 60 * 1000)
|
||||||
|
return `${WEEK_LABEL_FORMATTER.format(start)}–${WEEK_LABEL_FORMATTER.format(lastDayOfWeek)}: ${countLabel}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBucketedCounts(reservations: ReservationLike[], granularity: DateBucketGranularity): BarDatum[] {
|
||||||
|
const rangeStart = bucketRangeStart(granularity)
|
||||||
|
const bucketCount = BUCKET_COUNTS[granularity]
|
||||||
|
|
||||||
|
return Array.from({ length: bucketCount }, (_, i) => {
|
||||||
|
const start = addBucketUnit(rangeStart, granularity, i)
|
||||||
|
const end = addBucketUnit(rangeStart, granularity, i + 1)
|
||||||
|
const count = reservations.filter((r) => {
|
||||||
|
const time = new Date(r.reservationTime).getTime()
|
||||||
|
return time >= start.getTime() && time < end.getTime()
|
||||||
|
}).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: formatBucketLabel(start, granularity),
|
||||||
|
value: count,
|
||||||
|
tooltip: formatBucketTooltip(start, end, granularity, count),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The restaurant can have more than one service window in a day (e.g. lunch +
|
||||||
|
// dinner) — span the whole gap between them rather than faceting the chart, so a
|
||||||
|
// quiet hour between services reads as "no bookings then", not a missing bar.
|
||||||
|
function hourSpanForRanges(ranges: OpeningRange[]) {
|
||||||
|
const startHour = Math.min(...ranges.map((r) => Number(r.start.split(':')[0])))
|
||||||
|
const endHour = Math.max(
|
||||||
|
...ranges.map((r) => {
|
||||||
|
const [hour, minute] = r.end.split(':').map(Number)
|
||||||
|
return minute > 0 ? hour + 1 : hour
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
return { startHour, endHour }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHourlyCountsForToday(reservations: ReservationLike[], ranges: OpeningRange[]): BarDatum[] {
|
||||||
|
const { startHour, endHour } = hourSpanForRanges(ranges)
|
||||||
|
const dayStart = new Date()
|
||||||
|
dayStart.setHours(0, 0, 0, 0)
|
||||||
|
|
||||||
|
return Array.from({ length: endHour - startHour }, (_, i) => {
|
||||||
|
const hour = startHour + i
|
||||||
|
const start = new Date(dayStart)
|
||||||
|
start.setHours(hour)
|
||||||
|
const end = new Date(dayStart)
|
||||||
|
end.setHours(hour + 1)
|
||||||
|
|
||||||
|
const count = reservations.filter((r) => {
|
||||||
|
const time = new Date(r.reservationTime).getTime()
|
||||||
|
return time >= start.getTime() && time < end.getTime()
|
||||||
|
}).length
|
||||||
|
|
||||||
|
return {
|
||||||
|
label: `${String(hour).padStart(2, '0')}:00`,
|
||||||
|
value: count,
|
||||||
|
tooltip: `${String(hour).padStart(2, '0')}:00–${String(hour + 1).padStart(2, '0')}:00: ${count} ${reservationsLabel(count)}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimelineData(
|
||||||
|
reservations: ReservationLike[],
|
||||||
|
granularity: Granularity,
|
||||||
|
): { data: BarDatum[]; emptyMessage: string } {
|
||||||
|
if (granularity === 'today') {
|
||||||
|
const ranges = getOpeningRangesForWeekday(new Date().getDay())
|
||||||
|
if (ranges.length === 0) {
|
||||||
|
return { data: [], emptyMessage: 'Heute ist Ruhetag.' }
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
data: buildHourlyCountsForToday(reservations, ranges),
|
||||||
|
emptyMessage: 'Heute sind noch keine Reservierungen eingetragen.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: buildBucketedCounts(reservations, granularity),
|
||||||
|
emptyMessage: 'In diesem Zeitraum sind noch keine Reservierungen eingetragen.',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The full date range the timeline chart currently covers, so the per-table chart
|
||||||
|
// can be filtered to the same window and the two charts always tell one consistent
|
||||||
|
// story instead of the table breakdown silently covering a different span.
|
||||||
|
function getTimelineRange(granularity: Granularity): { start: Date; end: Date } {
|
||||||
|
if (granularity === 'today') {
|
||||||
|
const start = new Date()
|
||||||
|
start.setHours(0, 0, 0, 0)
|
||||||
|
const end = new Date(start)
|
||||||
|
end.setDate(end.getDate() + 1)
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = bucketRangeStart(granularity)
|
||||||
|
const end = addBucketUnit(start, granularity, BUCKET_COUNTS[granularity])
|
||||||
|
return { start, end }
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTableCounts(reservations: ReservationLike[], tables: TableLike[]): BarDatum[] {
|
||||||
|
return tables.map((table) => {
|
||||||
|
const count = reservations.filter((r) => r.tableId === table.id).length
|
||||||
|
return {
|
||||||
|
label: table.name,
|
||||||
|
value: count,
|
||||||
|
tooltip: `${table.name}: ${count} ${reservationsLabel(count)}`,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Produces clean, evenly-stepped tick values (0, 2, 4, 6, …) so the gridlines
|
||||||
|
// never land on an awkward number like 7 or 13.
|
||||||
|
function niceTicks(maxValue: number, tickCount = 4): number[] {
|
||||||
|
if (maxValue <= 0) return [0, 1]
|
||||||
|
|
||||||
|
const rawStep = maxValue / (tickCount - 1)
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(rawStep))
|
||||||
|
const residual = rawStep / magnitude
|
||||||
|
const niceResidual = residual <= 1 ? 1 : residual <= 2 ? 2 : residual <= 5 ? 5 : 10
|
||||||
|
// Counts are always whole numbers, so a sub-1 step (e.g. 0.5) would just
|
||||||
|
// round two distinct ticks down to the same displayed integer.
|
||||||
|
const step = Math.max(1, niceResidual * magnitude)
|
||||||
|
const niceMax = Math.ceil(maxValue / step) * step
|
||||||
|
|
||||||
|
const ticks: number[] = []
|
||||||
|
for (let v = 0; v <= niceMax + 1e-9; v += step) ticks.push(Math.round(v))
|
||||||
|
return ticks
|
||||||
|
}
|
||||||
|
|
||||||
|
function BarChartBody({ data, showValueLabels }: { data: BarDatum[]; showValueLabels?: boolean }) {
|
||||||
|
const ticks = niceTicks(Math.max(...data.map((d) => d.value), 0))
|
||||||
|
const chartMax = ticks[ticks.length - 1] || 1
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bar-chart">
|
||||||
|
<div className="bar-chart-plot">
|
||||||
|
{ticks.map((tick) => (
|
||||||
|
<div key={tick} className="bar-chart-gridline" style={{ bottom: `${(tick / chartMax) * 100}%` }}>
|
||||||
|
<span className="bar-chart-tick">{tick}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="bar-chart-bars">
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<div className="bar-chart-col" key={i} tabIndex={0} aria-label={d.tooltip}>
|
||||||
|
<div className="bar-chart-bar" style={{ height: `${(d.value / chartMax) * 100}%` }}>
|
||||||
|
{showValueLabels && d.value > 0 && <span className="bar-chart-value">{d.value}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="bar-chart-tooltip">{d.tooltip}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bar-chart-axis">
|
||||||
|
{data.map((d, i) => (
|
||||||
|
<span key={i} className="bar-chart-axis-label">
|
||||||
|
{d.label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChartCard({
|
||||||
|
title,
|
||||||
|
headerExtra,
|
||||||
|
data,
|
||||||
|
showValueLabels,
|
||||||
|
emptyMessage,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
headerExtra?: React.ReactNode
|
||||||
|
data: BarDatum[]
|
||||||
|
showValueLabels?: boolean
|
||||||
|
emptyMessage: string
|
||||||
|
}) {
|
||||||
|
const hasData = data.some((d) => d.value > 0)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="chart-card">
|
||||||
|
<div className="chart-card-header">
|
||||||
|
<h3 className="chart-title">{title}</h3>
|
||||||
|
{headerExtra}
|
||||||
|
</div>
|
||||||
|
{hasData ? <BarChartBody data={data} showValueLabels={showValueLabels} /> : <p className="empty-state">{emptyMessage}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function GranularityToggle({ value, onChange }: { value: Granularity; onChange: (g: Granularity) => void }) {
|
||||||
|
return (
|
||||||
|
<div className="chart-toggle" role="group" aria-label="Zeitraum">
|
||||||
|
{(Object.keys(GRANULARITY_LABELS) as Granularity[]).map((g) => (
|
||||||
|
<button
|
||||||
|
key={g}
|
||||||
|
type="button"
|
||||||
|
className={g === value ? 'is-active' : undefined}
|
||||||
|
aria-pressed={g === value}
|
||||||
|
onClick={() => onChange(g)}
|
||||||
|
>
|
||||||
|
{GRANULARITY_LABELS[g]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReservationCharts({ reservations, tables }: Props) {
|
||||||
|
const [granularity, setGranularity] = useState<Granularity>('today')
|
||||||
|
|
||||||
|
const timeline = buildTimelineData(reservations, granularity)
|
||||||
|
|
||||||
|
const { start, end } = getTimelineRange(granularity)
|
||||||
|
const reservationsInRange = reservations.filter((r) => {
|
||||||
|
const time = new Date(r.reservationTime).getTime()
|
||||||
|
return time >= start.getTime() && time < end.getTime()
|
||||||
|
})
|
||||||
|
const tableCounts = buildTableCounts(reservationsInRange, tables)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="charts-grid">
|
||||||
|
<ChartCard
|
||||||
|
title="Reservierungen"
|
||||||
|
headerExtra={<GranularityToggle value={granularity} onChange={setGranularity} />}
|
||||||
|
data={timeline.data}
|
||||||
|
emptyMessage={timeline.emptyMessage}
|
||||||
|
/>
|
||||||
|
<ChartCard
|
||||||
|
title="Reservierungen je Tisch"
|
||||||
|
data={tableCounts}
|
||||||
|
showValueLabels
|
||||||
|
emptyMessage={timeline.emptyMessage}
|
||||||
|
/>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,362 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { formatDateOnly } from '#/lib/datetime'
|
||||||
|
import { generateTimeOptions, getOpeningRangesForWeekday, RESERVATION_INTERVAL_MINUTES } from '#/lib/opening-hours'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
value: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
// "HH:MM" times already taken (e.g. by another reservation on the same table)
|
||||||
|
// for the currently selected date — shown disabled rather than removed, so the
|
||||||
|
// slot doesn't look like it doesn't exist, just that it isn't free.
|
||||||
|
bookedTimes?: string[]
|
||||||
|
// How far apart selectable times are, and how far in advance a booking must be
|
||||||
|
// made — both configurable restaurant settings, falling back to sane defaults.
|
||||||
|
intervalMinutes?: number
|
||||||
|
minLeadTimeMinutes?: number
|
||||||
|
// How 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
|
||||||
|
}
|
||||||
|
|
||||||
|
type DayCell = { day: number; dateStr: string } | null
|
||||||
|
|
||||||
|
function splitValue(value: string) {
|
||||||
|
const [date = '', time = ''] = value.split('T')
|
||||||
|
return { date, time }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWeekday(dateStr: string) {
|
||||||
|
return new Date(`${dateStr}T00:00`).getDay()
|
||||||
|
}
|
||||||
|
|
||||||
|
function pad2(n: number) {
|
||||||
|
return String(n).padStart(2, '0')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isoToDisplay(iso: string) {
|
||||||
|
if (!iso) return ''
|
||||||
|
const [year, month, day] = iso.split('-')
|
||||||
|
return `${day}.${month}.${year}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progressively inserts dots as digits are typed, e.g. "22081" -> "22.08.1".
|
||||||
|
function maskDateInput(raw: string) {
|
||||||
|
const digits = raw.replace(/\D/g, '').slice(0, 8)
|
||||||
|
let out = digits.slice(0, 2)
|
||||||
|
if (digits.length > 2) out += '.' + digits.slice(2, 4)
|
||||||
|
if (digits.length > 4) out += '.' + digits.slice(4, 8)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns an ISO "YYYY-MM-DD" string, or null if the text isn't a complete, real calendar date.
|
||||||
|
function parseDisplayDate(raw: string) {
|
||||||
|
const digits = raw.replace(/\D/g, '')
|
||||||
|
if (digits.length !== 8) return null
|
||||||
|
|
||||||
|
const day = Number(digits.slice(0, 2))
|
||||||
|
const month = Number(digits.slice(2, 4))
|
||||||
|
const year = Number(digits.slice(4, 8))
|
||||||
|
const parsed = new Date(year, month - 1, day)
|
||||||
|
|
||||||
|
if (parsed.getFullYear() !== year || parsed.getMonth() !== month - 1 || parsed.getDate() !== day) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${digits.slice(4, 8)}-${digits.slice(2, 4)}-${digits.slice(0, 2)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monday-first weeks covering the given month, with null filler cells for days
|
||||||
|
// that belong to the adjacent month.
|
||||||
|
function getMonthGrid(year: number, month: number): DayCell[][] {
|
||||||
|
const firstOfMonth = new Date(year, month, 1)
|
||||||
|
const leadingBlanks = (firstOfMonth.getDay() + 6) % 7
|
||||||
|
const daysInMonth = new Date(year, month + 1, 0).getDate()
|
||||||
|
|
||||||
|
const cells: DayCell[] = Array(leadingBlanks).fill(null)
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
cells.push({ day, dateStr: `${year}-${pad2(month + 1)}-${pad2(day)}` })
|
||||||
|
}
|
||||||
|
while (cells.length % 7 !== 0) cells.push(null)
|
||||||
|
|
||||||
|
const weeks: DayCell[][] = []
|
||||||
|
for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7))
|
||||||
|
return weeks
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_HEADER_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
|
||||||
|
|
||||||
|
function CalendarIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||||
|
<rect x="3.5" y="5" width="17" height="16" rx="2" stroke="currentColor" strokeWidth="1.4" />
|
||||||
|
<path d="M3.5 9.5h17M8 3v4M16 3v4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReservationDateTimeField({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
bookedTimes = [],
|
||||||
|
intervalMinutes = RESERVATION_INTERVAL_MINUTES,
|
||||||
|
minLeadTimeMinutes = 0,
|
||||||
|
durationMinutes = RESERVATION_INTERVAL_MINUTES,
|
||||||
|
maxLeadTimeDays,
|
||||||
|
}: Props) {
|
||||||
|
const { date, time } = splitValue(value)
|
||||||
|
const [dateText, setDateText] = useState(() => isoToDisplay(date))
|
||||||
|
const [isCalendarOpen, setIsCalendarOpen] = useState(false)
|
||||||
|
const [view, setView] = useState(() => {
|
||||||
|
const [year, month] = (date || formatDateOnly(new Date())).split('-')
|
||||||
|
return { year: Number(year), month: Number(month) - 1 }
|
||||||
|
})
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setDateText(isoToDisplay(date))
|
||||||
|
}, [date])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isCalendarOpen) return
|
||||||
|
|
||||||
|
const [year, month] = (date || formatDateOnly(new Date())).split('-')
|
||||||
|
setView({ year: Number(year), month: Number(month) - 1 })
|
||||||
|
|
||||||
|
function handleOutsideClick(event: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsCalendarOpen(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function handleKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.key === 'Escape') setIsCalendarOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleOutsideClick)
|
||||||
|
document.addEventListener('keydown', handleKeyDown)
|
||||||
|
|
||||||
|
// Only locks the background on mobile, where the popover becomes a fixed,
|
||||||
|
// full-screen overlay that would otherwise let the page behind it scroll —
|
||||||
|
// on desktop the popover is just a small anchored dropdown, and pinning the
|
||||||
|
// body there only removes the scrollbar and shifts the whole page sideways.
|
||||||
|
// Matches the `@media (max-width: 30rem)` breakpoint the popover-to-modal
|
||||||
|
// CSS switches on.
|
||||||
|
const isMobile = window.matchMedia('(max-width: 30rem)').matches
|
||||||
|
|
||||||
|
if (!isMobile) {
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleOutsideClick)
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// `overflow: hidden` alone doesn't stop touch-driven rubber-band scrolling on
|
||||||
|
// iOS Safari, so the body is pinned in place instead and the scroll position
|
||||||
|
// restored after.
|
||||||
|
const scrollY = window.scrollY
|
||||||
|
const body = document.body.style
|
||||||
|
const previousStyle = { position: body.position, top: body.top, left: body.left, right: body.right }
|
||||||
|
body.position = 'fixed'
|
||||||
|
body.top = `-${scrollY}px`
|
||||||
|
body.left = '0'
|
||||||
|
body.right = '0'
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleOutsideClick)
|
||||||
|
document.removeEventListener('keydown', handleKeyDown)
|
||||||
|
body.position = previousStyle.position
|
||||||
|
body.top = previousStyle.top
|
||||||
|
body.left = previousStyle.left
|
||||||
|
body.right = previousStyle.right
|
||||||
|
// `behavior: 'instant'` is required, not just the default — the page sets
|
||||||
|
// `scroll-behavior: smooth` globally, which would otherwise animate this
|
||||||
|
// restore from the top of the page instead of snapping back in place.
|
||||||
|
window.scrollTo({ top: scrollY, left: 0, behavior: 'instant' })
|
||||||
|
}
|
||||||
|
// Only re-run when the popover opens/closes — reopening should reset to the
|
||||||
|
// currently selected month, not follow every keystroke in the text field.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isCalendarOpen])
|
||||||
|
|
||||||
|
const today = formatDateOnly(new Date())
|
||||||
|
const isPast = date !== '' && date < today
|
||||||
|
const earliestAllowedTime = new Date(Date.now() + minLeadTimeMinutes * 60_000)
|
||||||
|
const latestDate =
|
||||||
|
maxLeadTimeDays === undefined ? null : formatDateOnly(new Date(Date.now() + maxLeadTimeDays * 86_400_000))
|
||||||
|
const isBeyondHorizon = date !== '' && latestDate !== null && date > latestDate
|
||||||
|
|
||||||
|
// Past dates and dates beyond the booking horizon never have slots — shared by both
|
||||||
|
// the render below and commitDate, so a date committed from anywhere (typed, or
|
||||||
|
// otherwise) can't slip through with a leftover, still-"valid"-looking time.
|
||||||
|
function optionsForDate(dateStr: string) {
|
||||||
|
if (!dateStr || dateStr < today || (latestDate !== null && dateStr > latestDate)) return []
|
||||||
|
return generateTimeOptions(getOpeningRangesForWeekday(getWeekday(dateStr)), intervalMinutes, durationMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelectableDate(dateStr: string) {
|
||||||
|
return optionsForDate(dateStr).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const allOptions = optionsForDate(date)
|
||||||
|
// Compares full timestamps (not just "is it today") so a lead time longer than
|
||||||
|
// what's left of today correctly spills over into tomorrow's early slots too.
|
||||||
|
const timeOptions = allOptions.filter(
|
||||||
|
(option) => new Date(`${date}T${option}`).getTime() >= earliestAllowedTime.getTime(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const bookedTimesSet = new Set(bookedTimes)
|
||||||
|
const availableTimeOptions = timeOptions.filter((option) => !bookedTimesSet.has(option))
|
||||||
|
|
||||||
|
const isClosed = date !== '' && !isPast && !isBeyondHorizon && allOptions.length === 0
|
||||||
|
const noSlotsLeft = date !== '' && !isPast && allOptions.length > 0 && availableTimeOptions.length === 0
|
||||||
|
const dateIncomplete = parseDisplayDate(dateText) === null
|
||||||
|
|
||||||
|
function commitDate(newDate: string) {
|
||||||
|
const newOptions = optionsForDate(newDate)
|
||||||
|
const stillValid = newOptions.includes(time)
|
||||||
|
onChange(`${newDate}T${stillValid ? time : newOptions[0] ?? ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTextChange(raw: string) {
|
||||||
|
const masked = maskDateInput(raw)
|
||||||
|
setDateText(masked)
|
||||||
|
|
||||||
|
const parsed = parseDisplayDate(masked)
|
||||||
|
if (parsed) {
|
||||||
|
commitDate(parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDayPick(dateStr: string) {
|
||||||
|
commitDate(dateStr)
|
||||||
|
setIsCalendarOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTimeChange(newTime: string) {
|
||||||
|
onChange(`${date}T${newTime}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
let timePlaceholder = 'Uhrzeit wählen'
|
||||||
|
if (dateIncomplete) timePlaceholder = 'Datum eingeben'
|
||||||
|
else if (isPast) timePlaceholder = 'Datum liegt in der Vergangenheit'
|
||||||
|
else if (isBeyondHorizon) timePlaceholder = 'Datum liegt zu weit in der Zukunft'
|
||||||
|
else if (isClosed) timePlaceholder = 'An diesem Tag geschlossen'
|
||||||
|
else if (noSlotsLeft) timePlaceholder = 'Keine Zeiten mehr verfügbar'
|
||||||
|
|
||||||
|
const monthLabel = new Date(view.year, view.month, 1).toLocaleDateString('de-DE', {
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric',
|
||||||
|
})
|
||||||
|
const [todayYear, todayMonthStr] = today.split('-')
|
||||||
|
const isAtEarliestMonth = view.year === Number(todayYear) && view.month === Number(todayMonthStr) - 1
|
||||||
|
const isAtLatestMonth = (() => {
|
||||||
|
if (latestDate === null) return false
|
||||||
|
const [latestYear, latestMonthStr] = latestDate.split('-')
|
||||||
|
return view.year === Number(latestYear) && view.month === Number(latestMonthStr) - 1
|
||||||
|
})()
|
||||||
|
|
||||||
|
function goToMonth(offset: number) {
|
||||||
|
setView((v) => {
|
||||||
|
const total = v.year * 12 + v.month + offset
|
||||||
|
return { year: Math.floor(total / 12), month: ((total % 12) + 12) % 12 }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="datetime-field">
|
||||||
|
<div className="date-input-group" ref={containerRef}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="TT.MM.JJJJ"
|
||||||
|
pattern="\d{2}\.\d{2}\.\d{4}"
|
||||||
|
maxLength={10}
|
||||||
|
value={dateText}
|
||||||
|
onChange={(e) => handleTextChange(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="date-picker-trigger"
|
||||||
|
aria-label="Kalender öffnen"
|
||||||
|
aria-expanded={isCalendarOpen}
|
||||||
|
onClick={() => setIsCalendarOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<CalendarIcon />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isCalendarOpen && (
|
||||||
|
<div className="date-popover-backdrop" onClick={() => setIsCalendarOpen(false)}>
|
||||||
|
<div
|
||||||
|
className="date-popover"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Datum auswählen"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="date-popover-header">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Vorheriger Monat"
|
||||||
|
disabled={isAtEarliestMonth}
|
||||||
|
onClick={() => goToMonth(-1)}
|
||||||
|
>
|
||||||
|
‹
|
||||||
|
</button>
|
||||||
|
<span>{monthLabel}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label="Nächster Monat"
|
||||||
|
disabled={isAtLatestMonth}
|
||||||
|
onClick={() => goToMonth(1)}
|
||||||
|
>
|
||||||
|
›
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="date-popover-weekdays">
|
||||||
|
{WEEKDAY_HEADER_LABELS.map((label) => (
|
||||||
|
<span key={label}>{label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="date-popover-grid">
|
||||||
|
{getMonthGrid(view.year, view.month)
|
||||||
|
.flat()
|
||||||
|
.map((cell, index) => (
|
||||||
|
<button
|
||||||
|
key={cell?.dateStr ?? `blank-${index}`}
|
||||||
|
type="button"
|
||||||
|
className={cell?.dateStr === date ? 'is-selected' : undefined}
|
||||||
|
disabled={!cell || !isSelectableDate(cell.dateStr)}
|
||||||
|
onClick={() => cell && handleDayPick(cell.dateStr)}
|
||||||
|
>
|
||||||
|
{cell?.day ?? ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={time}
|
||||||
|
onChange={(e) => handleTimeChange(e.target.value)}
|
||||||
|
disabled={availableTimeOptions.length === 0}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{timePlaceholder}
|
||||||
|
</option>
|
||||||
|
{timeOptions.map((option) => (
|
||||||
|
<option key={option} value={option} disabled={bookedTimesSet.has(option)}>
|
||||||
|
{option}
|
||||||
|
{bookedTimesSet.has(option) ? ' (belegt)' : ''}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,28 +3,14 @@ import { Link } from '@tanstack/react-router'
|
|||||||
export function SiteHeader() {
|
export function SiteHeader() {
|
||||||
return (
|
return (
|
||||||
<header className="site-header">
|
<header className="site-header">
|
||||||
|
<div className="site-header-inner">
|
||||||
<Link className="wordmark" to="/">
|
<Link className="wordmark" to="/">
|
||||||
Steakhaus <span>bei Mirko</span>
|
Steakhaus <span>bei Mirko</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav>
|
<Link className="staff-login-link" to="/staff/login">
|
||||||
<ul className="site-nav">
|
Mitarbeiter-Login
|
||||||
<li>
|
|
||||||
<Link to="/" hash="reservieren">
|
|
||||||
Reservieren
|
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</div>
|
||||||
<li>
|
|
||||||
<Link to="/" hash="oeffnungszeiten">
|
|
||||||
Öffnungszeiten
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<Link to="/" hash="kontakt">
|
|
||||||
Kontakt
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</header>
|
</header>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
role?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StaffTabs({ role }: Props) {
|
||||||
|
return (
|
||||||
|
<nav className="staff-tabs" aria-label="Bereich">
|
||||||
|
<Link
|
||||||
|
className="staff-tab"
|
||||||
|
to="/staff"
|
||||||
|
activeOptions={{ exact: true }}
|
||||||
|
activeProps={{ className: 'is-active' }}
|
||||||
|
viewTransition
|
||||||
|
>
|
||||||
|
Reservierungen
|
||||||
|
</Link>
|
||||||
|
{role === 'admin' && (
|
||||||
|
<Link
|
||||||
|
className="staff-tab"
|
||||||
|
to="/staff/tables"
|
||||||
|
activeProps={{ className: 'is-active' }}
|
||||||
|
viewTransition
|
||||||
|
>
|
||||||
|
Tische
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{role === 'admin' && (
|
||||||
|
<Link
|
||||||
|
className="staff-tab"
|
||||||
|
to="/staff/team"
|
||||||
|
activeProps={{ className: 'is-active' }}
|
||||||
|
viewTransition
|
||||||
|
>
|
||||||
|
Mitarbeiter
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{role === 'admin' && (
|
||||||
|
<Link
|
||||||
|
className="staff-tab"
|
||||||
|
to="/staff/settings"
|
||||||
|
activeProps={{ className: 'is-active' }}
|
||||||
|
viewTransition
|
||||||
|
>
|
||||||
|
Einstellungen
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+55
-1
@@ -1,9 +1,24 @@
|
|||||||
import { boolean, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
|
import { sql } from 'drizzle-orm'
|
||||||
|
import { boolean, doublePrecision, integer, pgTable, serial, text, timestamp, unique } from 'drizzle-orm/pg-core'
|
||||||
|
|
||||||
export const restaurantTables = pgTable('restaurant_tables', {
|
export const restaurantTables = pgTable('restaurant_tables', {
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
name: text('name').notNull(),
|
name: text('name').notNull(),
|
||||||
seats: integer('seats').notNull(),
|
seats: integer('seats').notNull(),
|
||||||
|
// The number shown to guests (on the table's marker, etc.) — distinct from
|
||||||
|
// `name`, which can be a description like "Eck Tisch". Nullable so existing
|
||||||
|
// rows created before this field don't need a value until staff set one.
|
||||||
|
tableNumber: integer('table_number'),
|
||||||
|
// A data: URL of the uploaded photo, stored inline rather than as a file on disk —
|
||||||
|
// there's no separate object storage in this app, and this keeps the image tied
|
||||||
|
// to the row with no extra infra. Null until staff upload one.
|
||||||
|
imageUrl: text('image_url'),
|
||||||
|
// Where this table's pin sits on the settings overview photo, as a percentage
|
||||||
|
// (0-100) of that image's width/height — resolution-independent, so the same
|
||||||
|
// marker lines up whether it's rendered in the admin editor or on the public
|
||||||
|
// booking page. Both null until staff place one; always set together.
|
||||||
|
markerX: doublePrecision('marker_x'),
|
||||||
|
markerY: doublePrecision('marker_y'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const reservations = pgTable(
|
export const reservations = pgTable(
|
||||||
@@ -15,13 +30,48 @@ export const reservations = pgTable(
|
|||||||
.references(() => restaurantTables.id, { onDelete: 'cascade' }),
|
.references(() => restaurantTables.id, { onDelete: 'cascade' }),
|
||||||
customerName: text('customer_name').notNull(),
|
customerName: text('customer_name').notNull(),
|
||||||
customerEmail: text('customer_email').notNull(),
|
customerEmail: text('customer_email').notNull(),
|
||||||
|
// Nullable so existing rows created before this field don't need a value —
|
||||||
|
// required going forward by the create/update zod schema instead.
|
||||||
|
customerPhone: text('customer_phone'),
|
||||||
|
// An optional free-text note from whoever booked — allergies, a birthday, a
|
||||||
|
// seating preference. Null rather than '' when nothing was entered.
|
||||||
|
message: text('message'),
|
||||||
partySize: integer('party_size').notNull(),
|
partySize: integer('party_size').notNull(),
|
||||||
|
// 'confirmed' | 'no_show' | 'completed' — an app-level enum (validated by the
|
||||||
|
// zod schema in lib/reservations.ts, not a pg enum), matching the pattern
|
||||||
|
// already used for restaurant_settings.table_display_mode. Every reservation
|
||||||
|
// is confirmed at booking time, so that's the default; staff mark the other
|
||||||
|
// two afterward. Cancelling still deletes the row (and emails the guest) —
|
||||||
|
// this doesn't introduce a "cancelled" status.
|
||||||
|
status: text('status').notNull().default('confirmed'),
|
||||||
reservationTime: timestamp('reservation_time', { withTimezone: true }).notNull(),
|
reservationTime: timestamp('reservation_time', { withTimezone: true }).notNull(),
|
||||||
|
// Captured from the reservation-duration setting at booking time, so a later
|
||||||
|
// change to that setting doesn't retroactively change how long past bookings
|
||||||
|
// are considered to occupy their table.
|
||||||
|
durationMinutes: integer('duration_minutes').notNull().default(90),
|
||||||
|
cancelToken: text('cancel_token')
|
||||||
|
.notNull()
|
||||||
|
.unique()
|
||||||
|
.default(sql`gen_random_uuid()`),
|
||||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => [unique().on(table.tableId, table.reservationTime)],
|
(table) => [unique().on(table.tableId, table.reservationTime)],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Single-row table of restaurant-wide booking rules, editable by admins.
|
||||||
|
export const restaurantSettings = pgTable('restaurant_settings', {
|
||||||
|
id: serial('id').primaryKey(),
|
||||||
|
reservationIntervalMinutes: integer('reservation_interval_minutes').notNull().default(15),
|
||||||
|
reservationDurationMinutes: integer('reservation_duration_minutes').notNull().default(90),
|
||||||
|
minLeadTimeMinutes: integer('min_lead_time_minutes').notNull().default(60),
|
||||||
|
maxLeadTimeDays: integer('max_lead_time_days').notNull().default(60),
|
||||||
|
// Whether the booking form shows a photo of the selected table ('individual') or
|
||||||
|
// a single always-visible photo of the whole room ('overview') — an app-level
|
||||||
|
// enum, validated by the zod schema in lib/settings.ts rather than a pg enum.
|
||||||
|
tableDisplayMode: text('table_display_mode').notNull().default('individual'),
|
||||||
|
tableOverviewImageUrl: text('table_overview_image_url'),
|
||||||
|
})
|
||||||
|
|
||||||
// Better Auth's standard schema (staff accounts) — see https://www.better-auth.com/docs/concepts/database
|
// Better Auth's standard schema (staff accounts) — see https://www.better-auth.com/docs/concepts/database
|
||||||
// `role`/`banned`/`banReason`/`banExpires` and `session.impersonatedBy` are added by the `admin` plugin.
|
// `role`/`banned`/`banReason`/`banExpires` and `session.impersonatedBy` are added by the `admin` plugin.
|
||||||
export const user = pgTable('user', {
|
export const user = pgTable('user', {
|
||||||
@@ -36,6 +86,9 @@ export const user = pgTable('user', {
|
|||||||
banned: boolean('banned'),
|
banned: boolean('banned'),
|
||||||
banReason: text('ban_reason'),
|
banReason: text('ban_reason'),
|
||||||
banExpires: timestamp('ban_expires'),
|
banExpires: timestamp('ban_expires'),
|
||||||
|
// Registered with Better Auth as a `user.additionalFields` entry in
|
||||||
|
// auth-instance.ts — not part of its base schema, same as `role`/`banned` above.
|
||||||
|
phone: text('phone'),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const session = pgTable('session', {
|
export const session = pgTable('session', {
|
||||||
@@ -82,3 +135,4 @@ export const verification = pgTable('verification', {
|
|||||||
export type RestaurantTable = typeof restaurantTables.$inferSelect
|
export type RestaurantTable = typeof restaurantTables.$inferSelect
|
||||||
export type Reservation = typeof reservations.$inferSelect
|
export type Reservation = typeof reservations.$inferSelect
|
||||||
export type NewReservation = typeof reservations.$inferInsert
|
export type NewReservation = typeof reservations.$inferInsert
|
||||||
|
export type RestaurantSettings = typeof restaurantSettings.$inferSelect
|
||||||
|
|||||||
@@ -24,6 +24,19 @@ function createAuth() {
|
|||||||
// Staff accounts are provisioned via the seed script, not public self sign-up.
|
// Staff accounts are provisioned via the seed script, not public self sign-up.
|
||||||
disableSignUp: true,
|
disableSignUp: true,
|
||||||
},
|
},
|
||||||
|
user: {
|
||||||
|
additionalFields: {
|
||||||
|
// `required` here only governs Better Auth's own self-service sign-up/
|
||||||
|
// update-user flows, which this app doesn't use for staff — accounts go
|
||||||
|
// through admin.createUser, whose `data` field isn't schema-validated by
|
||||||
|
// Better Auth, so the actual "required" enforcement lives in the staff
|
||||||
|
// team UI (staff.team.tsx) instead.
|
||||||
|
phone: {
|
||||||
|
type: 'string',
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
session: {
|
session: {
|
||||||
expiresIn: 60 * 60 * 12,
|
expiresIn: 60 * 60 * 12,
|
||||||
},
|
},
|
||||||
|
|||||||
+8
-3
@@ -1,10 +1,15 @@
|
|||||||
import { createServerFn } from '@tanstack/react-start'
|
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
|
||||||
import { getRequest } from '@tanstack/react-start/server'
|
import { getRequest } from '@tanstack/react-start/server'
|
||||||
|
|
||||||
export async function getStaffSession() {
|
// `staff.index.tsx` and friends import `getCurrentStaffUser` from this module for
|
||||||
|
// their `beforeLoad` checks, which pulls the whole file into the client bundle too.
|
||||||
|
// `createServerOnlyFn` tells the compiler this closure (and the server-only
|
||||||
|
// `getRequest` import it uses) can be stripped from that client bundle instead of
|
||||||
|
// dragging the Postgres driver into the browser.
|
||||||
|
export const getStaffSession = createServerOnlyFn(async function getStaffSession() {
|
||||||
const { getAuth } = await import('#/lib/auth-instance')
|
const { getAuth } = await import('#/lib/auth-instance')
|
||||||
return getAuth().api.getSession({ headers: getRequest().headers })
|
return getAuth().api.getSession({ headers: getRequest().headers })
|
||||||
}
|
})
|
||||||
|
|
||||||
export async function requireStaffSession() {
|
export async function requireStaffSession() {
|
||||||
const session = await getStaffSession()
|
const session = await getStaffSession()
|
||||||
|
|||||||
+6
-1
@@ -1,4 +1,9 @@
|
|||||||
|
export function formatDateOnly(date: Date) {
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
export function formatDateTimeLocal(date: Date) {
|
export function formatDateTimeLocal(date: Date) {
|
||||||
const pad = (n: number) => String(n).padStart(2, '0')
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
return `${formatDateOnly(date)}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,275 @@
|
|||||||
|
import { Resend } from 'resend'
|
||||||
|
|
||||||
|
import type { RestaurantTable } from '#/db/schema'
|
||||||
|
|
||||||
|
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
dateStyle: 'full',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
bg: '#17120f',
|
||||||
|
bgRaised: '#1e1712',
|
||||||
|
line: '#3a2f26',
|
||||||
|
text: '#f3ead9',
|
||||||
|
textMuted: '#c9baa3',
|
||||||
|
gold: '#c9a15a',
|
||||||
|
goldBright: '#e0bb78',
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(value: string) {
|
||||||
|
return value
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll("'", ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
let resendClient: Resend | undefined
|
||||||
|
|
||||||
|
function getResendClient() {
|
||||||
|
const apiKey = process.env.RESEND_API_KEY
|
||||||
|
|
||||||
|
if (!apiKey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
resendClient ??= new Resend(apiKey)
|
||||||
|
return resendClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReservationEmailInfo = {
|
||||||
|
customerName: string
|
||||||
|
customerEmail: string
|
||||||
|
message?: string | null
|
||||||
|
partySize: number
|
||||||
|
reservationTime: Date
|
||||||
|
cancelToken: string
|
||||||
|
table: Pick<RestaurantTable, 'name'>
|
||||||
|
}
|
||||||
|
|
||||||
|
const CONTACT_EMAIL = 'info@steakhaus-meppen.de'
|
||||||
|
|
||||||
|
function getSiteUrl() {
|
||||||
|
return process.env.BETTER_AUTH_URL ?? 'http://localhost:3000'
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildCancelUrl(cancelToken: string) {
|
||||||
|
return `${getSiteUrl()}/stornieren/${cancelToken}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReservationEmailHtml(
|
||||||
|
heading: string,
|
||||||
|
intro: string,
|
||||||
|
reservation: ReservationEmailInfo,
|
||||||
|
showActions: boolean,
|
||||||
|
) {
|
||||||
|
const formattedTime = dateTimeFormatter.format(reservation.reservationTime)
|
||||||
|
const name = escapeHtml(reservation.customerName)
|
||||||
|
const tableName = escapeHtml(reservation.table.name)
|
||||||
|
const cancelUrl = buildCancelUrl(reservation.cancelToken)
|
||||||
|
|
||||||
|
const row = (label: string, value: string) => `
|
||||||
|
<tr>
|
||||||
|
<td style="padding:10px 0;border-top:1px solid ${COLORS.line};color:${COLORS.textMuted};font:400 14px/1.4 'Helvetica Neue',Arial,sans-serif;letter-spacing:0.03em;text-transform:uppercase;white-space:nowrap;">
|
||||||
|
${label}
|
||||||
|
</td>
|
||||||
|
<td style="padding:10px 0;border-top:1px solid ${COLORS.line};color:${COLORS.text};font:600 15px/1.4 Georgia,'Times New Roman',serif;text-align:right;">
|
||||||
|
${value}
|
||||||
|
</td>
|
||||||
|
</tr>`
|
||||||
|
|
||||||
|
return `
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>${heading}</title>
|
||||||
|
</head>
|
||||||
|
<body style="margin:0;padding:32px 16px;background:${COLORS.bg};font:400 16px/1.5 'Helvetica Neue',Arial,sans-serif;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="max-width:480px;">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom:28px;">
|
||||||
|
<span style="font:600 20px/1.3 Georgia,'Times New Roman',serif;letter-spacing:0.03em;color:${COLORS.text};">
|
||||||
|
Steakhaus <span style="color:${COLORS.goldBright};">bei Mirko</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="background:${COLORS.bgRaised};border:1px solid ${COLORS.line};border-radius:10px;padding:32px;">
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-bottom:20px;">
|
||||||
|
<span style="display:inline-block;width:36px;height:2px;background:${COLORS.gold};"></span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="font:600 22px/1.3 Georgia,'Times New Roman',serif;color:${COLORS.goldBright};padding-bottom:12px;">
|
||||||
|
${heading}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:${COLORS.textMuted};font-size:15px;padding-bottom:8px;">
|
||||||
|
Hallo ${name},
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="color:${COLORS.textMuted};font-size:15px;padding-bottom:20px;">
|
||||||
|
${intro}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||||
|
${row('Tisch', tableName)}
|
||||||
|
${row('Datum & Uhrzeit', formattedTime)}
|
||||||
|
${row('Personen', String(reservation.partySize))}
|
||||||
|
</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
|
||||||
|
? `
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-top:28px;">
|
||||||
|
<a href="${cancelUrl}" style="display:inline-block;padding:0.7rem 1.4rem;border:1px solid ${COLORS.line};border-radius:4px;color:${COLORS.text};font:600 13px/1 'Helvetica Neue',Arial,sans-serif;text-decoration:none;">
|
||||||
|
Reservierung stornieren
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-top:16px;color:${COLORS.textMuted};font-size:13px;">
|
||||||
|
Möchten Sie etwas ändern? Schreiben Sie uns einfach eine E-Mail an
|
||||||
|
<a href="mailto:${CONTACT_EMAIL}" style="color:${COLORS.goldBright};">${CONTACT_EMAIL}</a>.
|
||||||
|
</td>
|
||||||
|
</tr>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-top:28px;color:${COLORS.textMuted};font-size:13px;">
|
||||||
|
Wir freuen uns auf Sie!
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td align="center" style="padding-top:16px;color:${COLORS.textMuted};font-size:12px;">
|
||||||
|
Meppener Straße 8 · 49716 Meppen · 05931 - 12750
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderReservationEmailText(
|
||||||
|
heading: string,
|
||||||
|
intro: string,
|
||||||
|
reservation: ReservationEmailInfo,
|
||||||
|
showActions: boolean,
|
||||||
|
) {
|
||||||
|
const formattedTime = dateTimeFormatter.format(reservation.reservationTime)
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Steakhaus bei Mirko',
|
||||||
|
'',
|
||||||
|
heading,
|
||||||
|
'',
|
||||||
|
`Hallo ${reservation.customerName},`,
|
||||||
|
'',
|
||||||
|
intro,
|
||||||
|
'',
|
||||||
|
`Tisch: ${reservation.table.name}`,
|
||||||
|
`Datum & Uhrzeit: ${formattedTime}`,
|
||||||
|
`Personen: ${reservation.partySize}`,
|
||||||
|
...(reservation.message ? [`Nachricht: ${reservation.message}`] : []),
|
||||||
|
'',
|
||||||
|
...(showActions
|
||||||
|
? [
|
||||||
|
`Reservierung stornieren: ${buildCancelUrl(reservation.cancelToken)}`,
|
||||||
|
`Möchten Sie etwas ändern? Schreiben Sie uns eine E-Mail an ${CONTACT_EMAIL}.`,
|
||||||
|
'',
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
'Wir freuen uns auf Sie!',
|
||||||
|
'Steakhaus bei Mirko',
|
||||||
|
'Meppener Straße 8 · 49716 Meppen · 05931 - 12750',
|
||||||
|
].join('\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendReservationEmail(
|
||||||
|
subject: string,
|
||||||
|
intro: string,
|
||||||
|
reservation: ReservationEmailInfo,
|
||||||
|
showActions: boolean,
|
||||||
|
) {
|
||||||
|
const client = getResendClient()
|
||||||
|
|
||||||
|
if (!client) {
|
||||||
|
console.warn('RESEND_API_KEY is not set — skipping reservation email')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromAddress = process.env.RESERVATIONS_FROM_EMAIL ?? 'Steakhaus bei Mirko <reservierungen@steakhaus-meppen.de>'
|
||||||
|
|
||||||
|
try {
|
||||||
|
await client.emails.send({
|
||||||
|
from: fromAddress,
|
||||||
|
to: reservation.customerEmail,
|
||||||
|
subject,
|
||||||
|
html: renderReservationEmailHtml(subject, intro, reservation, showActions),
|
||||||
|
text: renderReservationEmailText(subject, intro, reservation, showActions),
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send reservation email', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendReservationConfirmationEmail(reservation: ReservationEmailInfo) {
|
||||||
|
return sendReservationEmail(
|
||||||
|
'Ihre Reservierung ist bestätigt',
|
||||||
|
'vielen Dank für Ihre Reservierung. Hier sind die Details:',
|
||||||
|
reservation,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendReservationUpdateEmail(reservation: ReservationEmailInfo) {
|
||||||
|
return sendReservationEmail(
|
||||||
|
'Ihre Reservierung wurde geändert',
|
||||||
|
'Ihre Reservierung wurde aktualisiert. Hier sind die neuen Details:',
|
||||||
|
reservation,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sendReservationCancellationEmail(reservation: ReservationEmailInfo) {
|
||||||
|
return sendReservationEmail(
|
||||||
|
'Ihre Reservierung wurde storniert',
|
||||||
|
'Ihre folgende Reservierung wurde storniert:',
|
||||||
|
reservation,
|
||||||
|
false,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { formatDateOnly } from '#/lib/datetime'
|
||||||
|
|
||||||
|
export const RESTAURANT_TIME_ZONE = 'Europe/Berlin'
|
||||||
|
export const RESERVATION_INTERVAL_MINUTES = 15
|
||||||
|
|
||||||
|
export type OpeningRange = { start: string; end: string }
|
||||||
|
|
||||||
|
// Keyed by the JS `Date#getDay()` convention: 0 = Sunday … 6 = Saturday.
|
||||||
|
const OPENING_HOURS_BY_WEEKDAY: Record<number, OpeningRange[]> = {
|
||||||
|
0: [
|
||||||
|
{ start: '11:30', end: '13:30' },
|
||||||
|
{ start: '17:00', end: '22:00' },
|
||||||
|
],
|
||||||
|
1: [],
|
||||||
|
2: [{ start: '17:00', end: '21:30' }],
|
||||||
|
3: [{ start: '17:00', end: '21:30' }],
|
||||||
|
4: [{ start: '17:00', end: '21:30' }],
|
||||||
|
5: [{ start: '17:00', end: '22:00' }],
|
||||||
|
6: [{ start: '17:00', end: '22:00' }],
|
||||||
|
}
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS_MONDAY_FIRST: Array<{ day: string; weekday: number }> = [
|
||||||
|
{ day: 'Montag', weekday: 1 },
|
||||||
|
{ day: 'Dienstag', weekday: 2 },
|
||||||
|
{ day: 'Mittwoch', weekday: 3 },
|
||||||
|
{ day: 'Donnerstag', weekday: 4 },
|
||||||
|
{ day: 'Freitag', weekday: 5 },
|
||||||
|
{ day: 'Samstag', weekday: 6 },
|
||||||
|
{ day: 'Sonntag', weekday: 0 },
|
||||||
|
]
|
||||||
|
|
||||||
|
const WEEKDAY_SHORT_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
||||||
|
|
||||||
|
const berlinPartsFormatter = new Intl.DateTimeFormat('en-US', {
|
||||||
|
timeZone: RESTAURANT_TIME_ZONE,
|
||||||
|
weekday: 'short',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
})
|
||||||
|
|
||||||
|
function toMinutes(time: string) {
|
||||||
|
const [hours, minutes] = time.split(':').map(Number)
|
||||||
|
return hours * 60 + minutes
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBerlinTimeParts(date: Date) {
|
||||||
|
const parts = berlinPartsFormatter.formatToParts(date)
|
||||||
|
const byType = Object.fromEntries(parts.map((part) => [part.type, part.value]))
|
||||||
|
return {
|
||||||
|
weekday: WEEKDAY_SHORT_NAMES.indexOf(byType.weekday),
|
||||||
|
minutesSinceMidnight: Number(byType.hour) * 60 + Number(byType.minute),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isOnReservationInterval(date: Date, intervalMinutes: number = RESERVATION_INTERVAL_MINUTES) {
|
||||||
|
return date.getUTCMinutes() % intervalMinutes === 0 && date.getUTCSeconds() === 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// `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 + durationMinutes <= toMinutes(range.end),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getOpeningHoursForDisplay() {
|
||||||
|
return WEEKDAY_LABELS_MONDAY_FIRST.map(({ day, weekday }) => {
|
||||||
|
const ranges = OPENING_HOURS_BY_WEEKDAY[weekday]
|
||||||
|
return {
|
||||||
|
day,
|
||||||
|
hours: ranges.length ? ranges.map((range) => `${range.start} – ${range.end}`) : null,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTodayLabelBerlin() {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', { timeZone: RESTAURANT_TIME_ZONE, weekday: 'long' }).format(new Date())
|
||||||
|
}
|
||||||
|
|
||||||
|
// The functions below drive the customer-facing date/time picker. They work with the
|
||||||
|
// browser's local time (not RESTAURANT_TIME_ZONE) since visitors are assumed to be
|
||||||
|
// browsing from the restaurant's own timezone — same assumption the rest of the
|
||||||
|
// booking form already makes when it builds a Date from an unqualified local string.
|
||||||
|
|
||||||
|
export function getOpeningRangesForWeekday(weekday: number): OpeningRange[] {
|
||||||
|
return OPENING_HOURS_BY_WEEKDAY[weekday] ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
// `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[] = []
|
||||||
|
|
||||||
|
for (const range of ranges) {
|
||||||
|
const startMinutes = toMinutes(range.start)
|
||||||
|
const endMinutes = toMinutes(range.end)
|
||||||
|
|
||||||
|
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')}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextAvailableSlot(
|
||||||
|
from: Date,
|
||||||
|
intervalMinutes: number = RESERVATION_INTERVAL_MINUTES,
|
||||||
|
durationMinutes: number = intervalMinutes,
|
||||||
|
): { date: string; time: string } | null {
|
||||||
|
for (let dayOffset = 0; dayOffset < 14; dayOffset++) {
|
||||||
|
const day = new Date(from)
|
||||||
|
day.setDate(from.getDate() + dayOffset)
|
||||||
|
|
||||||
|
const dateStr = formatDateOnly(day)
|
||||||
|
const options = generateTimeOptions(getOpeningRangesForWeekday(day.getDay()), intervalMinutes, durationMinutes)
|
||||||
|
|
||||||
|
for (const time of options) {
|
||||||
|
const slot = new Date(`${dateStr}T${time}`)
|
||||||
|
if (slot.getTime() >= from.getTime()) {
|
||||||
|
return { date: dateStr, time }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -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')
|
||||||
+376
-24
@@ -1,15 +1,205 @@
|
|||||||
import { createServerFn } from '@tanstack/react-start'
|
import { createServerFn } from '@tanstack/react-start'
|
||||||
import { and, eq, ne } from 'drizzle-orm'
|
import { eq } from 'drizzle-orm'
|
||||||
import { z } from 'zod'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { db } from '#/db'
|
import { db } from '#/db'
|
||||||
import { reservations, restaurantTables } from '#/db/schema'
|
import { reservations, restaurantTables } from '#/db/schema'
|
||||||
import { requireStaffSession } from '#/lib/auth'
|
import { requireStaffSession } from '#/lib/auth'
|
||||||
|
import {
|
||||||
|
sendReservationCancellationEmail,
|
||||||
|
sendReservationConfirmationEmail,
|
||||||
|
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) {
|
||||||
|
const hours = minutes / 60
|
||||||
|
return `${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`
|
||||||
|
}
|
||||||
|
return `${minutes} Minuten`
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidReservationSlot(
|
||||||
|
reservationTime: Date,
|
||||||
|
intervalMinutes: number,
|
||||||
|
minLeadTimeMinutes: number,
|
||||||
|
maxLeadTimeDays: number,
|
||||||
|
durationMinutes: number,
|
||||||
|
) {
|
||||||
|
const earliestAllowed = Date.now() + minLeadTimeMinutes * 60_000
|
||||||
|
|
||||||
|
if (reservationTime.getTime() < earliestAllowed) {
|
||||||
|
throw new Error(
|
||||||
|
minLeadTimeMinutes > 0
|
||||||
|
? `Reservierungen müssen mindestens ${formatLeadTime(minLeadTimeMinutes)} im Voraus gebucht werden`
|
||||||
|
: 'Die Reservierungszeit muss in der Zukunft liegen',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const latestAllowed = Date.now() + maxLeadTimeDays * 24 * 60 * 60_000
|
||||||
|
|
||||||
|
if (reservationTime.getTime() > latestAllowed) {
|
||||||
|
throw new Error(`Reservierungen sind höchstens ${maxLeadTimeDays} Tage im Voraus möglich`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOnReservationInterval(reservationTime, intervalMinutes)) {
|
||||||
|
throw new Error(`Reservierungen sind nur im ${intervalMinutes}-Minuten-Raster möglich`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isWithinOpeningHours(reservationTime, 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. `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() - bufferMs
|
||||||
|
const existingEnd = existingStart + r.durationMinutes * 60_000 + 2 * bufferMs
|
||||||
|
return existingStart < end && start.getTime() < existingEnd
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
export const getTables = createServerFn({ method: 'GET' }).handler(async () => {
|
export const getTables = createServerFn({ method: 'GET' }).handler(async () => {
|
||||||
return db.select().from(restaurantTables).orderBy(restaurantTables.name)
|
return db.select().from(restaurantTables).orderBy(restaurantTables.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const tableSchema = z.object({
|
||||||
|
name: z.string().trim().min(1, 'Name ist erforderlich').max(120),
|
||||||
|
tableNumber: z.number().int().positive('Tischnummer muss positiv sein'),
|
||||||
|
seats: z.number().int().min(1, 'Mindestens 1 Platz').max(50),
|
||||||
|
imageUrl: dataUrlImageSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const createTable = createServerFn({ method: 'POST' })
|
||||||
|
.validator(zodValidator(tableSchema))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const session = await requireStaffSession()
|
||||||
|
if (session.user.role !== 'admin') {
|
||||||
|
throw new Error('Nur Admins dürfen Tische verwalten')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [table] = await db.insert(restaurantTables).values(data).returning()
|
||||||
|
return table
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateTableSchema = tableSchema.extend({
|
||||||
|
id: z.number().int().positive(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateTable = createServerFn({ method: 'POST' })
|
||||||
|
.validator(zodValidator(updateTableSchema))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const session = await requireStaffSession()
|
||||||
|
if (session.user.role !== 'admin') {
|
||||||
|
throw new Error('Nur Admins dürfen Tische verwalten')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(restaurantTables)
|
||||||
|
.set({ name: data.name, tableNumber: data.tableNumber, seats: data.seats, imageUrl: data.imageUrl })
|
||||||
|
.where(eq(restaurantTables.id, data.id))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Tisch wurde nicht gefunden')
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
const deleteTableSchema = z.object({
|
||||||
|
id: z.number().int().positive(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const deleteTable = createServerFn({ method: 'POST' })
|
||||||
|
.validator(zodValidator(deleteTableSchema))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const session = await requireStaffSession()
|
||||||
|
if (session.user.role !== 'admin') {
|
||||||
|
throw new Error('Nur Admins dürfen Tische verwalten')
|
||||||
|
}
|
||||||
|
|
||||||
|
// The FK from reservations to this table cascades on delete, which would
|
||||||
|
// silently wipe out (and never notify) any guest booked on it. Deleting
|
||||||
|
// reservations individually first goes through deleteReservation, which
|
||||||
|
// does send a cancellation email.
|
||||||
|
const [existingReservation] = await db
|
||||||
|
.select({ id: reservations.id })
|
||||||
|
.from(reservations)
|
||||||
|
.where(eq(reservations.tableId, data.id))
|
||||||
|
.limit(1)
|
||||||
|
|
||||||
|
if (existingReservation) {
|
||||||
|
throw new Error(
|
||||||
|
'Tisch kann nicht gelöscht werden, solange Reservierungen dafür bestehen — bitte zuerst verschieben oder löschen',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const [deleted] = await db.delete(restaurantTables).where(eq(restaurantTables.id, data.id)).returning()
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new Error('Tisch wurde nicht gefunden')
|
||||||
|
}
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
})
|
||||||
|
|
||||||
|
const setTableMarkerSchema = z.object({
|
||||||
|
id: z.number().int().positive(),
|
||||||
|
// Both coordinates set together (a marker) or both cleared (none) — there's no
|
||||||
|
// meaningful state for only one of them being present.
|
||||||
|
marker: z
|
||||||
|
.object({
|
||||||
|
x: z.number().min(0).max(100),
|
||||||
|
y: z.number().min(0).max(100),
|
||||||
|
})
|
||||||
|
.nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const setTableMarker = createServerFn({ method: 'POST' })
|
||||||
|
.validator(zodValidator(setTableMarkerSchema))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const session = await requireStaffSession()
|
||||||
|
if (session.user.role !== 'admin') {
|
||||||
|
throw new Error('Nur Admins dürfen Tische verwalten')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(restaurantTables)
|
||||||
|
.set({ markerX: data.marker?.x ?? null, markerY: data.marker?.y ?? null })
|
||||||
|
.where(eq(restaurantTables.id, data.id))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!updated) {
|
||||||
|
throw new Error('Tisch wurde nicht gefunden')
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
export const getReservations = createServerFn({ method: 'GET' }).handler(async () => {
|
export const getReservations = createServerFn({ method: 'GET' }).handler(async () => {
|
||||||
await requireStaffSession()
|
await requireStaffSession()
|
||||||
|
|
||||||
@@ -18,20 +208,45 @@ export const getReservations = createServerFn({ method: 'GET' }).handler(async (
|
|||||||
id: reservations.id,
|
id: reservations.id,
|
||||||
tableId: reservations.tableId,
|
tableId: reservations.tableId,
|
||||||
tableName: restaurantTables.name,
|
tableName: restaurantTables.name,
|
||||||
|
tableNumber: restaurantTables.tableNumber,
|
||||||
customerName: reservations.customerName,
|
customerName: reservations.customerName,
|
||||||
customerEmail: reservations.customerEmail,
|
customerEmail: reservations.customerEmail,
|
||||||
|
customerPhone: reservations.customerPhone,
|
||||||
|
message: reservations.message,
|
||||||
partySize: reservations.partySize,
|
partySize: reservations.partySize,
|
||||||
|
status: reservations.status,
|
||||||
reservationTime: reservations.reservationTime,
|
reservationTime: reservations.reservationTime,
|
||||||
|
durationMinutes: reservations.durationMinutes,
|
||||||
})
|
})
|
||||||
.from(reservations)
|
.from(reservations)
|
||||||
.innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id))
|
.innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id))
|
||||||
.orderBy(reservations.reservationTime)
|
.orderBy(reservations.reservationTime)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Unauthenticated — powers the public booking form's time picker, so it only
|
||||||
|
// exposes what's needed to block already-taken slots (table + time span), never
|
||||||
|
// any customer data.
|
||||||
|
export const getReservationAvailability = createServerFn({ method: 'GET' }).handler(async () => {
|
||||||
|
return db
|
||||||
|
.select({
|
||||||
|
tableId: reservations.tableId,
|
||||||
|
reservationTime: reservations.reservationTime,
|
||||||
|
durationMinutes: reservations.durationMinutes,
|
||||||
|
})
|
||||||
|
.from(reservations)
|
||||||
|
})
|
||||||
|
|
||||||
const createReservationSchema = z.object({
|
const createReservationSchema = z.object({
|
||||||
tableId: z.number().int().positive(),
|
tableId: z.number().int().positive(),
|
||||||
customerName: z.string().trim().min(1, 'Name is required').max(120),
|
customerName: z.string().trim().min(1, 'Name is required').max(120),
|
||||||
customerEmail: z.string().trim().email('Enter a valid email'),
|
customerEmail: z.string().trim().email('Enter a valid email'),
|
||||||
|
customerPhone: phoneSchema,
|
||||||
|
message: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(500, 'Message must be at most 500 characters')
|
||||||
|
.optional()
|
||||||
|
.transform((value) => value || null),
|
||||||
partySize: z.number().int().min(1, 'At least 1 guest').max(20),
|
partySize: z.number().int().min(1, 'At least 1 guest').max(20),
|
||||||
reservationTime: z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
|
reservationTime: z.string().refine((value) => !Number.isNaN(Date.parse(value)), {
|
||||||
message: 'Enter a valid date and time',
|
message: 'Enter a valid date and time',
|
||||||
@@ -39,13 +254,18 @@ const createReservationSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const createReservation = createServerFn({ method: 'POST' })
|
export const createReservation = createServerFn({ method: 'POST' })
|
||||||
.validator(createReservationSchema)
|
.validator(zodValidator(createReservationSchema))
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
|
const settings = await loadReservationSettings()
|
||||||
const reservationTime = new Date(data.reservationTime)
|
const reservationTime = new Date(data.reservationTime)
|
||||||
|
|
||||||
if (reservationTime.getTime() < Date.now()) {
|
assertValidReservationSlot(
|
||||||
throw new Error('Reservation time must be in the future')
|
reservationTime,
|
||||||
}
|
settings.reservationIntervalMinutes,
|
||||||
|
settings.minLeadTimeMinutes,
|
||||||
|
settings.maxLeadTimeDays,
|
||||||
|
settings.reservationDurationMinutes,
|
||||||
|
)
|
||||||
|
|
||||||
const [table] = await db
|
const [table] = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -60,14 +280,11 @@ export const createReservation = createServerFn({ method: 'POST' })
|
|||||||
throw new Error(`${table.name} only seats up to ${table.seats} guests`)
|
throw new Error(`${table.name} only seats up to ${table.seats} guests`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [conflict] = await db
|
const conflict = await findOverlappingReservation(
|
||||||
.select()
|
data.tableId,
|
||||||
.from(reservations)
|
reservationTime,
|
||||||
.where(
|
settings.reservationDurationMinutes,
|
||||||
and(
|
settings.minLeadTimeMinutes,
|
||||||
eq(reservations.tableId, data.tableId),
|
|
||||||
eq(reservations.reservationTime, reservationTime),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
@@ -80,11 +297,24 @@ export const createReservation = createServerFn({ method: 'POST' })
|
|||||||
tableId: data.tableId,
|
tableId: data.tableId,
|
||||||
customerName: data.customerName,
|
customerName: data.customerName,
|
||||||
customerEmail: data.customerEmail,
|
customerEmail: data.customerEmail,
|
||||||
|
customerPhone: data.customerPhone,
|
||||||
|
message: data.message,
|
||||||
|
durationMinutes: settings.reservationDurationMinutes,
|
||||||
partySize: data.partySize,
|
partySize: data.partySize,
|
||||||
reservationTime,
|
reservationTime,
|
||||||
})
|
})
|
||||||
.returning()
|
.returning()
|
||||||
|
|
||||||
|
await sendReservationConfirmationEmail({
|
||||||
|
customerName: reservation.customerName,
|
||||||
|
customerEmail: reservation.customerEmail,
|
||||||
|
message: reservation.message,
|
||||||
|
partySize: reservation.partySize,
|
||||||
|
reservationTime: reservation.reservationTime,
|
||||||
|
cancelToken: reservation.cancelToken,
|
||||||
|
table,
|
||||||
|
})
|
||||||
|
|
||||||
return reservation
|
return reservation
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -93,12 +323,21 @@ const updateReservationSchema = createReservationSchema.extend({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const updateReservation = createServerFn({ method: 'POST' })
|
export const updateReservation = createServerFn({ method: 'POST' })
|
||||||
.validator(updateReservationSchema)
|
.validator(zodValidator(updateReservationSchema))
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
await requireStaffSession()
|
await requireStaffSession()
|
||||||
|
|
||||||
|
const settings = await loadReservationSettings()
|
||||||
const reservationTime = new Date(data.reservationTime)
|
const reservationTime = new Date(data.reservationTime)
|
||||||
|
|
||||||
|
assertValidReservationSlot(
|
||||||
|
reservationTime,
|
||||||
|
settings.reservationIntervalMinutes,
|
||||||
|
settings.minLeadTimeMinutes,
|
||||||
|
settings.maxLeadTimeDays,
|
||||||
|
settings.reservationDurationMinutes,
|
||||||
|
)
|
||||||
|
|
||||||
const [table] = await db
|
const [table] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(restaurantTables)
|
.from(restaurantTables)
|
||||||
@@ -112,15 +351,12 @@ export const updateReservation = createServerFn({ method: 'POST' })
|
|||||||
throw new Error(`${table.name} only seats up to ${table.seats} guests`)
|
throw new Error(`${table.name} only seats up to ${table.seats} guests`)
|
||||||
}
|
}
|
||||||
|
|
||||||
const [conflict] = await db
|
const conflict = await findOverlappingReservation(
|
||||||
.select()
|
data.tableId,
|
||||||
.from(reservations)
|
reservationTime,
|
||||||
.where(
|
settings.reservationDurationMinutes,
|
||||||
and(
|
settings.minLeadTimeMinutes,
|
||||||
eq(reservations.tableId, data.tableId),
|
data.id,
|
||||||
eq(reservations.reservationTime, reservationTime),
|
|
||||||
ne(reservations.id, data.id),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
@@ -133,8 +369,11 @@ export const updateReservation = createServerFn({ method: 'POST' })
|
|||||||
tableId: data.tableId,
|
tableId: data.tableId,
|
||||||
customerName: data.customerName,
|
customerName: data.customerName,
|
||||||
customerEmail: data.customerEmail,
|
customerEmail: data.customerEmail,
|
||||||
|
customerPhone: data.customerPhone,
|
||||||
|
message: data.message,
|
||||||
partySize: data.partySize,
|
partySize: data.partySize,
|
||||||
reservationTime,
|
reservationTime,
|
||||||
|
durationMinutes: settings.reservationDurationMinutes,
|
||||||
})
|
})
|
||||||
.where(eq(reservations.id, data.id))
|
.where(eq(reservations.id, data.id))
|
||||||
.returning()
|
.returning()
|
||||||
@@ -143,6 +382,49 @@ export const updateReservation = createServerFn({ method: 'POST' })
|
|||||||
throw new Error('Reservation not found')
|
throw new Error('Reservation not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await sendReservationUpdateEmail({
|
||||||
|
customerName: updated.customerName,
|
||||||
|
customerEmail: updated.customerEmail,
|
||||||
|
message: updated.message,
|
||||||
|
partySize: updated.partySize,
|
||||||
|
reservationTime: updated.reservationTime,
|
||||||
|
cancelToken: updated.cancelToken,
|
||||||
|
table,
|
||||||
|
})
|
||||||
|
|
||||||
|
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
|
return updated
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -151,7 +433,7 @@ const deleteReservationSchema = z.object({
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const deleteReservation = createServerFn({ method: 'POST' })
|
export const deleteReservation = createServerFn({ method: 'POST' })
|
||||||
.validator(deleteReservationSchema)
|
.validator(zodValidator(deleteReservationSchema))
|
||||||
.handler(async ({ data }) => {
|
.handler(async ({ data }) => {
|
||||||
await requireStaffSession()
|
await requireStaffSession()
|
||||||
|
|
||||||
@@ -161,5 +443,75 @@ export const deleteReservation = createServerFn({ method: 'POST' })
|
|||||||
throw new Error('Reservation not found')
|
throw new Error('Reservation not found')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const [table] = await db
|
||||||
|
.select()
|
||||||
|
.from(restaurantTables)
|
||||||
|
.where(eq(restaurantTables.id, deleted.tableId))
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
await sendReservationCancellationEmail({
|
||||||
|
customerName: deleted.customerName,
|
||||||
|
customerEmail: deleted.customerEmail,
|
||||||
|
partySize: deleted.partySize,
|
||||||
|
reservationTime: deleted.reservationTime,
|
||||||
|
cancelToken: deleted.cancelToken,
|
||||||
|
table,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return deleted
|
return deleted
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getReservationByCancelTokenSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getReservationByCancelToken = createServerFn({ method: 'GET' })
|
||||||
|
.validator(zodValidator(getReservationByCancelTokenSchema))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const [reservation] = await db
|
||||||
|
.select({
|
||||||
|
customerName: reservations.customerName,
|
||||||
|
partySize: reservations.partySize,
|
||||||
|
reservationTime: reservations.reservationTime,
|
||||||
|
tableName: restaurantTables.name,
|
||||||
|
})
|
||||||
|
.from(reservations)
|
||||||
|
.innerJoin(restaurantTables, eq(reservations.tableId, restaurantTables.id))
|
||||||
|
.where(eq(reservations.cancelToken, data.token))
|
||||||
|
|
||||||
|
return reservation ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancelReservationByTokenSchema = z.object({
|
||||||
|
token: z.string().min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const cancelReservationByToken = createServerFn({ method: 'POST' })
|
||||||
|
.validator(zodValidator(cancelReservationByTokenSchema))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
const [deleted] = await db
|
||||||
|
.delete(reservations)
|
||||||
|
.where(eq(reservations.cancelToken, data.token))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
if (!deleted) {
|
||||||
|
throw new Error('Reservation not found')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [table] = await db
|
||||||
|
.select()
|
||||||
|
.from(restaurantTables)
|
||||||
|
.where(eq(restaurantTables.id, deleted.tableId))
|
||||||
|
|
||||||
|
if (table) {
|
||||||
|
await sendReservationCancellationEmail({
|
||||||
|
customerName: deleted.customerName,
|
||||||
|
customerEmail: deleted.customerEmail,
|
||||||
|
partySize: deleted.partySize,
|
||||||
|
reservationTime: deleted.reservationTime,
|
||||||
|
cancelToken: deleted.cancelToken,
|
||||||
|
table,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { createServerFn, createServerOnlyFn } from '@tanstack/react-start'
|
||||||
|
import { eq } from 'drizzle-orm'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { db } from '#/db'
|
||||||
|
import { restaurantSettings } from '#/db/schema'
|
||||||
|
import { requireStaffSession } from '#/lib/auth'
|
||||||
|
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
|
||||||
|
// (rather than only reachable through the server fn below) so reservations.ts can
|
||||||
|
// read the current settings directly while validating a booking. This module is
|
||||||
|
// imported by client-reachable route files for the createServerFns below, so —
|
||||||
|
// same issue as `#/lib/auth` — a plain, unwrapped export using `db` directly would
|
||||||
|
// drag the Postgres driver into the browser bundle; `createServerOnlyFn` keeps it
|
||||||
|
// server-side only.
|
||||||
|
export const loadReservationSettings = createServerOnlyFn(async function loadReservationSettings() {
|
||||||
|
const [existing] = await db.select().from(restaurantSettings).limit(1)
|
||||||
|
if (existing) return existing
|
||||||
|
|
||||||
|
const [created] = await db.insert(restaurantSettings).values({}).returning()
|
||||||
|
return created
|
||||||
|
})
|
||||||
|
|
||||||
|
export const getReservationSettings = createServerFn({ method: 'GET' }).handler(async () => {
|
||||||
|
return loadReservationSettings()
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateReservationSettingsSchema = z.object({
|
||||||
|
reservationIntervalMinutes: z.number().int().min(5).max(120),
|
||||||
|
reservationDurationMinutes: z.number().int().min(15).max(480),
|
||||||
|
minLeadTimeMinutes: z.number().int().min(0).max(10080),
|
||||||
|
maxLeadTimeDays: z.number().int().min(1).max(365),
|
||||||
|
tableDisplayMode: z.enum(['individual', 'overview']),
|
||||||
|
tableOverviewImageUrl: dataUrlImageSchema,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const updateReservationSettings = createServerFn({ method: 'POST' })
|
||||||
|
.validator(zodValidator(updateReservationSettingsSchema))
|
||||||
|
.handler(async ({ data }) => {
|
||||||
|
console.log('[settings] updateReservationSettings called with', data)
|
||||||
|
|
||||||
|
const session = await requireStaffSession()
|
||||||
|
console.log('[settings] session role:', session.user.role)
|
||||||
|
|
||||||
|
if (session.user.role !== 'admin') {
|
||||||
|
throw new Error('Nur Admins dürfen die Einstellungen ändern')
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await loadReservationSettings()
|
||||||
|
console.log('[settings] existing row before update:', existing)
|
||||||
|
|
||||||
|
const [updated] = await db
|
||||||
|
.update(restaurantSettings)
|
||||||
|
.set(data)
|
||||||
|
.where(eq(restaurantSettings.id, existing.id))
|
||||||
|
.returning()
|
||||||
|
|
||||||
|
console.log('[settings] row after update:', updated)
|
||||||
|
|
||||||
|
return updated
|
||||||
|
})
|
||||||
+5
-1
@@ -11,5 +11,9 @@ export const listStaffMembers = createServerFn({ method: 'GET' }).handler(async
|
|||||||
query: { sortBy: 'createdAt', sortDirection: 'asc', limit: 100 },
|
query: { sortBy: 'createdAt', sortDirection: 'asc', limit: 100 },
|
||||||
})
|
})
|
||||||
|
|
||||||
return users
|
// `listUsers`'s return type doesn't pick up `user.additionalFields` from the
|
||||||
|
// auth config the way the actual response body does — `phone` is present at
|
||||||
|
// runtime (Better Auth includes every configured additional field on every
|
||||||
|
// user-returning endpoint) but missing from admin.d.ts's `UserWithRole` type.
|
||||||
|
return users as Array<(typeof users)[number] & { phone: string | null }>
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
+81
-3
@@ -13,7 +13,10 @@ import { Route as IndexRouteImport } from './routes/index'
|
|||||||
import { Route as ImpressumRouteImport } from './routes/impressum'
|
import { Route as ImpressumRouteImport } from './routes/impressum'
|
||||||
import { Route as StaffIndexRouteImport } from './routes/staff.index'
|
import { Route as StaffIndexRouteImport } from './routes/staff.index'
|
||||||
import { Route as StaffLoginRouteImport } from './routes/staff.login'
|
import { Route as StaffLoginRouteImport } from './routes/staff.login'
|
||||||
|
import { Route as StaffSettingsRouteImport } from './routes/staff.settings'
|
||||||
|
import { Route as StaffTablesRouteImport } from './routes/staff.tables'
|
||||||
import { Route as StaffTeamRouteImport } from './routes/staff.team'
|
import { Route as StaffTeamRouteImport } from './routes/staff.team'
|
||||||
|
import { Route as StornierenTokenRouteImport } from './routes/stornieren.$token'
|
||||||
|
|
||||||
const IndexRoute = IndexRouteImport.update({
|
const IndexRoute = IndexRouteImport.update({
|
||||||
id: '/',
|
id: '/',
|
||||||
@@ -35,24 +38,45 @@ const StaffLoginRoute = StaffLoginRouteImport.update({
|
|||||||
path: '/staff/login',
|
path: '/staff/login',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const StaffSettingsRoute = StaffSettingsRouteImport.update({
|
||||||
|
id: '/staff/settings',
|
||||||
|
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({
|
const StaffTeamRoute = StaffTeamRouteImport.update({
|
||||||
id: '/staff/team',
|
id: '/staff/team',
|
||||||
path: '/staff/team',
|
path: '/staff/team',
|
||||||
getParentRoute: () => rootRouteImport,
|
getParentRoute: () => rootRouteImport,
|
||||||
} as any)
|
} as any)
|
||||||
|
const StornierenTokenRoute = StornierenTokenRouteImport.update({
|
||||||
|
id: '/stornieren/$token',
|
||||||
|
path: '/stornieren/$token',
|
||||||
|
getParentRoute: () => rootRouteImport,
|
||||||
|
} as any)
|
||||||
|
|
||||||
export interface FileRoutesByFullPath {
|
export interface FileRoutesByFullPath {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/impressum': typeof ImpressumRoute
|
'/impressum': typeof ImpressumRoute
|
||||||
'/staff/login': typeof StaffLoginRoute
|
'/staff/login': typeof StaffLoginRoute
|
||||||
|
'/staff/settings': typeof StaffSettingsRoute
|
||||||
|
'/staff/tables': typeof StaffTablesRoute
|
||||||
'/staff/team': typeof StaffTeamRoute
|
'/staff/team': typeof StaffTeamRoute
|
||||||
|
'/stornieren/$token': typeof StornierenTokenRoute
|
||||||
'/staff/': typeof StaffIndexRoute
|
'/staff/': typeof StaffIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesByTo {
|
export interface FileRoutesByTo {
|
||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/impressum': typeof ImpressumRoute
|
'/impressum': typeof ImpressumRoute
|
||||||
'/staff/login': typeof StaffLoginRoute
|
'/staff/login': typeof StaffLoginRoute
|
||||||
|
'/staff/settings': typeof StaffSettingsRoute
|
||||||
|
'/staff/tables': typeof StaffTablesRoute
|
||||||
'/staff/team': typeof StaffTeamRoute
|
'/staff/team': typeof StaffTeamRoute
|
||||||
|
'/stornieren/$token': typeof StornierenTokenRoute
|
||||||
'/staff': typeof StaffIndexRoute
|
'/staff': typeof StaffIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRoutesById {
|
export interface FileRoutesById {
|
||||||
@@ -60,23 +84,53 @@ export interface FileRoutesById {
|
|||||||
'/': typeof IndexRoute
|
'/': typeof IndexRoute
|
||||||
'/impressum': typeof ImpressumRoute
|
'/impressum': typeof ImpressumRoute
|
||||||
'/staff/login': typeof StaffLoginRoute
|
'/staff/login': typeof StaffLoginRoute
|
||||||
|
'/staff/settings': typeof StaffSettingsRoute
|
||||||
|
'/staff/tables': typeof StaffTablesRoute
|
||||||
'/staff/team': typeof StaffTeamRoute
|
'/staff/team': typeof StaffTeamRoute
|
||||||
|
'/stornieren/$token': typeof StornierenTokenRoute
|
||||||
'/staff/': typeof StaffIndexRoute
|
'/staff/': typeof StaffIndexRoute
|
||||||
}
|
}
|
||||||
export interface FileRouteTypes {
|
export interface FileRouteTypes {
|
||||||
fileRoutesByFullPath: FileRoutesByFullPath
|
fileRoutesByFullPath: FileRoutesByFullPath
|
||||||
fullPaths: '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff/'
|
fullPaths:
|
||||||
|
| '/'
|
||||||
|
| '/impressum'
|
||||||
|
| '/staff/login'
|
||||||
|
| '/staff/settings'
|
||||||
|
| '/staff/tables'
|
||||||
|
| '/staff/team'
|
||||||
|
| '/stornieren/$token'
|
||||||
|
| '/staff/'
|
||||||
fileRoutesByTo: FileRoutesByTo
|
fileRoutesByTo: FileRoutesByTo
|
||||||
to: '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff'
|
to:
|
||||||
|
| '/'
|
||||||
|
| '/impressum'
|
||||||
|
| '/staff/login'
|
||||||
|
| '/staff/settings'
|
||||||
|
| '/staff/tables'
|
||||||
|
| '/staff/team'
|
||||||
|
| '/stornieren/$token'
|
||||||
|
| '/staff'
|
||||||
id:
|
id:
|
||||||
'__root__' | '/' | '/impressum' | '/staff/login' | '/staff/team' | '/staff/'
|
| '__root__'
|
||||||
|
| '/'
|
||||||
|
| '/impressum'
|
||||||
|
| '/staff/login'
|
||||||
|
| '/staff/settings'
|
||||||
|
| '/staff/tables'
|
||||||
|
| '/staff/team'
|
||||||
|
| '/stornieren/$token'
|
||||||
|
| '/staff/'
|
||||||
fileRoutesById: FileRoutesById
|
fileRoutesById: FileRoutesById
|
||||||
}
|
}
|
||||||
export interface RootRouteChildren {
|
export interface RootRouteChildren {
|
||||||
IndexRoute: typeof IndexRoute
|
IndexRoute: typeof IndexRoute
|
||||||
ImpressumRoute: typeof ImpressumRoute
|
ImpressumRoute: typeof ImpressumRoute
|
||||||
StaffLoginRoute: typeof StaffLoginRoute
|
StaffLoginRoute: typeof StaffLoginRoute
|
||||||
|
StaffSettingsRoute: typeof StaffSettingsRoute
|
||||||
|
StaffTablesRoute: typeof StaffTablesRoute
|
||||||
StaffTeamRoute: typeof StaffTeamRoute
|
StaffTeamRoute: typeof StaffTeamRoute
|
||||||
|
StornierenTokenRoute: typeof StornierenTokenRoute
|
||||||
StaffIndexRoute: typeof StaffIndexRoute
|
StaffIndexRoute: typeof StaffIndexRoute
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +164,20 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof StaffLoginRouteImport
|
preLoaderRoute: typeof StaffLoginRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/staff/settings': {
|
||||||
|
id: '/staff/settings'
|
||||||
|
path: '/staff/settings'
|
||||||
|
fullPath: '/staff/settings'
|
||||||
|
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': {
|
'/staff/team': {
|
||||||
id: '/staff/team'
|
id: '/staff/team'
|
||||||
path: '/staff/team'
|
path: '/staff/team'
|
||||||
@@ -117,6 +185,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof StaffTeamRouteImport
|
preLoaderRoute: typeof StaffTeamRouteImport
|
||||||
parentRoute: typeof rootRouteImport
|
parentRoute: typeof rootRouteImport
|
||||||
}
|
}
|
||||||
|
'/stornieren/$token': {
|
||||||
|
id: '/stornieren/$token'
|
||||||
|
path: '/stornieren/$token'
|
||||||
|
fullPath: '/stornieren/$token'
|
||||||
|
preLoaderRoute: typeof StornierenTokenRouteImport
|
||||||
|
parentRoute: typeof rootRouteImport
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -124,7 +199,10 @@ const rootRouteChildren: RootRouteChildren = {
|
|||||||
IndexRoute: IndexRoute,
|
IndexRoute: IndexRoute,
|
||||||
ImpressumRoute: ImpressumRoute,
|
ImpressumRoute: ImpressumRoute,
|
||||||
StaffLoginRoute: StaffLoginRoute,
|
StaffLoginRoute: StaffLoginRoute,
|
||||||
|
StaffSettingsRoute: StaffSettingsRoute,
|
||||||
|
StaffTablesRoute: StaffTablesRoute,
|
||||||
StaffTeamRoute: StaffTeamRoute,
|
StaffTeamRoute: StaffTeamRoute,
|
||||||
|
StornierenTokenRoute: StornierenTokenRoute,
|
||||||
StaffIndexRoute: StaffIndexRoute,
|
StaffIndexRoute: StaffIndexRoute,
|
||||||
}
|
}
|
||||||
export const routeTree = rootRouteImport
|
export const routeTree = rootRouteImport
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export const Route = createRootRoute({
|
|||||||
rel: 'stylesheet',
|
rel: 'stylesheet',
|
||||||
href: appCss,
|
href: appCss,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
rel: 'icon',
|
||||||
|
href: '/favicon.svg',
|
||||||
|
type: 'image/svg+xml',
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
shellComponent: RootDocument,
|
shellComponent: RootDocument,
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ function Impressum() {
|
|||||||
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
|
bedürfen der schriftlichen Zustimmung des jeweiligen Erstellers.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<h2>Datenschutz</h2>
|
<h2 id="datenschutz">Datenschutz</h2>
|
||||||
<p>
|
<p>
|
||||||
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
|
Verantwortlich für die Verarbeitung personenbezogener Daten auf dieser Website ist die
|
||||||
oben genannte Drenica & Mousa GbR.
|
oben genannte Drenica & Mousa GbR.
|
||||||
|
|||||||
+217
-31
@@ -1,28 +1,52 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, useRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { ReservationConfirmedModal } from '#/components/reservation-confirmed-modal'
|
||||||
|
import { ReservationDateTimeField } from '#/components/reservation-datetime-field'
|
||||||
import { SiteFooter } from '#/components/site-footer'
|
import { SiteFooter } from '#/components/site-footer'
|
||||||
import { SiteHeader } from '#/components/site-header'
|
import { SiteHeader } from '#/components/site-header'
|
||||||
import { formatDateTimeLocal } from '#/lib/datetime'
|
import { formatDateOnly } from '#/lib/datetime'
|
||||||
import { createReservation, getTables } from '#/lib/reservations'
|
import {
|
||||||
|
generateTimeOptions,
|
||||||
|
getNextAvailableSlot,
|
||||||
|
getOpeningHoursForDisplay,
|
||||||
|
getOpeningRangesForWeekday,
|
||||||
|
getTodayLabelBerlin,
|
||||||
|
} from '#/lib/opening-hours'
|
||||||
|
import { createReservation, getReservationAvailability, getTables } from '#/lib/reservations'
|
||||||
|
import { getReservationSettings } from '#/lib/settings'
|
||||||
|
|
||||||
export const Route = createFileRoute('/')({
|
export const Route = createFileRoute('/')({
|
||||||
component: Home,
|
component: Home,
|
||||||
loader: async () => {
|
loader: async () => {
|
||||||
const tables = await getTables()
|
const [tables, settings, availability] = await Promise.all([
|
||||||
return { tables }
|
getTables(),
|
||||||
|
getReservationSettings(),
|
||||||
|
getReservationAvailability(),
|
||||||
|
])
|
||||||
|
return { tables, settings, availability }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const OPENING_HOURS = [
|
const OPENING_HOURS = getOpeningHoursForDisplay()
|
||||||
{ day: 'Montag', hours: null },
|
|
||||||
{ day: 'Dienstag', hours: ['17:00 – 21:30'] },
|
const confirmedDateFormatter = new Intl.DateTimeFormat('de-DE', { dateStyle: 'full' })
|
||||||
{ day: 'Mittwoch', hours: ['17:00 – 21:30'] },
|
const confirmedTimeFormatter = new Intl.DateTimeFormat('de-DE', { timeStyle: 'short' })
|
||||||
{ day: 'Donnerstag', hours: ['17:00 – 21:30'] },
|
|
||||||
{ day: 'Freitag', hours: ['17:00 – 22:00'] },
|
const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||||
{ day: 'Samstag', hours: ['17:00 – 22:00'] },
|
// Also used as the phone input's `pattern` attribute below, so the two stay in sync.
|
||||||
{ day: 'Sonntag', hours: ['11:30 – 13:30', '17:00 – 22:00'] },
|
const PHONE_PATTERN_SOURCE = String.raw`[+]?[0-9()\-.\s]{6,30}`
|
||||||
]
|
const PHONE_PATTERN = new RegExp(`^${PHONE_PATTERN_SOURCE}$`)
|
||||||
|
|
||||||
|
type ConfirmedReservation = {
|
||||||
|
customerName: string
|
||||||
|
customerEmail: string
|
||||||
|
tableName: string
|
||||||
|
date: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
partySize: number
|
||||||
|
}
|
||||||
|
|
||||||
function FlameIcon() {
|
function FlameIcon() {
|
||||||
return (
|
return (
|
||||||
@@ -39,47 +63,117 @@ function FlameIcon() {
|
|||||||
|
|
||||||
function Home() {
|
function Home() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { tables } = Route.useLoaderData()
|
const { tables, settings, availability } = Route.useLoaderData()
|
||||||
|
|
||||||
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
|
const [tableId, setTableId] = useState<number | string>(tables[0]?.id ?? '')
|
||||||
const [customerName, setCustomerName] = useState('')
|
const [customerName, setCustomerName] = useState('')
|
||||||
const [customerEmail, setCustomerEmail] = useState('')
|
const [customerEmail, setCustomerEmail] = useState('')
|
||||||
|
const [customerPhone, setCustomerPhone] = useState('')
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
const [partySize, setPartySize] = useState(2)
|
const [partySize, setPartySize] = useState(2)
|
||||||
const [reservationTime, setReservationTime] = useState(() =>
|
const [acceptsTerms, setAcceptsTerms] = useState(false)
|
||||||
formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)),
|
const [reservationTime, setReservationTime] = useState(() => {
|
||||||
|
const slot = getNextAvailableSlot(
|
||||||
|
new Date(Date.now() + settings.minLeadTimeMinutes * 60_000),
|
||||||
|
settings.reservationIntervalMinutes,
|
||||||
|
settings.reservationDurationMinutes,
|
||||||
)
|
)
|
||||||
|
return slot ? `${slot.date}T${slot.time}` : ''
|
||||||
|
})
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [confirmedReservation, setConfirmedReservation] = useState<ConfirmedReservation | null>(null)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
const todayName = new Date().toLocaleDateString('de-DE', { weekday: 'long' })
|
const todayName = getTodayLabelBerlin()
|
||||||
|
const selectedTable = tables.find((table) => String(table.id) === String(tableId))
|
||||||
|
|
||||||
|
const [selectedDate] = reservationTime.split('T')
|
||||||
|
const otherReservationsOnSelection = availability.filter(
|
||||||
|
(r) => String(r.tableId) === String(tableId) && formatDateOnly(new Date(r.reservationTime)) === selectedDate,
|
||||||
|
)
|
||||||
|
// `minLeadTimeMinutes` is reused here as a table turnover buffer, extending each
|
||||||
|
// existing reservation's taken window on both sides so back-to-back bookings on the
|
||||||
|
// same table always leave that much of a gap.
|
||||||
|
const turnoverBufferMs = settings.minLeadTimeMinutes * 60_000
|
||||||
|
const bookedTimesForForm = selectedDate
|
||||||
|
? generateTimeOptions(
|
||||||
|
getOpeningRangesForWeekday(new Date(`${selectedDate}T00:00`).getDay()),
|
||||||
|
settings.reservationIntervalMinutes,
|
||||||
|
settings.reservationDurationMinutes,
|
||||||
|
).filter((option) => {
|
||||||
|
const slotStart = new Date(`${selectedDate}T${option}`).getTime()
|
||||||
|
return otherReservationsOnSelection.some((r) => {
|
||||||
|
const otherStart = new Date(r.reservationTime).getTime() - turnoverBufferMs
|
||||||
|
const otherEnd = otherStart + r.durationMinutes * 60_000 + 2 * turnoverBufferMs
|
||||||
|
return slotStart >= otherStart && slotStart < otherEnd
|
||||||
|
})
|
||||||
|
})
|
||||||
|
: []
|
||||||
|
|
||||||
|
// Mirrors the fields' own `required`/`type`/`pattern`/`min`/`max` constraints, so
|
||||||
|
// the submit button is disabled until they'd all actually pass — instead of only
|
||||||
|
// catching a missing table/time/consent on submit and leaving everything else to
|
||||||
|
// the browser's native validation popup.
|
||||||
|
const isFormValid =
|
||||||
|
Boolean(tableId) &&
|
||||||
|
customerName.trim().length > 0 &&
|
||||||
|
EMAIL_PATTERN.test(customerEmail) &&
|
||||||
|
PHONE_PATTERN.test(customerPhone) &&
|
||||||
|
partySize >= 1 &&
|
||||||
|
partySize <= 20 &&
|
||||||
|
Boolean(reservationTime.split('T')[1]) &&
|
||||||
|
acceptsTerms
|
||||||
|
|
||||||
async function handleSubmit(event: React.FormEvent) {
|
async function handleSubmit(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
|
||||||
|
|
||||||
if (!tableId) {
|
if (!tableId) {
|
||||||
setError('Bitte wählen Sie einen Tisch aus')
|
setError('Bitte wählen Sie einen Tisch aus')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!reservationTime.split('T')[1]) {
|
||||||
|
setError('Bitte wählen Sie eine gültige Uhrzeit aus')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!acceptsTerms) {
|
||||||
|
setError('Bitte stimmen Sie der Verarbeitung Ihrer Daten zu')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
try {
|
try {
|
||||||
await createReservation({
|
const created = await createReservation({
|
||||||
data: {
|
data: {
|
||||||
tableId: Number(tableId),
|
tableId: Number(tableId),
|
||||||
customerName,
|
customerName,
|
||||||
customerEmail,
|
customerEmail,
|
||||||
|
customerPhone,
|
||||||
|
message,
|
||||||
partySize: Number(partySize),
|
partySize: Number(partySize),
|
||||||
reservationTime: new Date(reservationTime).toISOString(),
|
reservationTime: new Date(reservationTime).toISOString(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
setSuccess('Vielen Dank! Ihre Reservierung ist bestätigt.')
|
const start = new Date(created.reservationTime)
|
||||||
|
const end = new Date(start.getTime() + created.durationMinutes * 60_000)
|
||||||
|
setConfirmedReservation({
|
||||||
|
customerName: created.customerName,
|
||||||
|
customerEmail: created.customerEmail,
|
||||||
|
tableName: selectedTable?.name ?? '',
|
||||||
|
date: confirmedDateFormatter.format(start),
|
||||||
|
startTime: confirmedTimeFormatter.format(start),
|
||||||
|
endTime: confirmedTimeFormatter.format(end),
|
||||||
|
partySize: created.partySize,
|
||||||
|
})
|
||||||
setCustomerName('')
|
setCustomerName('')
|
||||||
setCustomerEmail('')
|
setCustomerEmail('')
|
||||||
|
setCustomerPhone('')
|
||||||
|
setMessage('')
|
||||||
setPartySize(2)
|
setPartySize(2)
|
||||||
|
setAcceptsTerms(false)
|
||||||
await router.invalidate()
|
await router.invalidate()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||||
@@ -95,11 +189,11 @@ function Home() {
|
|||||||
<section className="hero" id="top">
|
<section className="hero" id="top">
|
||||||
<FlameIcon />
|
<FlameIcon />
|
||||||
<h1>
|
<h1>
|
||||||
Steakhaus <span>bei Mirko</span>
|
Vom Feuer verwöhnt, <span>mit Liebe serviert</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p>
|
<p>
|
||||||
Steaks vom Grill, gute Gesellschaft und ein gemütlicher Abend mitten in Meppen. Reservieren
|
Dicke Steaks, knisterndes Holzkohlefeuer und Gelächter am Nachbartisch – so schmeckt ein
|
||||||
Sie Ihren Tisch – wir freuen uns auf Sie.
|
guter Abend mitten in Meppen. Reservieren Sie Ihren Tisch, wir legen schon auf.
|
||||||
</p>
|
</p>
|
||||||
<div className="hero-actions">
|
<div className="hero-actions">
|
||||||
<a className="btn btn-primary" href="#reservieren">
|
<a className="btn btn-primary" href="#reservieren">
|
||||||
@@ -155,6 +249,36 @@ function Home() {
|
|||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
{settings.tableDisplayMode === 'overview' ? (
|
||||||
|
settings.tableOverviewImageUrl && (
|
||||||
|
<div className="table-overview-wrap">
|
||||||
|
<img className="table-overview-image" src={settings.tableOverviewImageUrl} alt="Übersicht unserer Tische" />
|
||||||
|
{tables
|
||||||
|
.filter((table) => table.markerX !== null && table.markerY !== null)
|
||||||
|
.map((table) => (
|
||||||
|
<button
|
||||||
|
key={table.id}
|
||||||
|
type="button"
|
||||||
|
className={`table-marker${String(table.id) === String(tableId) ? ' is-selected' : ''}`}
|
||||||
|
style={{ left: `${table.markerX}%`, top: `${table.markerY}%` }}
|
||||||
|
onClick={() => setTableId(table.id)}
|
||||||
|
aria-label={`${table.name} auswählen`}
|
||||||
|
>
|
||||||
|
<span className="table-marker-label">{table.tableNumber ?? '?'}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
selectedTable?.imageUrl && (
|
||||||
|
<img
|
||||||
|
className="table-preview"
|
||||||
|
src={selectedTable.imageUrl}
|
||||||
|
alt={`Ansicht von ${selectedTable.name}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label>
|
<label>
|
||||||
Name
|
Name
|
||||||
@@ -178,10 +302,24 @@ function Home() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
|
<label>
|
||||||
|
Telefon
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
pattern={PHONE_PATTERN_SOURCE}
|
||||||
|
title="Nur Ziffern, Leerzeichen und + ( ) - ."
|
||||||
|
value={customerPhone}
|
||||||
|
onChange={(e) => setCustomerPhone(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Anzahl Personen
|
Anzahl Personen
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
min={1}
|
min={1}
|
||||||
max={20}
|
max={20}
|
||||||
value={partySize}
|
value={partySize}
|
||||||
@@ -189,24 +327,58 @@ function Home() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>
|
<label>
|
||||||
Datum & Uhrzeit
|
Datum & Uhrzeit
|
||||||
<input
|
<ReservationDateTimeField
|
||||||
type="datetime-local"
|
|
||||||
value={reservationTime}
|
value={reservationTime}
|
||||||
onChange={(e) => setReservationTime(e.target.value)}
|
onChange={setReservationTime}
|
||||||
required
|
bookedTimes={bookedTimesForForm}
|
||||||
|
intervalMinutes={settings.reservationIntervalMinutes}
|
||||||
|
minLeadTimeMinutes={settings.minLeadTimeMinutes}
|
||||||
|
durationMinutes={settings.reservationDurationMinutes}
|
||||||
|
maxLeadTimeDays={settings.maxLeadTimeDays}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" disabled={isSubmitting}>
|
<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>
|
||||||
|
|
||||||
|
<label className="radio-option consent-option">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={acceptsTerms}
|
||||||
|
onChange={(e) => setAcceptsTerms(e.target.checked)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
Ich stimme der Verarbeitung meiner Daten gemäß der{' '}
|
||||||
|
<a href="/impressum#datenschutz" target="_blank" rel="noopener noreferrer">
|
||||||
|
Datenschutzerklärung
|
||||||
|
</a>{' '}
|
||||||
|
zu.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<button type="submit" disabled={isSubmitting || !isFormValid}>
|
||||||
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
|
{isSubmitting ? 'Wird gebucht …' : 'Jetzt reservieren'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{error && <p className="message message-error">{error}</p>}
|
{error && <p className="message message-error">{error}</p>}
|
||||||
{success && <p className="message message-success">{success}</p>}
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -240,6 +412,20 @@ function Home() {
|
|||||||
</main>
|
</main>
|
||||||
|
|
||||||
<SiteFooter />
|
<SiteFooter />
|
||||||
|
|
||||||
|
{confirmedReservation && (
|
||||||
|
<ReservationConfirmedModal
|
||||||
|
open
|
||||||
|
customerName={confirmedReservation.customerName}
|
||||||
|
customerEmail={confirmedReservation.customerEmail}
|
||||||
|
tableName={confirmedReservation.tableName}
|
||||||
|
date={confirmedReservation.date}
|
||||||
|
startTime={confirmedReservation.startTime}
|
||||||
|
endTime={confirmedReservation.endTime}
|
||||||
|
partySize={confirmedReservation.partySize}
|
||||||
|
onClose={() => setConfirmedReservation(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+242
-194
@@ -1,16 +1,25 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { ConfirmDialog } from '#/components/confirm-dialog'
|
||||||
|
import { ReservationCharts } from '#/components/reservation-charts'
|
||||||
|
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 { getCurrentStaffUser } from '#/lib/auth'
|
||||||
import { authClient } from '#/lib/auth-client'
|
import { authClient } from '#/lib/auth-client'
|
||||||
import { formatDateTimeLocal } from '#/lib/datetime'
|
|
||||||
import {
|
import {
|
||||||
|
RESERVATION_STATUS_LABELS,
|
||||||
createReservation,
|
createReservation,
|
||||||
deleteReservation,
|
deleteReservation,
|
||||||
getReservations,
|
getReservations,
|
||||||
getTables,
|
getTables,
|
||||||
updateReservation,
|
updateReservation,
|
||||||
|
updateReservationStatus,
|
||||||
|
type ReservationStatus,
|
||||||
} from '#/lib/reservations'
|
} from '#/lib/reservations'
|
||||||
|
import { getReservationSettings } from '#/lib/settings'
|
||||||
|
|
||||||
export const Route = createFileRoute('/staff/')({
|
export const Route = createFileRoute('/staff/')({
|
||||||
component: StaffDashboard,
|
component: StaffDashboard,
|
||||||
@@ -22,13 +31,20 @@ export const Route = createFileRoute('/staff/')({
|
|||||||
return { staffUser }
|
return { staffUser }
|
||||||
},
|
},
|
||||||
loader: async ({ context }) => {
|
loader: async ({ context }) => {
|
||||||
const [reservations, tables] = await Promise.all([getReservations(), getTables()])
|
const [reservations, tables, settings] = await Promise.all([
|
||||||
return { staffUser: context.staffUser, reservations, tables }
|
getReservations(),
|
||||||
|
getTables(),
|
||||||
|
getReservationSettings(),
|
||||||
|
])
|
||||||
|
return { staffUser: context.staffUser, reservations, tables, settings }
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
const dateFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
dateStyle: 'medium',
|
dateStyle: 'medium',
|
||||||
|
})
|
||||||
|
|
||||||
|
const timeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
timeStyle: 'short',
|
timeStyle: 'short',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -40,117 +56,100 @@ function isSameDay(a: Date, b: Date) {
|
|||||||
|
|
||||||
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
|
type ReservationRow = Awaited<ReturnType<typeof getReservations>>[number]
|
||||||
|
|
||||||
type FormState = {
|
|
||||||
tableId: number | string
|
|
||||||
customerName: string
|
|
||||||
customerEmail: string
|
|
||||||
partySize: number
|
|
||||||
reservationTime: string
|
|
||||||
}
|
|
||||||
|
|
||||||
function emptyForm(defaultTableId: number | string): FormState {
|
|
||||||
return {
|
|
||||||
tableId: defaultTableId,
|
|
||||||
customerName: '',
|
|
||||||
customerEmail: '',
|
|
||||||
partySize: 2,
|
|
||||||
reservationTime: formatDateTimeLocal(new Date(Date.now() + 60 * 60 * 1000)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function StaffDashboard() {
|
function StaffDashboard() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const { staffUser, reservations, tables } = Route.useLoaderData()
|
const { staffUser, reservations, tables, settings } = Route.useLoaderData()
|
||||||
|
|
||||||
const [editingId, setEditingId] = useState<number | null>(null)
|
|
||||||
const [form, setForm] = useState<FormState>(() => emptyForm(tables[0]?.id ?? ''))
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [successModal, setSuccessModal] = useState<{ title: string; message: string } | null>(null)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [pendingDelete, setPendingDelete] = useState<ReservationRow | null>(null)
|
||||||
const [deletingId, setDeletingId] = useState<number | null>(null)
|
const [viewingReservation, setViewingReservation] = useState<ReservationRow | null>(null)
|
||||||
|
const [isCreating, setIsCreating] = useState(false)
|
||||||
|
const [isUpdatingStatus, setIsUpdatingStatus] = useState(false)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('')
|
||||||
|
const [tableFilter, setTableFilter] = useState('all')
|
||||||
|
const [timeFilter, setTimeFilter] = useState<'all' | 'today' | 'upcoming' | 'past'>('today')
|
||||||
|
const [statusFilter, setStatusFilter] = useState<'all' | ReservationStatus>('all')
|
||||||
|
|
||||||
|
const dialogOpen = viewingReservation !== null || isCreating
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
|
const todaysReservations = reservations.filter((r) => isSameDay(new Date(r.reservationTime), now))
|
||||||
const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0)
|
const guestsToday = todaysReservations.reduce((sum, r) => sum + r.partySize, 0)
|
||||||
const upcoming = reservations.filter((r) => new Date(r.reservationTime).getTime() >= now.getTime())
|
const upcoming = reservations.filter((r) => new Date(r.reservationTime).getTime() >= now.getTime())
|
||||||
|
|
||||||
function startEdit(reservation: ReservationRow) {
|
const filteredReservations = reservations.filter((r) => {
|
||||||
setEditingId(reservation.id)
|
if (tableFilter !== 'all' && String(r.tableId) !== tableFilter) return false
|
||||||
setForm({
|
if (statusFilter !== 'all' && r.status !== statusFilter) return false
|
||||||
tableId: reservation.tableId,
|
|
||||||
customerName: reservation.customerName,
|
const reservationTime = new Date(r.reservationTime)
|
||||||
customerEmail: reservation.customerEmail,
|
if (timeFilter === 'today' && !isSameDay(reservationTime, now)) return false
|
||||||
partySize: reservation.partySize,
|
if (timeFilter === 'upcoming' && reservationTime.getTime() < now.getTime()) return false
|
||||||
reservationTime: formatDateTimeLocal(new Date(reservation.reservationTime)),
|
if (timeFilter === 'past' && reservationTime.getTime() >= now.getTime()) return false
|
||||||
|
|
||||||
|
const query = searchQuery.trim().toLowerCase()
|
||||||
|
if (query) {
|
||||||
|
const haystack = `${r.customerName} ${r.customerEmail} ${r.customerPhone ?? ''} ${r.tableName} ${r.tableNumber ?? ''}`.toLowerCase()
|
||||||
|
if (!haystack.includes(query)) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
})
|
})
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
function closeDialog() {
|
||||||
|
setViewingReservation(null)
|
||||||
|
setIsCreating(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelEdit() {
|
async function handleCreate(payload: ReservationPayload) {
|
||||||
setEditingId(null)
|
|
||||||
setForm(emptyForm(tables[0]?.id ?? ''))
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 })
|
await createReservation({ data: payload })
|
||||||
setSuccess('Reservierung wurde angelegt.')
|
setSuccessModal({
|
||||||
}
|
title: 'Reservierung angelegt',
|
||||||
|
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich angelegt.`,
|
||||||
setEditingId(null)
|
})
|
||||||
setForm(emptyForm(tables[0]?.id ?? ''))
|
|
||||||
await router.invalidate()
|
await router.invalidate()
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleDelete(reservation: ReservationRow) {
|
async function handleUpdate(id: number, payload: ReservationPayload) {
|
||||||
if (!window.confirm(`Reservierung von ${reservation.customerName} wirklich löschen?`)) {
|
await updateReservation({ data: { id, ...payload } })
|
||||||
return
|
setSuccessModal({
|
||||||
|
title: 'Reservierung aktualisiert',
|
||||||
|
message: `Die Reservierung für ${payload.customerName} wurde erfolgreich aktualisiert.`,
|
||||||
|
})
|
||||||
|
await router.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
setDeletingId(reservation.id)
|
async function confirmDelete() {
|
||||||
|
const reservation = pendingDelete
|
||||||
|
if (!reservation) return
|
||||||
|
|
||||||
|
setPendingDelete(null)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteReservation({ data: { id: reservation.id } })
|
await deleteReservation({ data: { id: reservation.id } })
|
||||||
if (editingId === reservation.id) {
|
setSuccessModal({
|
||||||
cancelEdit()
|
title: 'Reservierung gelöscht',
|
||||||
|
message: `Die Reservierung von ${reservation.customerName} wurde gelöscht.`,
|
||||||
|
})
|
||||||
|
await router.invalidate()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||||
}
|
}
|
||||||
setSuccess('Reservierung wurde gelöscht.')
|
}
|
||||||
|
|
||||||
|
async function handleStatusChange(reservation: ReservationRow, status: ReservationStatus) {
|
||||||
|
setIsUpdatingStatus(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateReservationStatus({ data: { id: reservation.id, status } })
|
||||||
|
setViewingReservation((current) => (current ? { ...current, status } : current))
|
||||||
await router.invalidate()
|
await router.invalidate()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||||
} finally {
|
} finally {
|
||||||
setDeletingId(null)
|
setIsUpdatingStatus(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,16 +161,12 @@ function StaffDashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="staff-page">
|
<div className="staff-page">
|
||||||
|
<div className="staff-nav">
|
||||||
<header className="staff-topbar">
|
<header className="staff-topbar">
|
||||||
<Link className="wordmark" to="/">
|
<Link className="wordmark" to="/">
|
||||||
Steakhaus <span>bei Mirko</span>
|
Steakhaus <span>bei Mirko</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="staff-topbar-right">
|
<div className="staff-topbar-right">
|
||||||
{staffUser.role === 'admin' && (
|
|
||||||
<Link className="btn btn-secondary" to="/staff/team">
|
|
||||||
Mitarbeiter
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<span>Angemeldet als {staffUser.name}</span>
|
<span>Angemeldet als {staffUser.name}</span>
|
||||||
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
|
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
|
||||||
Abmelden
|
Abmelden
|
||||||
@@ -179,9 +174,20 @@ function StaffDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="staff-tabs-bar">
|
||||||
|
<StaffTabs role={staffUser.role} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main className="page staff-main">
|
<main className="page staff-main">
|
||||||
<h1 className="section-heading">Reservierungen</h1>
|
<h1 className="section-heading">Reservierungen</h1>
|
||||||
|
|
||||||
|
{!dialogOpen && error && (
|
||||||
|
<div className="staff-page-messages">
|
||||||
|
<p className="message message-error">{error}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="staff-stats">
|
<div className="staff-stats">
|
||||||
<div className="stat-card">
|
<div className="stat-card">
|
||||||
<span className="stat-value">{reservations.length}</span>
|
<span className="stat-value">{reservations.length}</span>
|
||||||
@@ -201,130 +207,125 @@ function StaffDashboard() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<section className="staff-form-section">
|
<ReservationCharts reservations={reservations} tables={tables} />
|
||||||
<h2 className="section-heading staff-subheading">
|
|
||||||
{editingId ? 'Reservierung bearbeiten' : 'Neue Reservierung'}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form className="reservation-form" onSubmit={handleSubmit}>
|
<section className="staff-table-section">
|
||||||
<label>
|
<div className="table-toolbar">
|
||||||
Tisch
|
<h2 className="section-heading staff-subheading">Alle Reservierungen</h2>
|
||||||
<select
|
<button type="button" className="btn btn-primary" onClick={() => setIsCreating(true)}>
|
||||||
value={form.tableId}
|
+ Neue Reservierung
|
||||||
onChange={(e) => setForm((f) => ({ ...f, tableId: e.target.value }))}
|
</button>
|
||||||
required
|
</div>
|
||||||
>
|
|
||||||
<option value="" disabled>
|
{reservations.length > 0 && (
|
||||||
Tisch auswählen
|
<div className="table-filters">
|
||||||
</option>
|
<input
|
||||||
|
type="search"
|
||||||
|
className="table-search"
|
||||||
|
placeholder="Suche nach Name, E-Mail oder Tisch …"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
aria-label="Reservierungen durchsuchen"
|
||||||
|
/>
|
||||||
|
<select value={tableFilter} onChange={(e) => setTableFilter(e.target.value)} aria-label="Nach Tisch filtern">
|
||||||
|
<option value="all">Alle Tische</option>
|
||||||
{tables.map((table) => (
|
{tables.map((table) => (
|
||||||
<option key={table.id} value={table.id}>
|
<option key={table.id} value={String(table.id)}>
|
||||||
{table.name} ({table.seats} Plätze)
|
{table.tableNumber === null ? table.name : `${table.tableNumber} — ${table.name}`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={timeFilter}
|
||||||
|
onChange={(e) => setTimeFilter(e.target.value as typeof timeFilter)}
|
||||||
|
aria-label="Nach Zeitraum filtern"
|
||||||
|
>
|
||||||
|
<option value="all">Alle Zeiträume</option>
|
||||||
|
<option value="today">Heute</option>
|
||||||
|
<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>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</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>
|
||||||
|
|
||||||
<div className="form-row">
|
|
||||||
<label>
|
|
||||||
Anzahl Personen
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={1}
|
|
||||||
max={20}
|
|
||||||
value={form.partySize}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, partySize: Number(e.target.value) }))}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label>
|
|
||||||
Datum & Uhrzeit
|
|
||||||
<input
|
|
||||||
type="datetime-local"
|
|
||||||
value={form.reservationTime}
|
|
||||||
onChange={(e) => setForm((f) => ({ ...f, reservationTime: e.target.value }))}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</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>}
|
{filteredReservations.length === 0 ? (
|
||||||
{success && <p className="message message-success">{success}</p>}
|
<p className="empty-state">
|
||||||
</form>
|
{reservations.length === 0
|
||||||
</section>
|
? 'Noch keine Reservierungen vorhanden.'
|
||||||
|
: 'Keine Reservierungen für diese Filter gefunden.'}
|
||||||
<section className="staff-table-section">
|
</p>
|
||||||
{reservations.length === 0 ? (
|
|
||||||
<p className="empty-state">Noch keine Reservierungen vorhanden.</p>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table className="reservations-table">
|
<table className="reservations-table reservations-table-clickable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Datum & Uhrzeit</th>
|
{timeFilter !== 'today' && <th>Datum</th>}
|
||||||
|
<th>Start</th>
|
||||||
|
<th>Ende</th>
|
||||||
|
<th>Nr.</th>
|
||||||
<th>Tisch</th>
|
<th>Tisch</th>
|
||||||
<th>Kunde</th>
|
<th>Kunde</th>
|
||||||
<th>Personen</th>
|
<th>Personen</th>
|
||||||
<th></th>
|
<th>Status</th>
|
||||||
|
<th>Nachricht</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{reservations.map((reservation) => (
|
{filteredReservations.map((reservation) => (
|
||||||
<tr key={reservation.id} className={editingId === reservation.id ? 'is-editing' : undefined}>
|
<tr key={reservation.id} onClick={() => setViewingReservation(reservation)}>
|
||||||
<td>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</td>
|
{timeFilter !== 'today' && (
|
||||||
<td>{reservation.tableName}</td>
|
<td data-label="Datum" className="cell-datetime">
|
||||||
<td>
|
{dateFormatter.format(new Date(reservation.reservationTime))}
|
||||||
|
</td>
|
||||||
|
)}
|
||||||
|
<td data-label="Start" className="cell-datetime">
|
||||||
|
{timeFormatter.format(new Date(reservation.reservationTime))}
|
||||||
|
</td>
|
||||||
|
<td data-label="Ende" className="cell-datetime">
|
||||||
|
{timeFormatter.format(
|
||||||
|
new Date(
|
||||||
|
new Date(reservation.reservationTime).getTime() +
|
||||||
|
reservation.durationMinutes * 60_000,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td data-label="Nr." className="cell-table-nr">
|
||||||
|
{reservation.tableNumber ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td data-label="Tisch" className="cell-table-name">
|
||||||
|
{reservation.tableName}
|
||||||
|
</td>
|
||||||
|
<td data-label="Kunde" className="cell-customer">
|
||||||
{reservation.customerName}
|
{reservation.customerName}
|
||||||
<br />
|
<br />
|
||||||
<span className="muted">{reservation.customerEmail}</span>
|
<span className="muted">{reservation.customerEmail}</span>
|
||||||
|
{reservation.customerPhone && (
|
||||||
|
<>
|
||||||
|
<br />
|
||||||
|
<span className="muted">{reservation.customerPhone}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>{reservation.partySize}</td>
|
<td data-label="Personen" className="cell-party">
|
||||||
<td className="table-actions">
|
{reservation.partySize}
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => startEdit(reservation)}>
|
</td>
|
||||||
Bearbeiten
|
<td data-label="Status" className="cell-status">
|
||||||
</button>
|
<StatusBadge status={reservation.status as ReservationStatus} />
|
||||||
<button
|
</td>
|
||||||
type="button"
|
<td data-label="Nachricht" className="message-cell">
|
||||||
className="btn btn-danger"
|
{reservation.message || <span className="muted">—</span>}
|
||||||
disabled={deletingId === reservation.id}
|
|
||||||
onClick={() => handleDelete(reservation)}
|
|
||||||
>
|
|
||||||
{deletingId === reservation.id ? 'Löschen …' : 'Löschen'}
|
|
||||||
</button>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -334,6 +335,53 @@ function StaffDashboard() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingDelete !== null}
|
||||||
|
title="Reservierung löschen"
|
||||||
|
message={
|
||||||
|
pendingDelete && (
|
||||||
|
<>
|
||||||
|
Reservierung von <strong>{pendingDelete.customerName}</strong> wirklich löschen? Der Gast
|
||||||
|
erhält eine E-Mail zur Bestätigung der Stornierung.
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,52 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { createFileRoute, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { getCurrentStaffUser } from '#/lib/auth'
|
import { getCurrentStaffUser } from '#/lib/auth'
|
||||||
import { authClient } from '#/lib/auth-client'
|
import { authClient } from '#/lib/auth-client'
|
||||||
|
|
||||||
|
function ArrowLeftIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M19 12H5M11 6l-6 6 6 6"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.6"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EyeIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function EyeOffIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<circle cx="12" cy="12" r="3" stroke="currentColor" strokeWidth="1.5" />
|
||||||
|
<path d="M4 4l16 16" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const Route = createFileRoute('/staff/login')({
|
export const Route = createFileRoute('/staff/login')({
|
||||||
component: StaffLogin,
|
component: StaffLogin,
|
||||||
beforeLoad: async () => {
|
beforeLoad: async () => {
|
||||||
@@ -19,6 +62,7 @@ function StaffLogin() {
|
|||||||
|
|
||||||
const [email, setEmail] = useState('')
|
const [email, setEmail] = useState('')
|
||||||
const [password, setPassword] = useState('')
|
const [password, setPassword] = useState('')
|
||||||
|
const [showPassword, setShowPassword] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
|
||||||
@@ -41,6 +85,11 @@ function StaffLogin() {
|
|||||||
return (
|
return (
|
||||||
<div className="staff-page staff-login-page">
|
<div className="staff-page staff-login-page">
|
||||||
<form className="reservation-form staff-login-form" onSubmit={handleSubmit}>
|
<form className="reservation-form staff-login-form" onSubmit={handleSubmit}>
|
||||||
|
<Link className="back-link" to="/">
|
||||||
|
<ArrowLeftIcon />
|
||||||
|
Zurück zur Startseite
|
||||||
|
</Link>
|
||||||
|
|
||||||
<div className="staff-login-heading">
|
<div className="staff-login-heading">
|
||||||
<span className="section-kicker">Mitarbeiterbereich</span>
|
<span className="section-kicker">Mitarbeiterbereich</span>
|
||||||
<h1 className="section-heading">Anmelden</h1>
|
<h1 className="section-heading">Anmelden</h1>
|
||||||
@@ -59,12 +108,23 @@ function StaffLogin() {
|
|||||||
|
|
||||||
<label>
|
<label>
|
||||||
Passwort
|
Passwort
|
||||||
|
<div className="password-field">
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="password-toggle"
|
||||||
|
aria-label={showPassword ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
||||||
|
aria-pressed={showPassword}
|
||||||
|
onClick={() => setShowPassword((show) => !show)}
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOffIcon /> : <EyeIcon />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<button type="submit" disabled={isSubmitting}>
|
<button type="submit" disabled={isSubmitting}>
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
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,
|
||||||
|
beforeLoad: async () => {
|
||||||
|
const staffUser = await getCurrentStaffUser()
|
||||||
|
if (!staffUser) {
|
||||||
|
throw redirect({ to: '/staff/login' })
|
||||||
|
}
|
||||||
|
if (staffUser.role !== 'admin') {
|
||||||
|
throw redirect({ to: '/staff' })
|
||||||
|
}
|
||||||
|
return { staffUser }
|
||||||
|
},
|
||||||
|
loader: async ({ context }) => {
|
||||||
|
const [settings, tables] = await Promise.all([getReservationSettings(), getTables()])
|
||||||
|
return { staffUser: context.staffUser, settings, tables }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
type FormState = {
|
||||||
|
reservationIntervalMinutes: number
|
||||||
|
reservationDurationMinutes: number
|
||||||
|
minLeadTimeMinutes: number
|
||||||
|
maxLeadTimeDays: number
|
||||||
|
tableDisplayMode: 'individual' | 'overview'
|
||||||
|
tableOverviewImageUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_IMAGE_MB = Math.floor(MAX_TABLE_IMAGE_BYTES / 1_000_000)
|
||||||
|
|
||||||
|
function StaffSettings() {
|
||||||
|
const router = useRouter()
|
||||||
|
const { staffUser, settings, tables } = Route.useLoaderData()
|
||||||
|
|
||||||
|
const [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()
|
||||||
|
await router.navigate({ to: '/staff/login' })
|
||||||
|
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)
|
||||||
|
setSuccess(null)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateReservationSettings({ data: form })
|
||||||
|
setSuccess('Einstellungen wurden gespeichert.')
|
||||||
|
await router.invalidate()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">Einstellungen</h1>
|
||||||
|
|
||||||
|
<section className="staff-form-section">
|
||||||
|
<h2 className="section-heading staff-subheading">Reservierungsregeln</h2>
|
||||||
|
|
||||||
|
<form className="reservation-form" onSubmit={handleSubmit}>
|
||||||
|
<label>
|
||||||
|
Reservierungsintervall (Minuten)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={5}
|
||||||
|
max={120}
|
||||||
|
step={5}
|
||||||
|
value={form.reservationIntervalMinutes}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, reservationIntervalMinutes: Number(e.target.value) }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="field-hint">Abstand zwischen den wählbaren Uhrzeiten im Buchungsformular.</p>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Reservierungsdauer (Minuten)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={15}
|
||||||
|
max={480}
|
||||||
|
step={15}
|
||||||
|
value={form.reservationDurationMinutes}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, reservationDurationMinutes: Number(e.target.value) }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="field-hint">
|
||||||
|
Wie lange ein Tisch nach einer Reservierung als belegt gilt — verhindert überlappende Buchungen.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Mindestvorlaufzeit (Minuten)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={0}
|
||||||
|
max={10080}
|
||||||
|
step={15}
|
||||||
|
value={form.minLeadTimeMinutes}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, minLeadTimeMinutes: Number(e.target.value) }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="field-hint">
|
||||||
|
Wie kurzfristig frühestens reserviert werden kann, z. B. 60 für „mindestens eine Stunde im Voraus“.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Buchbar bis zu (Tage im Voraus)
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
min={1}
|
||||||
|
max={365}
|
||||||
|
step={1}
|
||||||
|
value={form.maxLeadTimeDays}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, maxLeadTimeDays: Number(e.target.value) }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<p className="field-hint">
|
||||||
|
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>
|
||||||
|
|
||||||
|
{error && <p className="message message-error">{error}</p>}
|
||||||
|
{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>
|
||||||
|
)
|
||||||
|
}
|
||||||
+109
-12
@@ -1,8 +1,12 @@
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
import { createFileRoute, Link, redirect, useRouter } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { ConfirmDialog } from '#/components/confirm-dialog'
|
||||||
|
import { StaffMemberDetailDialog } from '#/components/staff-member-detail-dialog'
|
||||||
|
import { StaffTabs } from '#/components/staff-tabs'
|
||||||
import { getCurrentStaffUser } from '#/lib/auth'
|
import { getCurrentStaffUser } from '#/lib/auth'
|
||||||
import { authClient } from '#/lib/auth-client'
|
import { authClient } from '#/lib/auth-client'
|
||||||
|
import { phoneSchema } from '#/lib/phone'
|
||||||
import { listStaffMembers } from '#/lib/staff'
|
import { listStaffMembers } from '#/lib/staff'
|
||||||
|
|
||||||
export const Route = createFileRoute('/staff/team')({
|
export const Route = createFileRoute('/staff/team')({
|
||||||
@@ -28,12 +32,13 @@ type StaffMember = Awaited<ReturnType<typeof listStaffMembers>>[number]
|
|||||||
type FormState = {
|
type FormState = {
|
||||||
name: string
|
name: string
|
||||||
email: string
|
email: string
|
||||||
|
phone: string
|
||||||
password: string
|
password: string
|
||||||
role: 'admin' | 'user'
|
role: 'admin' | 'user'
|
||||||
}
|
}
|
||||||
|
|
||||||
function emptyForm(): FormState {
|
function emptyForm(): FormState {
|
||||||
return { name: '', email: '', password: '', role: 'user' }
|
return { name: '', email: '', phone: '', password: '', role: 'user' }
|
||||||
}
|
}
|
||||||
|
|
||||||
function StaffTeam() {
|
function StaffTeam() {
|
||||||
@@ -45,11 +50,20 @@ function StaffTeam() {
|
|||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
const [busyId, setBusyId] = useState<string | null>(null)
|
const [busyId, setBusyId] = useState<string | null>(null)
|
||||||
|
const [viewingMember, setViewingMember] = useState<StaffMember | null>(null)
|
||||||
|
const [pendingRemove, setPendingRemove] = useState<StaffMember | null>(null)
|
||||||
|
|
||||||
async function handleCreate(event: React.FormEvent) {
|
async function handleCreate(event: React.FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
|
|
||||||
|
const phoneResult = phoneSchema.safeParse(form.phone)
|
||||||
|
if (!phoneResult.success) {
|
||||||
|
setError('Bitte geben Sie eine gültige Telefonnummer an')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
setIsSubmitting(true)
|
setIsSubmitting(true)
|
||||||
|
|
||||||
const { error: createError } = await authClient.admin.createUser({
|
const { error: createError } = await authClient.admin.createUser({
|
||||||
@@ -57,6 +71,7 @@ function StaffTeam() {
|
|||||||
email: form.email,
|
email: form.email,
|
||||||
password: form.password,
|
password: form.password,
|
||||||
role: form.role,
|
role: form.role,
|
||||||
|
data: { phone: phoneResult.data },
|
||||||
})
|
})
|
||||||
|
|
||||||
if (createError) {
|
if (createError) {
|
||||||
@@ -71,11 +86,11 @@ function StaffTeam() {
|
|||||||
await router.invalidate()
|
await router.invalidate()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleRemove(member: StaffMember) {
|
async function confirmRemove() {
|
||||||
if (!window.confirm(`Zugang für ${member.name} wirklich entfernen?`)) {
|
const member = pendingRemove
|
||||||
return
|
if (!member) return
|
||||||
}
|
|
||||||
|
|
||||||
|
setPendingRemove(null)
|
||||||
setBusyId(member.id)
|
setBusyId(member.id)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
@@ -129,6 +144,44 @@ function StaffTeam() {
|
|||||||
setBusyId(null)
|
setBusyId(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleLogout() {
|
||||||
|
await authClient.signOut()
|
||||||
|
await router.navigate({ to: '/staff/login' })
|
||||||
|
await router.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSetPhone(member: StaffMember) {
|
||||||
|
const newPhone = window.prompt(`Telefonnummer für ${member.name}:`, member.phone ?? '')
|
||||||
|
|
||||||
|
if (newPhone === null) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const phoneResult = phoneSchema.safeParse(newPhone)
|
||||||
|
if (!phoneResult.success) {
|
||||||
|
setError('Bitte geben Sie eine gültige Telefonnummer an')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusyId(member.id)
|
||||||
|
setError(null)
|
||||||
|
setSuccess(null)
|
||||||
|
|
||||||
|
const { error: phoneError } = await authClient.admin.updateUser({
|
||||||
|
userId: member.id,
|
||||||
|
data: { phone: phoneResult.data },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (phoneError) {
|
||||||
|
setError(phoneError.message ?? 'Etwas ist schiefgelaufen')
|
||||||
|
} else {
|
||||||
|
setSuccess(`Telefonnummer für ${member.name} wurde aktualisiert.`)
|
||||||
|
await router.invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusyId(null)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSetPassword(member: StaffMember) {
|
async function handleSetPassword(member: StaffMember) {
|
||||||
const newPassword = window.prompt(`Neues Passwort für ${member.name} (mind. 8 Zeichen):`)
|
const newPassword = window.prompt(`Neues Passwort für ${member.name} (mind. 8 Zeichen):`)
|
||||||
|
|
||||||
@@ -156,18 +209,24 @@ function StaffTeam() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="staff-page">
|
<div className="staff-page">
|
||||||
|
<div className="staff-nav">
|
||||||
<header className="staff-topbar">
|
<header className="staff-topbar">
|
||||||
<Link className="wordmark" to="/">
|
<Link className="wordmark" to="/">
|
||||||
Steakhaus <span>bei Mirko</span>
|
Steakhaus <span>bei Mirko</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="staff-topbar-right">
|
<div className="staff-topbar-right">
|
||||||
<Link className="btn btn-secondary" to="/staff">
|
|
||||||
Zu den Reservierungen
|
|
||||||
</Link>
|
|
||||||
<span>Angemeldet als {staffUser.name}</span>
|
<span>Angemeldet als {staffUser.name}</span>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={handleLogout}>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div className="staff-tabs-bar">
|
||||||
|
<StaffTabs role={staffUser.role} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<main className="page staff-main">
|
<main className="page staff-main">
|
||||||
<h1 className="section-heading">Mitarbeiter</h1>
|
<h1 className="section-heading">Mitarbeiter</h1>
|
||||||
|
|
||||||
@@ -197,6 +256,19 @@ function StaffTeam() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
Telefon
|
||||||
|
<input
|
||||||
|
type="tel"
|
||||||
|
inputMode="tel"
|
||||||
|
pattern="[+]?[0-9()\-.\s]{6,30}"
|
||||||
|
title="Nur Ziffern, Leerzeichen und + ( ) - ."
|
||||||
|
value={form.phone}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, phone: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<label>
|
<label>
|
||||||
Passwort
|
Passwort
|
||||||
@@ -232,11 +304,12 @@ function StaffTeam() {
|
|||||||
|
|
||||||
<section className="staff-table-section">
|
<section className="staff-table-section">
|
||||||
<div className="table-scroll">
|
<div className="table-scroll">
|
||||||
<table className="reservations-table">
|
<table className="reservations-table reservations-table-clickable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>E-Mail</th>
|
<th>E-Mail</th>
|
||||||
|
<th>Telefon</th>
|
||||||
<th>Rolle</th>
|
<th>Rolle</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
@@ -244,12 +317,14 @@ function StaffTeam() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<tr key={member.id}>
|
<tr key={member.id} onClick={() => setViewingMember(member)}>
|
||||||
<td>{member.name}</td>
|
<td>{member.name}</td>
|
||||||
<td>{member.email}</td>
|
<td>{member.email}</td>
|
||||||
|
<td>{member.phone || <span className="muted">—</span>}</td>
|
||||||
<td>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</td>
|
<td>{member.role === 'admin' ? 'Admin' : 'Mitarbeiter'}</td>
|
||||||
<td>{member.banned ? 'Gesperrt' : 'Aktiv'}</td>
|
<td>{member.banned ? 'Gesperrt' : 'Aktiv'}</td>
|
||||||
<td className="table-actions">
|
<td onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="table-actions">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -258,6 +333,14 @@ function StaffTeam() {
|
|||||||
>
|
>
|
||||||
{member.role === 'admin' ? 'Zu Mitarbeiter machen' : 'Zu Admin machen'}
|
{member.role === 'admin' ? 'Zu Mitarbeiter machen' : 'Zu Admin machen'}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={busyId === member.id}
|
||||||
|
onClick={() => handleSetPhone(member)}
|
||||||
|
>
|
||||||
|
Telefon bearbeiten
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
@@ -278,10 +361,11 @@ function StaffTeam() {
|
|||||||
type="button"
|
type="button"
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
disabled={busyId === member.id || member.id === staffUser.id}
|
disabled={busyId === member.id || member.id === staffUser.id}
|
||||||
onClick={() => handleRemove(member)}
|
onClick={() => setPendingRemove(member)}
|
||||||
>
|
>
|
||||||
Entfernen
|
Entfernen
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
@@ -290,6 +374,19 @@ function StaffTeam() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<StaffMemberDetailDialog member={viewingMember} onClose={() => setViewingMember(null)} />
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={pendingRemove !== null}
|
||||||
|
title="Zugang entfernen"
|
||||||
|
message={pendingRemove && <>Zugang für <strong>{pendingRemove.name}</strong> wirklich entfernen?</>}
|
||||||
|
warning="Diese Aktion kann nicht rückgängig gemacht werden."
|
||||||
|
confirmLabel="Entfernen"
|
||||||
|
danger
|
||||||
|
onConfirm={confirmRemove}
|
||||||
|
onCancel={() => setPendingRemove(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { createFileRoute, Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { SiteFooter } from '#/components/site-footer'
|
||||||
|
import { SiteHeader } from '#/components/site-header'
|
||||||
|
import { cancelReservationByToken, getReservationByCancelToken } from '#/lib/reservations'
|
||||||
|
|
||||||
|
export const Route = createFileRoute('/stornieren/$token')({
|
||||||
|
component: CancelReservationPage,
|
||||||
|
loader: async ({ params }) => {
|
||||||
|
const reservation = await getReservationByCancelToken({ data: { token: params.token } })
|
||||||
|
return { reservation }
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const dateTimeFormatter = new Intl.DateTimeFormat('de-DE', {
|
||||||
|
dateStyle: 'full',
|
||||||
|
timeStyle: 'short',
|
||||||
|
})
|
||||||
|
|
||||||
|
function CancelReservationPage() {
|
||||||
|
const { token } = Route.useParams()
|
||||||
|
const { reservation } = Route.useLoaderData()
|
||||||
|
|
||||||
|
const [isCancelled, setIsCancelled] = useState(false)
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
setError(null)
|
||||||
|
setIsSubmitting(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
await cancelReservationByToken({ data: { token } })
|
||||||
|
setIsCancelled(true)
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : 'Etwas ist schiefgelaufen')
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SiteHeader />
|
||||||
|
|
||||||
|
<main className="page">
|
||||||
|
<section className="section">
|
||||||
|
<div className="section-grid">
|
||||||
|
<div>
|
||||||
|
<span className="section-kicker">Reservierung verwalten</span>
|
||||||
|
<h1 className="section-heading">Reservierung stornieren</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!reservation ? (
|
||||||
|
<p className="empty-state">
|
||||||
|
Diese Reservierung wurde nicht gefunden oder ist bereits storniert.{' '}
|
||||||
|
<Link to="/">Zurück zur Startseite</Link>
|
||||||
|
</p>
|
||||||
|
) : isCancelled ? (
|
||||||
|
<p className="message message-success">
|
||||||
|
Ihre Reservierung wurde storniert. Wir haben Ihnen eine Bestätigung per E-Mail
|
||||||
|
gesendet. <Link to="/">Zurück zur Startseite</Link>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="reservation-form">
|
||||||
|
<ul className="contact-list">
|
||||||
|
<li>
|
||||||
|
<span className="label">Tisch</span>
|
||||||
|
<span>{reservation.tableName}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="label">Datum & Uhrzeit</span>
|
||||||
|
<span>{dateTimeFormatter.format(new Date(reservation.reservationTime))}</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<span className="label">Personen</span>
|
||||||
|
<span>{reservation.partySize}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<button type="button" className="btn btn-danger" disabled={isSubmitting} onClick={handleCancel}>
|
||||||
|
{isSubmitting ? 'Wird storniert …' : 'Reservierung stornieren'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && <p className="message message-error">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<SiteFooter />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
+1324
-30
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,9 @@ import viteReact from '@vitejs/plugin-react'
|
|||||||
const config = defineConfig({
|
const config = defineConfig({
|
||||||
resolve: { tsconfigPaths: true },
|
resolve: { tsconfigPaths: true },
|
||||||
plugins: [tanstackStart(), viteReact()],
|
plugins: [tanstackStart(), viteReact()],
|
||||||
|
preview: {
|
||||||
|
allowedHosts: ['localhost', '127.0.0.1', 'promo-steakhouse.lukas-harat.com'],
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
export default config
|
export default config
|
||||||
|
|||||||
Reference in New Issue
Block a user