Compare commits
10
Commits
63a30a3835
...
b499708a8f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b499708a8f | ||
|
|
8dd6ba236e | ||
|
|
7cac8cd4fc | ||
|
|
fbd4659346 | ||
|
|
b1e41026b6 | ||
|
|
8a393dce1a | ||
|
|
76f90d519c | ||
|
|
d2bb39d815 | ||
|
|
7e31dce237 | ||
|
|
74ce663813 |
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "speisekarte-dev",
|
||||||
|
"runtimeExecutable": "C:\\Program Files\\nodejs\\npm.cmd",
|
||||||
|
"runtimeArgs": ["run", "dev"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.git
|
||||||
|
.claude
|
||||||
|
.env*
|
||||||
|
!.env.example
|
||||||
|
ZUGANGSDATEN.md
|
||||||
|
supabase/local
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
README.md
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# Vorlage für .env.local (Entwicklung) bzw. die Umgebungsvariablen
|
||||||
|
# beim Hoster (Live-Betrieb). Echte Werte niemals committen!
|
||||||
|
|
||||||
|
# Supabase-Projekt-URL (Dashboard -> Project Settings -> API)
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL=https://<projekt-ref>.supabase.co
|
||||||
|
|
||||||
|
# Öffentlicher anon-Key (darf im Browser landen, RLS schützt die Daten)
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY=<anon-key>
|
||||||
|
|
||||||
|
# Service-Role-Key (GEHEIM! Nur serverseitig, für die Benutzerverwaltung)
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
|
||||||
@@ -32,6 +32,7 @@ yarn-error.log*
|
|||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (can opt-in for committing if needed)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -39,3 +40,9 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
|
# Zugangsdaten (lokal)
|
||||||
|
ZUGANGSDATEN.md
|
||||||
|
|
||||||
|
# lokale Claude-Code-Einstellungen
|
||||||
|
.claude/settings.local.json
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
# =============================================================
|
||||||
|
# Next.js-App als eigenständiges Produktions-Image (Multi-Stage)
|
||||||
|
# Build: docker compose --env-file .env.local build
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
# ---------- Abhängigkeiten ----------
|
||||||
|
FROM node:24-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---------- Build ----------
|
||||||
|
FROM node:24-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# NEXT_PUBLIC_*-Variablen werden zur Build-Zeit ins Client-Bundle eingebacken
|
||||||
|
ARG NEXT_PUBLIC_SUPABASE_URL
|
||||||
|
ARG NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
|
ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL
|
||||||
|
ENV NEXT_PUBLIC_SUPABASE_ANON_KEY=$NEXT_PUBLIC_SUPABASE_ANON_KEY
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---------- Runtime ----------
|
||||||
|
FROM node:24-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOSTNAME=0.0.0.0
|
||||||
|
|
||||||
|
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
|
||||||
|
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||||
|
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -1,36 +1,143 @@
|
|||||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
# Speisekarte – Betriebskantine
|
||||||
|
|
||||||
## Getting Started
|
Digitale Speisekarte für eine Firmenkantine (ca. 250 Mitarbeiter) mit
|
||||||
|
öffentlicher Wochenansicht, Kantinen-Bereich zur Pflege der Gerichte und
|
||||||
|
Admin-Bereich für Benutzerverwaltung und Branding.
|
||||||
|
|
||||||
First, run the development server:
|
**Stack:** Next.js 16 (App Router, TypeScript, Tailwind CSS) · Supabase
|
||||||
|
(PostgreSQL, Auth inkl. Passkeys, Storage, Row Level Security)
|
||||||
|
|
||||||
```bash
|
## Funktionen
|
||||||
npm run dev
|
|
||||||
# or
|
| Bereich | Funktionen |
|
||||||
yarn dev
|
| --- | --- |
|
||||||
# or
|
| Öffentlich (ohne Login) | Wochenplan Mo–Fr mit den Bereichen Brotzeit (09:00–10:00), Mittagessen, Mittagessen vegetarisch und Suppe (je 11:30–13:30); Navigation vorherige/nächste Woche; Logo zentriert über der Karte; Allergene & Zusatzstoffe unterhalb der Karte |
|
||||||
pnpm dev
|
| Kantinen-Team (`/intern`) | Gerichte anlegen/bearbeiten/löschen; Gerichte Tagen/Bereichen zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen; eigene Passkeys verwalten |
|
||||||
# or
|
| Admin (`/admin`) | Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo hochladen/entfernen |
|
||||||
bun dev
|
|
||||||
|
Anmeldung mit E-Mail + Passwort oder alternativ per **Passkey**
|
||||||
|
(Registrierung im Bereich „Passkeys“ nach dem ersten Login).
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
app/ Next.js-Routen
|
||||||
|
page.tsx Öffentliche Speisekarte
|
||||||
|
login/ Login (Passwort + Passkey)
|
||||||
|
intern/ Kantinen-Bereich (Rolle: staff oder admin)
|
||||||
|
admin/ Verwaltung (Rolle: admin)
|
||||||
|
api/admin/users/ Benutzerverwaltung (serverseitig, Service-Role-Key)
|
||||||
|
components/ Client-Komponenten (WeekEditor, DishManager, …)
|
||||||
|
lib/supabase/ Supabase-Clients (Browser / Server / Admin)
|
||||||
|
lib/ Typen, Datums-/Formatierungshelfer
|
||||||
|
proxy.ts Session-Refresh + Schutz von /intern und /admin
|
||||||
|
supabase/migrations/ Schema & Seed-Daten (Referenz/Doku)
|
||||||
|
supabase/local/ Skripte + Dumps für die lokale PostgreSQL-Kopie
|
||||||
|
scripts/ Hilfsskripte (DB-Migration, lokaler Sync)
|
||||||
```
|
```
|
||||||
|
|
||||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
## Entwicklung
|
||||||
|
|
||||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
Voraussetzung in beiden Varianten: `.env.local` mit den Werten aus
|
||||||
|
`.env.example` (liegt lokal bereits vor und ist per `.gitignore` vom
|
||||||
|
Repo ausgeschlossen).
|
||||||
|
|
||||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
**Variante A – Docker (nichts vorinstallieren außer Docker):**
|
||||||
|
|
||||||
## Learn More
|
```bash
|
||||||
|
docker compose --env-file .env.local --profile dev up dev
|
||||||
|
# http://localhost:3000 (Hot-Reload, Quellcode wird gemountet)
|
||||||
|
```
|
||||||
|
|
||||||
To learn more about Next.js, take a look at the following resources:
|
**Variante B – lokal installiertes Node.js:**
|
||||||
|
|
||||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
```bash
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
npm install
|
||||||
|
npm run dev # http://localhost:3000
|
||||||
|
```
|
||||||
|
|
||||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
## Umgebungsvariablen (Live-Betrieb)
|
||||||
|
|
||||||
## Deploy on Vercel
|
Beim Webserver/Hoster müssen genau diese drei Variablen hinterlegt werden
|
||||||
|
(Werte: Supabase-Dashboard → Project Settings → API Keys):
|
||||||
|
|
||||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
| Variable | Bedeutung | Geheim? |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `NEXT_PUBLIC_SUPABASE_URL` | URL des Supabase-Projekts, z. B. `https://hvzikwjoyhoakedlywhj.supabase.co` | nein |
|
||||||
|
| `NEXT_PUBLIC_SUPABASE_ANON_KEY` | Öffentlicher anon-Key (RLS schützt die Daten) | nein |
|
||||||
|
| `SUPABASE_SERVICE_ROLE_KEY` | Service-Role-Key für die Benutzerverwaltung | **ja – niemals im Browser/Repo!** |
|
||||||
|
|
||||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
Build & Start auf dem Server – **mit Docker** (empfohlen, Node.js und
|
||||||
|
PostgreSQL müssen nicht installiert sein):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.local up -d --build
|
||||||
|
# App: Port 3000 (hinter Reverse-Proxy mit HTTPS betreiben)
|
||||||
|
# Lokale DB-Kopie: Port 5433 (Dienst "db", optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
Oder klassisch ohne Docker:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm ci
|
||||||
|
npm run build
|
||||||
|
npm run start # Port 3000 (hinter Reverse-Proxy betreiben)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Passkeys für die Produktions-Domain freischalten
|
||||||
|
|
||||||
|
Passkeys sind an die Domain gebunden. Aktuell ist `localhost` konfiguriert.
|
||||||
|
Vor dem Livegang in der Supabase-Konfiguration (Management API oder
|
||||||
|
Dashboard → Authentication → Settings) anpassen:
|
||||||
|
|
||||||
|
- `webauthn_rp_id` = `speisekarte.example.de` (die echte Domain, ohne Protokoll)
|
||||||
|
- `webauthn_rp_origins` = `https://speisekarte.example.de`
|
||||||
|
- `passkey_enabled` = `true` (bereits aktiv)
|
||||||
|
|
||||||
|
## Datenbank
|
||||||
|
|
||||||
|
### Supabase (führende Datenbank)
|
||||||
|
|
||||||
|
- Projekt-Ref: `hvzikwjoyhoakedlywhj` (Region `eu-central-1`, Frankfurt)
|
||||||
|
- Schema und Seed-Daten: `supabase/migrations/0001_init.sql` und `0002_seed.sql`
|
||||||
|
- Ausführen weiterer SQL-Dateien:
|
||||||
|
`SUPABASE_ACCESS_TOKEN=<token> node scripts/db-apply.mjs <datei.sql>`
|
||||||
|
|
||||||
|
Sicherheitsmodell (Row Level Security):
|
||||||
|
|
||||||
|
- `anon` darf ausschließlich lesen (Speisekarte, Bereiche, Allergene,
|
||||||
|
Zusatzstoffe, Einstellungen).
|
||||||
|
- `staff`/`admin` (Rolle im `profiles`-Eintrag) pflegen Gerichte,
|
||||||
|
Wochenplan und Kennzeichnungen.
|
||||||
|
- Nur `admin` ändert Einstellungen (Farben/Logo) und verwaltet Benutzer;
|
||||||
|
das Anlegen/Löschen von Benutzern läuft serverseitig über den
|
||||||
|
Service-Role-Key (`app/api/admin/users`).
|
||||||
|
- Storage-Bucket `branding` (Logo): öffentlich lesbar, Schreibzugriff nur Admin.
|
||||||
|
|
||||||
|
### Lokale PostgreSQL-Kopie
|
||||||
|
|
||||||
|
Die Datenbank `speisekarte` (Kopie von Schema + Daten aus Supabase, inkl.
|
||||||
|
Minimal-Stubs für `auth`/`storage`) gibt es in zwei Varianten:
|
||||||
|
|
||||||
|
**Per Docker:** Der Dienst `db` aus `docker-compose.yml` spielt die
|
||||||
|
Skripte aus `supabase/local/` beim ersten Start automatisch ein
|
||||||
|
(PostgreSQL 17, Host-Port 5433 – 5432 ist meist durch eine native
|
||||||
|
PostgreSQL-Installation belegt –, Passwort über `LOCAL_PG_PASSWORD`).
|
||||||
|
|
||||||
|
**Nativ:** Auf dem Entwicklungsrechner läuft PostgreSQL 17 mit derselben
|
||||||
|
Datenbank. Neu aufbauen bzw. aktualisieren:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$env:SUPABASE_DB_PASSWORD = "<Supabase-DB-Passwort>"
|
||||||
|
$env:LOCAL_PG_PASSWORD = "<lokales postgres-Passwort>"
|
||||||
|
powershell -File scripts\sync-local-db.ps1
|
||||||
|
```
|
||||||
|
|
||||||
|
Hinweis: Die lokale Kopie dient als Ablage/Backup und für Auswertungen.
|
||||||
|
Logins (Passwort-Hashes, Passkeys) verbleiben ausschließlich in Supabase.
|
||||||
|
|
||||||
|
## Zugangsdaten
|
||||||
|
|
||||||
|
Die Anmeldedaten (Admin, Demo-Mitarbeiter, Datenbank-Passwörter) stehen in
|
||||||
|
`ZUGANGSDATEN.md` – diese Datei ist bewusst **nicht** im Repository
|
||||||
|
(`.gitignore`). Nach dem ersten Login bitte die Startpasswörter ändern.
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import DesignEditor from "@/components/DesignEditor";
|
||||||
|
|
||||||
|
export default function DesignPage() {
|
||||||
|
return <DesignEditor />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export default async function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("role, full_name")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
if (profile?.role !== "admin") redirect("/intern");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<header className="bg-[var(--c-primary-dark)] text-white">
|
||||||
|
<div className="max-w-[1600px] mx-auto w-full px-4 py-3 flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||||
|
<span className="font-bold">Verwaltung</span>
|
||||||
|
<nav className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
||||||
|
<Link href="/admin" className="hover:underline">
|
||||||
|
Benutzer
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/design" className="hover:underline">
|
||||||
|
Design
|
||||||
|
</Link>
|
||||||
|
<Link href="/intern" className="hover:underline">
|
||||||
|
Kantinen-Bereich
|
||||||
|
</Link>
|
||||||
|
<Link href="/" className="hover:underline">
|
||||||
|
Zur Speisekarte
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-white/70">
|
||||||
|
{profile.full_name || user.email}
|
||||||
|
</span>
|
||||||
|
<form action="/logout" method="post">
|
||||||
|
<button type="submit" className="btn btn-del">
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import UserManager from "@/components/UserManager";
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
return <UserManager />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { createAdminClient } from "@/lib/supabase/admin";
|
||||||
|
import { isSameOrigin } from "@/lib/origin";
|
||||||
|
|
||||||
|
// Benutzerverwaltung (nur Admin). Läuft serverseitig mit dem
|
||||||
|
// Service-Role-Key, da das Anlegen/Löschen von Auth-Benutzern
|
||||||
|
// nicht über den anon key möglich ist.
|
||||||
|
|
||||||
|
async function requireAdmin(request: Request) {
|
||||||
|
if (!isSameOrigin(request)) return null;
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) return null;
|
||||||
|
|
||||||
|
const admin = createAdminClient();
|
||||||
|
const { data: profile } = await admin
|
||||||
|
.from("profiles")
|
||||||
|
.select("role")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
if (profile?.role !== "admin") return null;
|
||||||
|
return { user, admin };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const ctx = await requireAdmin(request);
|
||||||
|
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const { email, password, full_name, role } = body ?? {};
|
||||||
|
if (!email || !password || !["admin", "staff"].includes(role)) {
|
||||||
|
return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (String(password).length < 8) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Das Passwort muss mindestens 8 Zeichen lang sein." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data, error } = await ctx.admin.auth.admin.createUser({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
email_confirm: true,
|
||||||
|
user_metadata: { full_name: full_name ?? "", role },
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: /already been registered/i.test(error.message)
|
||||||
|
? "Diese E-Mail-Adresse ist bereits registriert."
|
||||||
|
: error.message,
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return NextResponse.json({ id: data.user?.id });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(request: Request) {
|
||||||
|
const ctx = await requireAdmin(request);
|
||||||
|
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const { id, full_name, role, password } = body ?? {};
|
||||||
|
if (!id || (role && !["admin", "staff"].includes(role))) {
|
||||||
|
return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
|
||||||
|
}
|
||||||
|
// Der Admin darf sich nicht selbst die Admin-Rolle entziehen,
|
||||||
|
// sonst sperrt er sich aus der Verwaltung aus.
|
||||||
|
if (id === ctx.user.id && role === "staff") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Du kannst dir nicht selbst die Admin-Rolle entziehen." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (password) {
|
||||||
|
if (String(password).length < 8) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Das Passwort muss mindestens 8 Zeichen lang sein." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { error } = await ctx.admin.auth.admin.updateUserById(id, { password });
|
||||||
|
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates: Record<string, string> = {};
|
||||||
|
if (typeof full_name === "string") updates.full_name = full_name;
|
||||||
|
if (role) updates.role = role;
|
||||||
|
if (Object.keys(updates).length > 0) {
|
||||||
|
const { error } = await ctx.admin.from("profiles").update(updates).eq("id", id);
|
||||||
|
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(request: Request) {
|
||||||
|
const ctx = await requireAdmin(request);
|
||||||
|
if (!ctx) return NextResponse.json({ error: "Nicht berechtigt." }, { status: 403 });
|
||||||
|
|
||||||
|
const body = await request.json().catch(() => null);
|
||||||
|
const { id } = body ?? {};
|
||||||
|
if (!id) return NextResponse.json({ error: "Ungültige Eingaben." }, { status: 400 });
|
||||||
|
if (id === ctx.user.id) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Du kannst dich nicht selbst löschen." },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await ctx.admin.auth.admin.deleteUser(id);
|
||||||
|
if (error) return NextResponse.json({ error: error.message }, { status: 400 });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
+103
-19
@@ -1,26 +1,110 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/* Farb-Variablen: Defaults entsprechen r-hofmann.de; werden zur Laufzeit
|
||||||
|
aus den Einstellungen (Supabase settings.colors) überschrieben. */
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--c-primary: #003388;
|
||||||
--foreground: #171717;
|
--c-primary-dark: #0d315c;
|
||||||
}
|
--c-accent: #ff6900;
|
||||||
|
--c-header-text: #ffffff;
|
||||||
@theme inline {
|
--c-row-bg: #e3e4f1;
|
||||||
--color-background: var(--background);
|
--c-row-bg-alt: #f4f4fa;
|
||||||
--color-foreground: var(--foreground);
|
--c-add: #6da54c;
|
||||||
--font-sans: var(--font-geist-sans);
|
--c-del: #e2371d;
|
||||||
--font-mono: var(--font-geist-mono);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
|
||||||
:root {
|
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
background: var(--background);
|
background: #ffffff;
|
||||||
color: var(--foreground);
|
color: #1a1a1a;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
font-family: var(--font-geist-sans), Arial, Helvetica, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Buttons ---------- */
|
||||||
|
.btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 9999px;
|
||||||
|
padding: 0.35rem 1rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid rgb(0 0 0 / 0.15);
|
||||||
|
transition: filter 0.15s ease;
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn:hover { filter: brightness(1.08); }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-add { background: var(--c-add); }
|
||||||
|
.btn-del { background: var(--c-del); }
|
||||||
|
.btn-primary { background: var(--c-primary); }
|
||||||
|
.btn-neutral { background: #6b7280; }
|
||||||
|
|
||||||
|
/* ---------- Formulare ---------- */
|
||||||
|
.input {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
.input:focus { outline: 2px solid var(--c-primary); outline-offset: 0; }
|
||||||
|
.label {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
.card {
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: 0 1px 2px rgb(0 0 0 / 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- Speisekarten-Tabelle ---------- */
|
||||||
|
.menu-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: separate;
|
||||||
|
border-spacing: 2px;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
.menu-table th.day-head {
|
||||||
|
background: var(--c-primary);
|
||||||
|
color: var(--c-header-text);
|
||||||
|
padding: 0.5rem 0.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.menu-table th.section-head {
|
||||||
|
background: var(--c-primary);
|
||||||
|
color: var(--c-header-text);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: middle;
|
||||||
|
padding: 0.6rem 0.8rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
.menu-table th.section-head .time {
|
||||||
|
font-weight: 400;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.menu-table td.cell {
|
||||||
|
background: var(--c-row-bg);
|
||||||
|
vertical-align: top;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.menu-table tr:nth-child(even) td.cell { background: var(--c-row-bg-alt); }
|
||||||
|
.menu-table td.cell .dish { margin-bottom: 0.35rem; }
|
||||||
|
.menu-table td.cell .dish sup {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
color: #4b5563;
|
||||||
|
margin-left: 0.15rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import DishManager from "@/components/DishManager";
|
||||||
|
|
||||||
|
export default function GerichtePage() {
|
||||||
|
return <DishManager />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import CodeListManager from "@/components/CodeListManager";
|
||||||
|
|
||||||
|
export default function KennzeichnungPage() {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
|
<CodeListManager
|
||||||
|
table="allergens"
|
||||||
|
title="Allergene"
|
||||||
|
codeLabel="Kürzel (z. B. A)"
|
||||||
|
/>
|
||||||
|
<CodeListManager
|
||||||
|
table="additives"
|
||||||
|
title="Zusatzstoffe"
|
||||||
|
codeLabel="Kürzel (z. B. 1)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import PasskeyManager from "@/components/PasskeyManager";
|
||||||
|
|
||||||
|
export default function KontoPage() {
|
||||||
|
return <PasskeyManager />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
export default async function InternLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) redirect("/login");
|
||||||
|
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("role, full_name")
|
||||||
|
.eq("id", user.id)
|
||||||
|
.single();
|
||||||
|
if (!profile || !["staff", "admin"].includes(profile.role)) redirect("/");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex flex-col">
|
||||||
|
<header className="bg-[var(--c-primary-dark)] text-white">
|
||||||
|
<div className="max-w-[1600px] mx-auto w-full px-4 py-3 flex flex-wrap items-center gap-x-5 gap-y-2">
|
||||||
|
<span className="font-bold">Kantinen-Bereich</span>
|
||||||
|
<nav className="flex flex-wrap gap-x-4 gap-y-1 text-sm">
|
||||||
|
<Link href="/intern" className="hover:underline">
|
||||||
|
Wochenplan
|
||||||
|
</Link>
|
||||||
|
<Link href="/intern/gerichte" className="hover:underline">
|
||||||
|
Gerichte
|
||||||
|
</Link>
|
||||||
|
<Link href="/intern/kennzeichnung" className="hover:underline">
|
||||||
|
Allergene & Zusatzstoffe
|
||||||
|
</Link>
|
||||||
|
<Link href="/intern/konto" className="hover:underline">
|
||||||
|
Passkeys
|
||||||
|
</Link>
|
||||||
|
{profile.role === "admin" && (
|
||||||
|
<Link href="/admin" className="hover:underline">
|
||||||
|
Verwaltung
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<Link href="/" className="hover:underline">
|
||||||
|
Zur Speisekarte
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
|
<div className="ml-auto flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-white/70">
|
||||||
|
{profile.full_name || user.email}
|
||||||
|
</span>
|
||||||
|
<form action="/logout" method="post">
|
||||||
|
<button type="submit" className="btn btn-del">
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="max-w-[1600px] mx-auto w-full px-4 py-6 flex-1">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { mondayOf } from "@/lib/menu";
|
||||||
|
import WeekEditor from "@/components/WeekEditor";
|
||||||
|
|
||||||
|
export default async function InternPage({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ woche?: string }>;
|
||||||
|
}) {
|
||||||
|
const { woche } = await searchParams;
|
||||||
|
return <WeekEditor monday={mondayOf(woche)} />;
|
||||||
|
}
|
||||||
+28
-14
@@ -1,33 +1,47 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { Geist, Geist_Mono } from "next/font/google";
|
import { Geist } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { COLOR_VARS, DEFAULT_COLORS } from "@/lib/types";
|
||||||
|
|
||||||
|
// Farben/Logo sollen Änderungen sofort widerspiegeln.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
variable: "--font-geist-sans",
|
variable: "--font-geist-sans",
|
||||||
subsets: ["latin"],
|
subsets: ["latin"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const geistMono = Geist_Mono({
|
|
||||||
variable: "--font-geist-mono",
|
|
||||||
subsets: ["latin"],
|
|
||||||
});
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Speisekarte",
|
||||||
description: "Generated by create next app",
|
description: "Speisekarte der Betriebskantine",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default async function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data: settings } = await supabase
|
||||||
|
.from("settings")
|
||||||
|
.select("colors")
|
||||||
|
.eq("id", 1)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
const colors: Record<string, string> = {
|
||||||
|
...DEFAULT_COLORS,
|
||||||
|
...((settings?.colors as Record<string, string>) ?? {}),
|
||||||
|
};
|
||||||
|
const style = Object.fromEntries(
|
||||||
|
Object.entries(COLOR_VARS).map(([key, cssVar]) => [cssVar, colors[key]])
|
||||||
|
) as React.CSSProperties;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html
|
<html lang="de" className={`${geistSans.variable} h-full antialiased`}>
|
||||||
lang="en"
|
<body style={style} className="min-h-full flex flex-col">
|
||||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
{children}
|
||||||
>
|
</body>
|
||||||
<body className="min-h-full flex flex-col">{children}</body>
|
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
|
||||||
|
function germanAuthError(message: string): string {
|
||||||
|
if (/invalid login credentials/i.test(message))
|
||||||
|
return "E-Mail-Adresse oder Passwort ist falsch.";
|
||||||
|
if (/email not confirmed/i.test(message))
|
||||||
|
return "Die E-Mail-Adresse wurde noch nicht bestätigt.";
|
||||||
|
if (/cancelled|aborted|NotAllowedError/i.test(message))
|
||||||
|
return "Passkey-Anmeldung wurde abgebrochen.";
|
||||||
|
return `Anmeldung fehlgeschlagen: ${message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const supabase = createClient();
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
async function redirectByRole(userId: string) {
|
||||||
|
const { data: profile } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("role")
|
||||||
|
.eq("id", userId)
|
||||||
|
.single();
|
||||||
|
router.replace(profile?.role === "admin" ? "/admin" : "/intern");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInWithPassword(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
const { data, error } = await supabase.auth.signInWithPassword({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
if (error || !data.user) {
|
||||||
|
setError(germanAuthError(error?.message ?? "Unbekannter Fehler"));
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await redirectByRole(data.user.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function signInWithPasskey() {
|
||||||
|
setError(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase.auth.signInWithPasskey();
|
||||||
|
if (error || !data?.user) {
|
||||||
|
setError(
|
||||||
|
germanAuthError(error?.message ?? "Passkey-Anmeldung fehlgeschlagen")
|
||||||
|
);
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await redirectByRole(data.user.id);
|
||||||
|
} catch (err) {
|
||||||
|
setError(germanAuthError(err instanceof Error ? err.message : String(err)));
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-4">
|
||||||
|
<div className="card w-full max-w-sm">
|
||||||
|
<h1 className="text-xl font-bold text-[var(--c-primary)] mb-4">
|
||||||
|
Anmeldung
|
||||||
|
</h1>
|
||||||
|
<form onSubmit={signInWithPassword} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="email">
|
||||||
|
E-Mail-Adresse
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
className="input"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
autoComplete="username webauthn"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="password">
|
||||||
|
Passwort
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-sm text-[var(--c-del)]">{error}</p>}
|
||||||
|
<button type="submit" className="btn btn-primary w-full" disabled={busy}>
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="my-4 flex items-center gap-3 text-xs text-gray-400">
|
||||||
|
<span className="h-px flex-1 bg-gray-200" />
|
||||||
|
oder
|
||||||
|
<span className="h-px flex-1 bg-gray-200" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={signInWithPasskey}
|
||||||
|
className="btn btn-neutral w-full"
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Mit Passkey anmelden
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<p className="mt-4 text-center text-sm">
|
||||||
|
<Link href="/" className="text-gray-500 hover:text-[var(--c-primary)]">
|
||||||
|
← Zur Speisekarte
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import { isSameOrigin } from "@/lib/origin";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
if (!isSameOrigin(request)) {
|
||||||
|
return NextResponse.json({ error: "Nicht erlaubt." }, { status: 403 });
|
||||||
|
}
|
||||||
|
const supabase = await createClient();
|
||||||
|
await supabase.auth.signOut();
|
||||||
|
return NextResponse.redirect(new URL("/login", request.url), { status: 303 });
|
||||||
|
}
|
||||||
+140
-57
@@ -1,65 +1,148 @@
|
|||||||
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
import {
|
||||||
|
addDays,
|
||||||
|
formatDateLong,
|
||||||
|
formatPrice,
|
||||||
|
mondayOf,
|
||||||
|
WEEKDAYS,
|
||||||
|
} from "@/lib/menu";
|
||||||
|
import { logoUrl } from "@/lib/types";
|
||||||
|
import type { CodeItem, MenuEntry, Section } from "@/lib/types";
|
||||||
|
|
||||||
|
export default async function Home({
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
searchParams: Promise<{ woche?: string }>;
|
||||||
|
}) {
|
||||||
|
const { woche } = await searchParams;
|
||||||
|
const monday = mondayOf(woche);
|
||||||
|
const friday = addDays(monday, 4);
|
||||||
|
const days = WEEKDAYS.map((weekday, i) => ({
|
||||||
|
weekday,
|
||||||
|
iso: addDays(monday, i),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const [sectionsRes, entriesRes, allergensRes, additivesRes, settingsRes] =
|
||||||
|
await Promise.all([
|
||||||
|
supabase.from("sections").select("*").order("sort"),
|
||||||
|
supabase
|
||||||
|
.from("menu_entries")
|
||||||
|
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)")
|
||||||
|
.gte("menu_date", monday)
|
||||||
|
.lte("menu_date", friday)
|
||||||
|
.order("position")
|
||||||
|
.order("created_at"),
|
||||||
|
supabase.from("allergens").select("*").order("sort"),
|
||||||
|
supabase.from("additives").select("*").order("sort"),
|
||||||
|
supabase.from("settings").select("logo_path").eq("id", 1).maybeSingle(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const sections = (sectionsRes.data ?? []) as Section[];
|
||||||
|
const entries = (entriesRes.data ?? []) as unknown as MenuEntry[];
|
||||||
|
const allergens = (allergensRes.data ?? []) as CodeItem[];
|
||||||
|
const additives = (additivesRes.data ?? []) as CodeItem[];
|
||||||
|
const logo = logoUrl(settingsRes.data?.logo_path);
|
||||||
|
|
||||||
|
const cell = (iso: string, sectionKey: string) =>
|
||||||
|
entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
|
||||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
{/* Kopfbereich: Logo zentriert, Login-Link rechts */}
|
||||||
<Image
|
<header className="relative mb-6">
|
||||||
className="dark:invert"
|
<div className="flex justify-center">
|
||||||
src="/next.svg"
|
{logo ? (
|
||||||
alt="Next.js logo"
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
width={100}
|
<img src={logo} alt="Logo" className="h-20 object-contain" />
|
||||||
height={20}
|
) : (
|
||||||
priority
|
<h1 className="text-4xl font-extrabold tracking-tight text-[var(--c-primary)]">
|
||||||
/>
|
Speisekarte
|
||||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
</h1>
|
||||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
)}
|
||||||
To get started, edit the page.tsx file.
|
|
||||||
</h1>
|
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
|
||||||
<a
|
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Templates
|
|
||||||
</a>{" "}
|
|
||||||
or the{" "}
|
|
||||||
<a
|
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
|
||||||
>
|
|
||||||
Learning
|
|
||||||
</a>{" "}
|
|
||||||
center.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
<Link
|
||||||
<a
|
href="/login"
|
||||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
className="absolute right-0 top-0 text-sm text-gray-500 hover:text-[var(--c-primary)]"
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
>
|
||||||
target="_blank"
|
Anmelden
|
||||||
rel="noopener noreferrer"
|
</Link>
|
||||||
>
|
</header>
|
||||||
<Image
|
|
||||||
className="dark:invert"
|
{/* Wochen-Navigation */}
|
||||||
src="/vercel.svg"
|
<div className="flex gap-3 mb-4">
|
||||||
alt="Vercel logomark"
|
<Link href={`/?woche=${addDays(monday, -7)}`} className="btn btn-add">
|
||||||
width={16}
|
← Vorherige Woche
|
||||||
height={16}
|
</Link>
|
||||||
/>
|
<Link href={`/?woche=${addDays(monday, 7)}`} className="btn btn-add">
|
||||||
Deploy Now
|
Nächste Woche →
|
||||||
</a>
|
</Link>
|
||||||
<a
|
</div>
|
||||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
{/* Speisekarte */}
|
||||||
target="_blank"
|
<table className="menu-table">
|
||||||
rel="noopener noreferrer"
|
<thead>
|
||||||
>
|
<tr>
|
||||||
Documentation
|
<th className="section-head w-56" aria-hidden />
|
||||||
</a>
|
{days.map((d) => (
|
||||||
|
<th key={d.iso} className="day-head">
|
||||||
|
<span className="font-bold">{d.weekday}</span>
|
||||||
|
<br />
|
||||||
|
{formatDateLong(d.iso)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sections.map((s) => (
|
||||||
|
<tr key={s.key}>
|
||||||
|
<th className="section-head">
|
||||||
|
<div>{s.label}</div>
|
||||||
|
<div className="time">{s.time_range}</div>
|
||||||
|
</th>
|
||||||
|
{days.map((d) => (
|
||||||
|
<td key={d.iso} className="cell">
|
||||||
|
{cell(d.iso, s.key).map((e) => (
|
||||||
|
<div key={e.id} className="dish">
|
||||||
|
<span className="font-bold">{e.dish.name}</span>
|
||||||
|
{" – "}
|
||||||
|
{formatPrice(e.dish.price)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{/* Allergene & Zusatzstoffe (gelten für die komplette Speisekarte) */}
|
||||||
|
<section className="mt-8 grid gap-6 sm:grid-cols-2 text-sm text-gray-700">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-bold text-[var(--c-primary)] mb-2">Allergene</h2>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{allergens.map((a) => (
|
||||||
|
<li key={a.code}>
|
||||||
|
<span className="font-semibold">{a.code}</span> – {a.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
<div>
|
||||||
|
<h2 className="font-bold text-[var(--c-primary)] mb-2">
|
||||||
|
Zusatzstoffe
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{additives.map((a) => (
|
||||||
|
<li key={a.code}>
|
||||||
|
<span className="font-semibold">{a.code}</span> – {a.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// Pflege der globalen Allergen-/Zusatzstofflisten, die unterhalb
|
||||||
|
// der öffentlichen Speisekarte angezeigt werden.
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import type { CodeItem } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function CodeListManager({
|
||||||
|
table,
|
||||||
|
title,
|
||||||
|
codeLabel,
|
||||||
|
}: {
|
||||||
|
table: "allergens" | "additives";
|
||||||
|
title: string;
|
||||||
|
codeLabel: string;
|
||||||
|
}) {
|
||||||
|
const supabase = useMemo(() => createClient(), []);
|
||||||
|
const [items, setItems] = useState<CodeItem[]>([]);
|
||||||
|
const [editing, setEditing] = useState<{
|
||||||
|
original: string | null; // code vor der Bearbeitung, null = neu
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
} | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase.from(table).select("*").order("sort");
|
||||||
|
if (error) setError(`Laden fehlgeschlagen: ${error.message}`);
|
||||||
|
else setItems(data as CodeItem[]);
|
||||||
|
}, [supabase, table]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function save(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!editing) return;
|
||||||
|
setError(null);
|
||||||
|
const code = editing.code.trim();
|
||||||
|
const name = editing.name.trim();
|
||||||
|
if (!code || !name) return;
|
||||||
|
|
||||||
|
let dbError;
|
||||||
|
if (editing.original) {
|
||||||
|
({ error: dbError } = await supabase
|
||||||
|
.from(table)
|
||||||
|
.update({ code, name })
|
||||||
|
.eq("code", editing.original));
|
||||||
|
} else {
|
||||||
|
const maxSort = items.reduce((m, i) => Math.max(m, i.sort), 0);
|
||||||
|
({ error: dbError } = await supabase
|
||||||
|
.from(table)
|
||||||
|
.insert({ code, name, sort: maxSort + 1 }));
|
||||||
|
}
|
||||||
|
if (dbError) {
|
||||||
|
setError(
|
||||||
|
/duplicate key/i.test(dbError.message)
|
||||||
|
? `Das Kürzel „${code}“ existiert bereits.`
|
||||||
|
: `Speichern fehlgeschlagen: ${dbError.message}`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEditing(null);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(item: CodeItem) {
|
||||||
|
if (!window.confirm(`„${item.code} – ${item.name}“ wirklich löschen?`))
|
||||||
|
return;
|
||||||
|
const { error } = await supabase.from(table).delete().eq("code", item.code);
|
||||||
|
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="card">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<h2 className="text-lg font-bold text-[var(--c-primary)] mr-auto">
|
||||||
|
{title}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-add"
|
||||||
|
onClick={() => setEditing({ original: null, code: "", name: "" })}
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mb-2 text-sm text-[var(--c-del)]" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editing && (
|
||||||
|
<form onSubmit={save} className="mb-3 space-y-2 border-b border-gray-200 pb-3">
|
||||||
|
<div className="max-w-36">
|
||||||
|
<label className="label">{codeLabel}</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={editing.code}
|
||||||
|
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">Bezeichnung</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={editing.name}
|
||||||
|
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-add">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-neutral"
|
||||||
|
onClick={() => setEditing(null)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="divide-y divide-gray-100">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.code} className="flex items-center gap-2 py-1.5 text-sm">
|
||||||
|
<span className="mr-auto">
|
||||||
|
<span className="font-semibold">{item.code}</span> – {item.name}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() =>
|
||||||
|
setEditing({
|
||||||
|
original: item.code,
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-del"
|
||||||
|
onClick={() => remove(item)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{items.length === 0 && (
|
||||||
|
<li className="py-2 text-gray-500 text-sm">Keine Einträge.</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { COLOR_LABELS, DEFAULT_COLORS, logoUrl } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function DesignEditor() {
|
||||||
|
const supabase = useMemo(() => createClient(), []);
|
||||||
|
const router = useRouter();
|
||||||
|
const [colors, setColors] = useState<Record<string, string>>(DEFAULT_COLORS);
|
||||||
|
const [logoPath, setLogoPath] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [info, setInfo] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("settings")
|
||||||
|
.select("colors, logo_path")
|
||||||
|
.eq("id", 1)
|
||||||
|
.maybeSingle();
|
||||||
|
if (error) {
|
||||||
|
setError(`Laden fehlgeschlagen: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) });
|
||||||
|
setLogoPath(data?.logo_path ?? null);
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function saveColors() {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("settings")
|
||||||
|
.update({ colors })
|
||||||
|
.eq("id", 1);
|
||||||
|
if (error) setError(`Speichern fehlgeschlagen: ${error.message}`);
|
||||||
|
else {
|
||||||
|
setInfo("Farben gespeichert.");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetColors() {
|
||||||
|
setColors(DEFAULT_COLORS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadLogo(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
e.target.value = "";
|
||||||
|
if (!file) return;
|
||||||
|
if (!/^image\//.test(file.type)) {
|
||||||
|
setError("Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
|
||||||
|
const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
|
||||||
|
const path = `logo-${Date.now()}.${ext}`;
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from("branding")
|
||||||
|
.upload(path, file, { contentType: file.type });
|
||||||
|
if (uploadError) {
|
||||||
|
setError(`Upload fehlgeschlagen: ${uploadError.message}`);
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldPath = logoPath;
|
||||||
|
const { error: settingsError } = await supabase
|
||||||
|
.from("settings")
|
||||||
|
.update({ logo_path: path })
|
||||||
|
.eq("id", 1);
|
||||||
|
if (settingsError) {
|
||||||
|
setError(`Speichern fehlgeschlagen: ${settingsError.message}`);
|
||||||
|
setBusy(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldPath) {
|
||||||
|
await supabase.storage.from("branding").remove([oldPath]);
|
||||||
|
}
|
||||||
|
setLogoPath(path);
|
||||||
|
setInfo("Logo aktualisiert.");
|
||||||
|
router.refresh();
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeLogo() {
|
||||||
|
if (!logoPath) return;
|
||||||
|
if (!window.confirm("Logo wirklich entfernen?")) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
const { error } = await supabase
|
||||||
|
.from("settings")
|
||||||
|
.update({ logo_path: null })
|
||||||
|
.eq("id", 1);
|
||||||
|
if (error) {
|
||||||
|
setError(`Speichern fehlgeschlagen: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
await supabase.storage.from("branding").remove([logoPath]);
|
||||||
|
setLogoPath(null);
|
||||||
|
setInfo("Logo entfernt.");
|
||||||
|
router.refresh();
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLogo = logoUrl(logoPath);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl space-y-6">
|
||||||
|
<h1 className="text-xl font-bold text-[var(--c-primary)]">Design</h1>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="text-sm text-[var(--c-del)]" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{info && <p className="text-sm text-green-700">{info}</p>}
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="font-semibold mb-3">Logo</h2>
|
||||||
|
<p className="text-sm text-gray-600 mb-3">
|
||||||
|
Das Logo wird zentriert über der Speisekarte angezeigt.
|
||||||
|
</p>
|
||||||
|
{currentLogo ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={currentLogo}
|
||||||
|
alt="Aktuelles Logo"
|
||||||
|
className="h-20 object-contain mb-3 border border-gray-200 rounded p-2"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400 mb-3">Kein Logo hinterlegt.</p>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<label className="btn btn-primary cursor-pointer">
|
||||||
|
Logo hochladen
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={uploadLogo}
|
||||||
|
disabled={busy}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{currentLogo && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-del"
|
||||||
|
onClick={removeLogo}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Logo entfernen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card">
|
||||||
|
<h2 className="font-semibold mb-3">Farbgebung</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{Object.entries(COLOR_LABELS).map(([key, label]) => (
|
||||||
|
<label key={key} className="flex items-center gap-3 text-sm">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={colors[key] ?? "#000000"}
|
||||||
|
onChange={(e) =>
|
||||||
|
setColors({ ...colors, [key]: e.target.value })
|
||||||
|
}
|
||||||
|
className="h-9 w-14 cursor-pointer border border-gray-300 rounded"
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-add"
|
||||||
|
onClick={saveColors}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Farben speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-neutral"
|
||||||
|
onClick={resetColors}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Auf Standard zurücksetzen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { formatPrice } from "@/lib/menu";
|
||||||
|
import type { Dish } from "@/lib/types";
|
||||||
|
|
||||||
|
interface DishForm {
|
||||||
|
id: string | null; // null = neues Gericht
|
||||||
|
name: string;
|
||||||
|
price: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: DishForm = { id: null, name: "", price: "" };
|
||||||
|
|
||||||
|
export default function DishManager() {
|
||||||
|
const supabase = useMemo(() => createClient(), []);
|
||||||
|
const [dishes, setDishes] = useState<Dish[]>([]);
|
||||||
|
const [form, setForm] = useState<DishForm | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("dishes")
|
||||||
|
.select("*")
|
||||||
|
.order("name");
|
||||||
|
if (error) setError(`Laden fehlgeschlagen: ${error.message}`);
|
||||||
|
else setDishes(data as Dish[]);
|
||||||
|
setLoading(false);
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function save(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form) return;
|
||||||
|
setError(null);
|
||||||
|
const price = Number.parseFloat(form.price.replace(",", "."));
|
||||||
|
if (Number.isNaN(price) || price < 0) {
|
||||||
|
setError("Bitte einen gültigen Preis angeben, z. B. 3,50");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = { name: form.name.trim(), price };
|
||||||
|
const { error } = form.id
|
||||||
|
? await supabase.from("dishes").update(payload).eq("id", form.id)
|
||||||
|
: await supabase.from("dishes").insert(payload);
|
||||||
|
if (error) {
|
||||||
|
setError(`Speichern fehlgeschlagen: ${error.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setForm(null);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(dish: Dish) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`„${dish.name}“ wirklich löschen? Das Gericht wird auch aus allen Wochenplänen entfernt.`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
const { error } = await supabase.from("dishes").delete().eq("id", dish.id);
|
||||||
|
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
|
||||||
|
Gerichte
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-add"
|
||||||
|
onClick={() => setForm(EMPTY_FORM)}
|
||||||
|
>
|
||||||
|
Neues Gericht
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form && (
|
||||||
|
<form onSubmit={save} className="card mb-4 space-y-3">
|
||||||
|
<h2 className="font-semibold">
|
||||||
|
{form.id ? "Gericht bearbeiten" : "Neues Gericht"}
|
||||||
|
</h2>
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="dish-name">
|
||||||
|
Bezeichnung
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dish-name"
|
||||||
|
className="input"
|
||||||
|
value={form.name}
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-40">
|
||||||
|
<label className="label" htmlFor="dish-price">
|
||||||
|
Preis in €
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="dish-price"
|
||||||
|
className="input"
|
||||||
|
inputMode="decimal"
|
||||||
|
placeholder="3,50"
|
||||||
|
value={form.price}
|
||||||
|
onChange={(e) => setForm({ ...form, price: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-add">
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-neutral"
|
||||||
|
onClick={() => setForm(null)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500">Lade Gerichte…</p>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
|
||||||
|
{dishes.map((dish) => (
|
||||||
|
<li key={dish.id} className="flex items-center gap-3 px-4 py-2">
|
||||||
|
<span className="mr-auto">
|
||||||
|
<span className="font-semibold">{dish.name}</span>{" "}
|
||||||
|
<span className="text-gray-500">– {formatPrice(dish.price)}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() =>
|
||||||
|
setForm({
|
||||||
|
id: dish.id,
|
||||||
|
name: dish.name,
|
||||||
|
price: String(dish.price).replace(".", ","),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-del"
|
||||||
|
onClick={() => remove(dish)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{dishes.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-gray-500">
|
||||||
|
Noch keine Gerichte angelegt.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
|
||||||
|
interface PasskeyItem {
|
||||||
|
id: string;
|
||||||
|
friendly_name?: string;
|
||||||
|
created_at: string;
|
||||||
|
last_used_at?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PasskeyManager() {
|
||||||
|
const supabase = useMemo(() => createClient(), []);
|
||||||
|
const [passkeys, setPasskeys] = useState<PasskeyItem[]>([]);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [info, setInfo] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase.auth.passkey.list();
|
||||||
|
if (error) setError(`Passkeys konnten nicht geladen werden: ${error.message}`);
|
||||||
|
else setPasskeys((data ?? []) as PasskeyItem[]);
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function register() {
|
||||||
|
setError(null);
|
||||||
|
setInfo(null);
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const { error } = await supabase.auth.registerPasskey();
|
||||||
|
if (error) {
|
||||||
|
setError(`Registrierung fehlgeschlagen: ${error.message}`);
|
||||||
|
} else {
|
||||||
|
setInfo("Passkey wurde erfolgreich registriert.");
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setError(
|
||||||
|
/NotAllowedError|abort/i.test(String(err))
|
||||||
|
? "Die Passkey-Registrierung wurde abgebrochen."
|
||||||
|
: `Registrierung fehlgeschlagen: ${err instanceof Error ? err.message : String(err)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(pk: PasskeyItem) {
|
||||||
|
if (!window.confirm("Diesen Passkey wirklich löschen?")) return;
|
||||||
|
const { error } = await supabase.auth.passkey.delete({ passkeyId: pk.id });
|
||||||
|
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl">
|
||||||
|
<h1 className="text-xl font-bold text-[var(--c-primary)] mb-2">
|
||||||
|
Passkeys
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-gray-600 mb-4">
|
||||||
|
Mit einem Passkey (Fingerabdruck, Gesichtserkennung oder
|
||||||
|
Sicherheitsschlüssel) kannst du dich künftig ohne Passwort anmelden.
|
||||||
|
Passkeys gelten pro Domain – für den Live-Betrieb muss die Domain in
|
||||||
|
der Supabase-Konfiguration hinterlegt sein.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{info && <p className="mb-3 text-sm text-green-700">{info}</p>}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-add mb-4"
|
||||||
|
onClick={register}
|
||||||
|
disabled={busy}
|
||||||
|
>
|
||||||
|
Neuen Passkey registrieren
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
|
||||||
|
{passkeys.map((pk) => (
|
||||||
|
<li key={pk.id} className="flex items-center gap-3 px-4 py-2 text-sm">
|
||||||
|
<span className="mr-auto">
|
||||||
|
<span className="font-semibold">
|
||||||
|
{pk.friendly_name || "Passkey"}
|
||||||
|
</span>{" "}
|
||||||
|
<span className="text-gray-500">
|
||||||
|
– angelegt am{" "}
|
||||||
|
{new Date(pk.created_at).toLocaleDateString("de-DE")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-del"
|
||||||
|
onClick={() => remove(pk)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{passkeys.length === 0 && (
|
||||||
|
<li className="px-4 py-3 text-gray-500 text-sm">
|
||||||
|
Noch keine Passkeys registriert.
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import type { Profile, Role } from "@/lib/types";
|
||||||
|
|
||||||
|
interface UserForm {
|
||||||
|
id: string | null; // null = neuer Benutzer
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
role: Role;
|
||||||
|
password: string; // bei Bearbeitung optional (leer = unverändert)
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_FORM: UserForm = {
|
||||||
|
id: null,
|
||||||
|
email: "",
|
||||||
|
full_name: "",
|
||||||
|
role: "staff",
|
||||||
|
password: "",
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<Role, string> = {
|
||||||
|
admin: "Administrator",
|
||||||
|
staff: "Kantinenmitarbeiter",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UserManager() {
|
||||||
|
const supabase = useMemo(() => createClient(), []);
|
||||||
|
const [profiles, setProfiles] = useState<Profile[]>([]);
|
||||||
|
const [me, setMe] = useState<string | null>(null);
|
||||||
|
const [form, setForm] = useState<UserForm | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
const [{ data: profileData, error: profileError }, { data: userData }] =
|
||||||
|
await Promise.all([
|
||||||
|
supabase.from("profiles").select("*").order("created_at"),
|
||||||
|
supabase.auth.getUser(),
|
||||||
|
]);
|
||||||
|
if (profileError) setError(`Laden fehlgeschlagen: ${profileError.message}`);
|
||||||
|
else setProfiles(profileData as Profile[]);
|
||||||
|
setMe(userData.user?.id ?? null);
|
||||||
|
}, [supabase]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function callApi(method: string, body: unknown): Promise<boolean> {
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/admin/users", {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => null);
|
||||||
|
setError(data?.error ?? `Fehler (${res.status})`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : String(err));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!form) return;
|
||||||
|
const ok = form.id
|
||||||
|
? await callApi("PATCH", {
|
||||||
|
id: form.id,
|
||||||
|
full_name: form.full_name,
|
||||||
|
role: form.role,
|
||||||
|
password: form.password || undefined,
|
||||||
|
})
|
||||||
|
: await callApi("POST", {
|
||||||
|
email: form.email,
|
||||||
|
full_name: form.full_name,
|
||||||
|
role: form.role,
|
||||||
|
password: form.password,
|
||||||
|
});
|
||||||
|
if (ok) {
|
||||||
|
setForm(null);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(profile: Profile) {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Benutzer „${profile.email}“ wirklich löschen? Der Zugang wird dauerhaft entfernt.`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
if (await callApi("DELETE", { id: profile.id })) await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-3xl">
|
||||||
|
<div className="flex items-center gap-3 mb-4">
|
||||||
|
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
|
||||||
|
Benutzer
|
||||||
|
</h1>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-add"
|
||||||
|
onClick={() => setForm(EMPTY_FORM)}
|
||||||
|
>
|
||||||
|
Neuer Benutzer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form && (
|
||||||
|
<form onSubmit={save} className="card mb-4 space-y-3">
|
||||||
|
<h2 className="font-semibold">
|
||||||
|
{form.id ? "Benutzer bearbeiten" : "Neuer Benutzer"}
|
||||||
|
</h2>
|
||||||
|
{!form.id && (
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="user-email">
|
||||||
|
E-Mail-Adresse
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="user-email"
|
||||||
|
type="email"
|
||||||
|
className="input"
|
||||||
|
value={form.email}
|
||||||
|
onChange={(e) => setForm({ ...form, email: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="label" htmlFor="user-name">
|
||||||
|
Name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="user-name"
|
||||||
|
className="input"
|
||||||
|
value={form.full_name}
|
||||||
|
onChange={(e) => setForm({ ...form, full_name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-60">
|
||||||
|
<label className="label" htmlFor="user-role">
|
||||||
|
Rolle
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="user-role"
|
||||||
|
className="input"
|
||||||
|
value={form.role}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm({ ...form, role: e.target.value as Role })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="staff">Kantinenmitarbeiter</option>
|
||||||
|
<option value="admin">Administrator</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="max-w-60">
|
||||||
|
<label className="label" htmlFor="user-password">
|
||||||
|
{form.id ? "Neues Passwort (optional)" : "Passwort"}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="user-password"
|
||||||
|
type="password"
|
||||||
|
className="input"
|
||||||
|
value={form.password}
|
||||||
|
onChange={(e) => setForm({ ...form, password: e.target.value })}
|
||||||
|
minLength={8}
|
||||||
|
required={!form.id}
|
||||||
|
autoComplete="new-password"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="submit" className="btn btn-add" disabled={busy}>
|
||||||
|
Speichern
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-neutral"
|
||||||
|
onClick={() => setForm(null)}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
|
||||||
|
{profiles.map((p) => (
|
||||||
|
<li key={p.id} className="flex items-center gap-3 px-4 py-2 text-sm">
|
||||||
|
<span className="mr-auto">
|
||||||
|
<span className="font-semibold">{p.full_name || p.email}</span>{" "}
|
||||||
|
<span className="text-gray-500">
|
||||||
|
– {p.email} – {ROLE_LABELS[p.role]}
|
||||||
|
</span>
|
||||||
|
{p.id === me && <span className="text-gray-400"> (du)</span>}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() =>
|
||||||
|
setForm({
|
||||||
|
id: p.id,
|
||||||
|
email: p.email,
|
||||||
|
full_name: p.full_name,
|
||||||
|
role: p.role,
|
||||||
|
password: "",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Bearbeiten
|
||||||
|
</button>
|
||||||
|
{p.id !== me && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-del"
|
||||||
|
onClick={() => remove(p)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { createClient } from "@/lib/supabase/client";
|
||||||
|
import { addDays, formatDateLong, formatPrice, WEEKDAYS } from "@/lib/menu";
|
||||||
|
import type { Dish, MenuEntry, Section } from "@/lib/types";
|
||||||
|
|
||||||
|
export default function WeekEditor({ monday }: { monday: string }) {
|
||||||
|
const supabase = useMemo(() => createClient(), []);
|
||||||
|
const friday = addDays(monday, 4);
|
||||||
|
const days = WEEKDAYS.map((weekday, i) => ({
|
||||||
|
weekday,
|
||||||
|
iso: addDays(monday, i),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const [sections, setSections] = useState<Section[]>([]);
|
||||||
|
const [dishes, setDishes] = useState<Dish[]>([]);
|
||||||
|
const [entries, setEntries] = useState<MenuEntry[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
// Zelle, in der gerade ein Gericht hinzugefügt wird
|
||||||
|
const [adding, setAdding] = useState<{ iso: string; section: string } | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [selectedDish, setSelectedDish] = useState("");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setError(null);
|
||||||
|
const [se, di, en] = await Promise.all([
|
||||||
|
supabase.from("sections").select("*").order("sort"),
|
||||||
|
supabase.from("dishes").select("*").order("name"),
|
||||||
|
supabase
|
||||||
|
.from("menu_entries")
|
||||||
|
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)")
|
||||||
|
.gte("menu_date", monday)
|
||||||
|
.lte("menu_date", friday)
|
||||||
|
.order("position")
|
||||||
|
.order("created_at"),
|
||||||
|
]);
|
||||||
|
const problem = se.error ?? di.error ?? en.error;
|
||||||
|
if (problem) {
|
||||||
|
setError(`Laden fehlgeschlagen: ${problem.message}`);
|
||||||
|
} else {
|
||||||
|
setSections(se.data as Section[]);
|
||||||
|
setDishes(di.data as Dish[]);
|
||||||
|
setEntries(en.data as unknown as MenuEntry[]);
|
||||||
|
}
|
||||||
|
setLoading(false);
|
||||||
|
}, [supabase, monday, friday]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setLoading(true);
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function addEntry() {
|
||||||
|
if (!adding || !selectedDish) return;
|
||||||
|
const { error } = await supabase.from("menu_entries").insert({
|
||||||
|
menu_date: adding.iso,
|
||||||
|
section_key: adding.section,
|
||||||
|
dish_id: selectedDish,
|
||||||
|
});
|
||||||
|
if (error) {
|
||||||
|
setError(
|
||||||
|
/duplicate key/i.test(error.message)
|
||||||
|
? "Dieses Gericht ist an diesem Tag bereits zugewiesen."
|
||||||
|
: `Speichern fehlgeschlagen: ${error.message}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setAdding(null);
|
||||||
|
setSelectedDish("");
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeEntry(id: string) {
|
||||||
|
const { error } = await supabase.from("menu_entries").delete().eq("id", id);
|
||||||
|
if (error) setError(`Löschen fehlgeschlagen: ${error.message}`);
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cell = (iso: string, sectionKey: string) =>
|
||||||
|
entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 mb-4">
|
||||||
|
<h1 className="text-xl font-bold text-[var(--c-primary)] mr-auto">
|
||||||
|
Wochenplan
|
||||||
|
</h1>
|
||||||
|
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
|
||||||
|
← Vorherige Woche
|
||||||
|
</Link>
|
||||||
|
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
|
||||||
|
Nächste Woche →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<p className="mb-3 text-sm text-[var(--c-del)]" role="alert">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{loading ? (
|
||||||
|
<p className="text-gray-500">Lade Wochenplan…</p>
|
||||||
|
) : (
|
||||||
|
<table className="menu-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="section-head w-52" aria-hidden />
|
||||||
|
{days.map((d) => (
|
||||||
|
<th key={d.iso} className="day-head">
|
||||||
|
<span className="font-bold">{d.weekday}</span>
|
||||||
|
<br />
|
||||||
|
{formatDateLong(d.iso)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sections.map((s) => (
|
||||||
|
<tr key={s.key}>
|
||||||
|
<th className="section-head">
|
||||||
|
<div>{s.label}</div>
|
||||||
|
<div className="time">{s.time_range}</div>
|
||||||
|
</th>
|
||||||
|
{days.map((d) => {
|
||||||
|
const isAdding =
|
||||||
|
adding?.iso === d.iso && adding?.section === s.key;
|
||||||
|
return (
|
||||||
|
<td key={d.iso} className="cell">
|
||||||
|
{cell(d.iso, s.key).map((e) => (
|
||||||
|
<div key={e.id} className="dish">
|
||||||
|
<span className="font-bold">{e.dish.name}</span>
|
||||||
|
{" – "}
|
||||||
|
{formatPrice(e.dish.price)}
|
||||||
|
<div className="mt-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-del"
|
||||||
|
onClick={() => removeEntry(e.id)}
|
||||||
|
>
|
||||||
|
Löschen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{isAdding ? (
|
||||||
|
<div className="mt-2 space-y-1 text-left">
|
||||||
|
<select
|
||||||
|
className="input"
|
||||||
|
value={selectedDish}
|
||||||
|
onChange={(e) => setSelectedDish(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">– Gericht wählen –</option>
|
||||||
|
{dishes.map((dish) => (
|
||||||
|
<option key={dish.id} value={dish.id}>
|
||||||
|
{dish.name} ({formatPrice(dish.price)})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<div className="flex gap-1 justify-center">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-add"
|
||||||
|
onClick={addEntry}
|
||||||
|
disabled={!selectedDish}
|
||||||
|
>
|
||||||
|
Hinzufügen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-neutral"
|
||||||
|
onClick={() => {
|
||||||
|
setAdding(null);
|
||||||
|
setSelectedDish("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-add mt-1"
|
||||||
|
onClick={() => {
|
||||||
|
setAdding({ iso: d.iso, section: s.key });
|
||||||
|
setSelectedDish("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mahlzeit hinzufügen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
<p className="mt-4 text-sm text-gray-500">
|
||||||
|
Neue Gerichte legst du unter{" "}
|
||||||
|
<Link href="/intern/gerichte" className="underline">
|
||||||
|
Gerichte
|
||||||
|
</Link>{" "}
|
||||||
|
an.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# =============================================================
|
||||||
|
# Speisekarte – Docker-Setup
|
||||||
|
#
|
||||||
|
# Produktion (App + lokale PostgreSQL-Kopie):
|
||||||
|
# docker compose --env-file .env.local up -d --build
|
||||||
|
#
|
||||||
|
# Entwicklung (Dev-Server mit Hot-Reload, kein lokales Node nötig):
|
||||||
|
# docker compose --env-file .env.local --profile dev up dev
|
||||||
|
#
|
||||||
|
# Die --env-file-Angabe speist NEXT_PUBLIC_* in den Build und
|
||||||
|
# SUPABASE_SERVICE_ROLE_KEY in die Laufzeit-Umgebung.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
services:
|
||||||
|
# ---------- Next.js-App (Produktions-Build) ----------
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ---------- Lokale PostgreSQL-Kopie (Ablage/Auswertungen) ----------
|
||||||
|
# Beim ersten Start werden die Skripte aus supabase/local
|
||||||
|
# automatisch in alphabetischer Reihenfolge eingespielt.
|
||||||
|
db:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: speisekarte
|
||||||
|
POSTGRES_PASSWORD: ${LOCAL_PG_PASSWORD:-speisekarte}
|
||||||
|
ports:
|
||||||
|
# Host-Port 5433, da 5432 meist schon von einer nativen
|
||||||
|
# PostgreSQL-Installation belegt ist.
|
||||||
|
- "5433:5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
- ./supabase/local:/docker-entrypoint-initdb.d:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# ---------- Dev-Server (nur mit --profile dev) ----------
|
||||||
|
dev:
|
||||||
|
image: node:24-alpine
|
||||||
|
profiles: ["dev"]
|
||||||
|
working_dir: /app
|
||||||
|
command: sh -c "npm install && npm run dev"
|
||||||
|
environment:
|
||||||
|
NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL}
|
||||||
|
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||||
|
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
- dev_node_modules:/app/node_modules
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
|
dev_node_modules:
|
||||||
+49
@@ -0,0 +1,49 @@
|
|||||||
|
// Datums- und Formatierungshelfer für den Wochenplan (Mo–Fr).
|
||||||
|
// Alle Berechnungen laufen über UTC, damit Server und Browser
|
||||||
|
// unabhängig von der Zeitzone dieselbe Woche ermitteln.
|
||||||
|
|
||||||
|
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
function toDate(iso: string): Date {
|
||||||
|
return new Date(`${iso}T00:00:00Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toISO(d: Date): string {
|
||||||
|
return d.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Montag der Woche, in der `iso` liegt (bzw. der aktuellen Woche). */
|
||||||
|
export function mondayOf(iso?: string | null): string {
|
||||||
|
const base =
|
||||||
|
iso && ISO_DATE.test(iso)
|
||||||
|
? toDate(iso)
|
||||||
|
: new Date();
|
||||||
|
const day = (base.getUTCDay() + 6) % 7; // Montag = 0
|
||||||
|
const monday = new Date(
|
||||||
|
Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), base.getUTCDate() - day)
|
||||||
|
);
|
||||||
|
return toISO(monday);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(iso: string, days: number): string {
|
||||||
|
const d = toDate(iso);
|
||||||
|
d.setUTCDate(d.getUTCDate() + days);
|
||||||
|
return toISO(d);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"];
|
||||||
|
|
||||||
|
/** z. B. "13. Juli 2026" */
|
||||||
|
export function formatDateLong(iso: string): string {
|
||||||
|
return toDate(iso).toLocaleDateString("de-DE", {
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** z. B. "3,50€" */
|
||||||
|
export function formatPrice(price: number | string): string {
|
||||||
|
return Number(price).toFixed(2).replace(".", ",") + "€";
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
// CSRF-Schutz (Defense-in-Depth) für schreibende Route-Handler:
|
||||||
|
// Browser senden bei Cross-Site-POSTs immer einen Origin-Header –
|
||||||
|
// stimmt er nicht mit dem Host überein, wird die Anfrage abgelehnt.
|
||||||
|
// (Erste Verteidigungslinie sind bereits die SameSite=Lax-Session-
|
||||||
|
// Cookies von @supabase/ssr sowie der JSON-Content-Type.)
|
||||||
|
export function isSameOrigin(request: Request): boolean {
|
||||||
|
const origin = request.headers.get("origin");
|
||||||
|
// Kein Origin-Header: kein Browser-Cross-Site-Kontext (z. B. curl);
|
||||||
|
// die Authentifizierung greift trotzdem.
|
||||||
|
if (!origin) return true;
|
||||||
|
const host = request.headers.get("host");
|
||||||
|
try {
|
||||||
|
return new URL(origin).host === host;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import "server-only";
|
||||||
|
import { createClient as createSupabaseClient } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
// Service-Role-Client: umgeht RLS. Ausschließlich serverseitig verwenden
|
||||||
|
// (Benutzerverwaltung durch den Admin).
|
||||||
|
export function createAdminClient() {
|
||||||
|
return createSupabaseClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.SUPABASE_SERVICE_ROLE_KEY!,
|
||||||
|
{
|
||||||
|
auth: { persistSession: false, autoRefreshToken: false },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { createBrowserClient } from "@supabase/ssr";
|
||||||
|
|
||||||
|
// Browser-Client (Client Components). Passkey-Support ist bei Supabase
|
||||||
|
// derzeit als experimentelles Feature markiert und muss aktiviert werden.
|
||||||
|
export function createClient() {
|
||||||
|
return createBrowserClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
auth: {
|
||||||
|
experimental: { passkey: true },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
// Server-Client (Server Components, Route Handler, Server Actions).
|
||||||
|
// Muss pro Request neu erzeugt werden.
|
||||||
|
export async function createClient() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
return createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return cookieStore.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
try {
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
cookieStore.set(name, value, options)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// In Server Components dürfen keine Cookies gesetzt werden –
|
||||||
|
// das Session-Refresh übernimmt proxy.ts.
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
export type Role = "admin" | "staff";
|
||||||
|
|
||||||
|
export interface Profile {
|
||||||
|
id: string;
|
||||||
|
email: string;
|
||||||
|
full_name: string;
|
||||||
|
role: Role;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Section {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
time_range: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dish {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuEntry {
|
||||||
|
id: string;
|
||||||
|
menu_date: string;
|
||||||
|
section_key: string;
|
||||||
|
position: number;
|
||||||
|
dish: Dish;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CodeItem {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Farb-Defaults (r-hofmann.de: Blau #003388, Navy #0d315c, Orange #ff6900)
|
||||||
|
export const DEFAULT_COLORS: Record<string, string> = {
|
||||||
|
primary: "#003388",
|
||||||
|
primaryDark: "#0d315c",
|
||||||
|
accent: "#ff6900",
|
||||||
|
headerText: "#ffffff",
|
||||||
|
rowBg: "#e3e4f1",
|
||||||
|
rowBgAlt: "#f4f4fa",
|
||||||
|
addBtn: "#6da54c",
|
||||||
|
deleteBtn: "#e2371d",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COLOR_VARS: Record<string, string> = {
|
||||||
|
primary: "--c-primary",
|
||||||
|
primaryDark: "--c-primary-dark",
|
||||||
|
accent: "--c-accent",
|
||||||
|
headerText: "--c-header-text",
|
||||||
|
rowBg: "--c-row-bg",
|
||||||
|
rowBgAlt: "--c-row-bg-alt",
|
||||||
|
addBtn: "--c-add",
|
||||||
|
deleteBtn: "--c-del",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COLOR_LABELS: Record<string, string> = {
|
||||||
|
primary: "Primärfarbe (Tabellenkopf)",
|
||||||
|
primaryDark: "Primärfarbe dunkel (Kopfleiste intern)",
|
||||||
|
accent: "Akzentfarbe",
|
||||||
|
headerText: "Schriftfarbe im Tabellenkopf",
|
||||||
|
rowBg: "Zellen-Hintergrund",
|
||||||
|
rowBgAlt: "Zellen-Hintergrund (alternierend)",
|
||||||
|
addBtn: "Button „Hinzufügen“",
|
||||||
|
deleteBtn: "Button „Löschen“",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function logoUrl(path: string | null | undefined): string | null {
|
||||||
|
if (!path) return null;
|
||||||
|
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`;
|
||||||
|
}
|
||||||
+24
-1
@@ -1,7 +1,30 @@
|
|||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
|
||||||
|
// HTTP-Security-Header für alle Routen.
|
||||||
|
// HTTPS/HSTS übernimmt im Live-Betrieb der Reverse-Proxy.
|
||||||
|
const securityHeaders = [
|
||||||
|
// Verhindert Einbettung in fremde Seiten (Clickjacking)
|
||||||
|
{ key: "X-Frame-Options", value: "DENY" },
|
||||||
|
{ key: "Content-Security-Policy", value: "frame-ancestors 'none'" },
|
||||||
|
// Browser darf Content-Types nicht "erraten"
|
||||||
|
{ key: "X-Content-Type-Options", value: "nosniff" },
|
||||||
|
// Keine vollständigen URLs an fremde Seiten weitergeben
|
||||||
|
{ key: "Referrer-Policy", value: "strict-origin-when-cross-origin" },
|
||||||
|
// Sensorische APIs werden nicht benötigt
|
||||||
|
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
|
||||||
|
];
|
||||||
|
|
||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
/* config options here */
|
// Für den Docker-Betrieb: minimales, eigenständiges Server-Bundle
|
||||||
|
output: "standalone",
|
||||||
|
async headers() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
source: "/(.*)",
|
||||||
|
headers: securityHeaders,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
Generated
+128
-1
@@ -8,9 +8,12 @@
|
|||||||
"name": "speisekarte",
|
"name": "speisekarte",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/ssr": "^0.12.3",
|
||||||
|
"@supabase/supabase-js": "^2.110.7",
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"server-only": "^0.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
@@ -1313,6 +1316,102 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@supabase/auth-js": {
|
||||||
|
"version": "2.110.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.110.7.tgz",
|
||||||
|
"integrity": "sha512-M5Bpl4hCv6kHcOO/xM06Dyfg1mYLHljMkp1plhzG9IRZPc3czvyMsSN1XpL5+GKisOKM3lSN59zhpcm6sMVXfA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/functions-js": {
|
||||||
|
"version": "2.110.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/functions-js/-/functions-js-2.110.7.tgz",
|
||||||
|
"integrity": "sha512-megYmexlYEoR/0qlsr4Snh9wtzAodO7MAri3NMevZrXzNvQRKlvmTcSBoKGLQEPDakgDZMqbMdf9DwoZz6qfoA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/phoenix": {
|
||||||
|
"version": "0.4.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/phoenix/-/phoenix-0.4.5.tgz",
|
||||||
|
"integrity": "sha512-aAn9H9ovVyeApKy11OWOrrOGq8DV68yWeH4ud2lN9fzn4aO8Zb5GLL9m1pUg9nLqIcT+ZDfAcsZe0E/nqdv2lw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/postgrest-js": {
|
||||||
|
"version": "2.110.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/postgrest-js/-/postgrest-js-2.110.7.tgz",
|
||||||
|
"integrity": "sha512-ban6YV0djhVaqVYezlOARKLIuOBSvLLhyQVZjA2nxPrtswhxHCl1+gI4giFgI9ATQAaMNbUZb4JXiuL5lEA/5g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/realtime-js": {
|
||||||
|
"version": "2.110.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/realtime-js/-/realtime-js-2.110.7.tgz",
|
||||||
|
"integrity": "sha512-AMtZjyFA2gsmjuxopPNS/sRznLQHG0Ht5x+ytTPTOh3vAcOTUlVRLx7gW4/CONNnbb3PKOkE+HmM35HOSbmomQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/phoenix": "0.4.5",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/ssr": {
|
||||||
|
"version": "0.12.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/ssr/-/ssr-0.12.3.tgz",
|
||||||
|
"integrity": "sha512-qWXJ/dI7CiYDKyTgIPqJ4Qkh8y5edh2LdF/nkN48z47mmEVzAO2OE+YErYeQ/UemVfonn/F4kFz+lhLqoVMdpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cookie": "^1.0.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@supabase/supabase-js": "^2.110.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/storage-js": {
|
||||||
|
"version": "2.110.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/storage-js/-/storage-js-2.110.7.tgz",
|
||||||
|
"integrity": "sha512-2tcDE8cjEDy1uKxKavBpKQod1JdMV1jDXQag48TCa+kycmJOltc0yVabC0BUlhOwAl6WykXU2aOsH3ELMtZrmQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"iceberg-js": "^0.8.1",
|
||||||
|
"tslib": "2.8.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@supabase/supabase-js": {
|
||||||
|
"version": "2.110.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@supabase/supabase-js/-/supabase-js-2.110.7.tgz",
|
||||||
|
"integrity": "sha512-AnfO3A230Shy6RMO7cya3Wl1OcXnABJrzH8vP+fY7/RFjhzcchB7DjKkkTIAntlwekD+GkSFzEvt2tC+D4Fp8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@supabase/auth-js": "2.110.7",
|
||||||
|
"@supabase/functions-js": "2.110.7",
|
||||||
|
"@supabase/postgrest-js": "2.110.7",
|
||||||
|
"@supabase/realtime-js": "2.110.7",
|
||||||
|
"@supabase/storage-js": "2.110.7"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@swc/helpers": {
|
"node_modules/@swc/helpers": {
|
||||||
"version": "0.5.15",
|
"version": "0.5.15",
|
||||||
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
|
||||||
@@ -2816,6 +2915,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/cookie": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@@ -4103,6 +4215,15 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/iceberg-js": {
|
||||||
|
"version": "0.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/iceberg-js/-/iceberg-js-0.8.1.tgz",
|
||||||
|
"integrity": "sha512-1dhVQZXhcHje7798IVM+xoo/1ZdVfzOMIc8/rgVSijRK38EDqOJoGula9N/8ZI5RD8QTxNQtK/Gozpr+qUqRRA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@@ -5873,6 +5994,12 @@
|
|||||||
"semver": "bin/semver.js"
|
"semver": "bin/semver.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/server-only": {
|
||||||
|
"version": "0.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
|
||||||
|
"integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/set-function-length": {
|
"node_modules/set-function-length": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
|
|||||||
+4
-1
@@ -9,9 +9,12 @@
|
|||||||
"lint": "eslint"
|
"lint": "eslint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@supabase/ssr": "^0.12.3",
|
||||||
|
"@supabase/supabase-js": "^2.110.7",
|
||||||
"next": "16.2.10",
|
"next": "16.2.10",
|
||||||
"react": "19.2.4",
|
"react": "19.2.4",
|
||||||
"react-dom": "19.2.4"
|
"react-dom": "19.2.4",
|
||||||
|
"server-only": "^0.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { createServerClient } from "@supabase/ssr";
|
||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
|
||||||
|
// Session-Refresh + Zugriffsschutz für die internen Bereiche.
|
||||||
|
export async function proxy(request: NextRequest) {
|
||||||
|
let response = NextResponse.next({ request });
|
||||||
|
|
||||||
|
const supabase = createServerClient(
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_URL!,
|
||||||
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
|
||||||
|
{
|
||||||
|
cookies: {
|
||||||
|
getAll() {
|
||||||
|
return request.cookies.getAll();
|
||||||
|
},
|
||||||
|
setAll(cookiesToSet) {
|
||||||
|
cookiesToSet.forEach(({ name, value }) =>
|
||||||
|
request.cookies.set(name, value)
|
||||||
|
);
|
||||||
|
response = NextResponse.next({ request });
|
||||||
|
cookiesToSet.forEach(({ name, value, options }) =>
|
||||||
|
response.cookies.set(name, value, options)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
const url = request.nextUrl.clone();
|
||||||
|
url.pathname = "/login";
|
||||||
|
url.search = "";
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
matcher: ["/intern/:path*", "/admin/:path*"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Synchronisiert die lokale PostgreSQL-Kopie mit der Supabase-Datenbank.
|
||||||
|
# Voraussetzungen:
|
||||||
|
# - PostgreSQL 17 lokal installiert (Standardpfad, Dienst läuft)
|
||||||
|
# - Umgebungsvariablen:
|
||||||
|
# SUPABASE_DB_PASSWORD = Datenbank-Passwort des Supabase-Projekts
|
||||||
|
# LOCAL_PG_PASSWORD = Passwort des lokalen postgres-Benutzers
|
||||||
|
# Aufruf: powershell -File scripts\sync-local-db.ps1
|
||||||
|
|
||||||
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
|
$PgBin = "C:\Program Files\PostgreSQL\17\bin"
|
||||||
|
$RemoteHost = "aws-0-eu-central-1.pooler.supabase.com"
|
||||||
|
$RemoteUser = "postgres.hvzikwjoyhoakedlywhj"
|
||||||
|
$LocalDir = Join-Path $PSScriptRoot "..\supabase\local"
|
||||||
|
|
||||||
|
if (-not $env:SUPABASE_DB_PASSWORD) { throw "SUPABASE_DB_PASSWORD ist nicht gesetzt." }
|
||||||
|
if (-not $env:LOCAL_PG_PASSWORD) { throw "LOCAL_PG_PASSWORD ist nicht gesetzt." }
|
||||||
|
|
||||||
|
# 1. Aktuellen Stand aus Supabase ziehen (Session-Pooler, Port 5432)
|
||||||
|
$env:PGPASSWORD = $env:SUPABASE_DB_PASSWORD
|
||||||
|
$SchemaFile = Join-Path $LocalDir "10_schema.sql"
|
||||||
|
& "$PgBin\pg_dump" -h $RemoteHost -p 5432 -U $RemoteUser -d postgres `
|
||||||
|
--schema=public --schema-only --no-owner --no-privileges `
|
||||||
|
-f $SchemaFile
|
||||||
|
& "$PgBin\pg_dump" -h $RemoteHost -p 5432 -U $RemoteUser -d postgres `
|
||||||
|
--schema=public --data-only --no-owner --disable-triggers `
|
||||||
|
-f (Join-Path $LocalDir "20_data.sql")
|
||||||
|
|
||||||
|
# pg_dump legt "CREATE SCHEMA public;" und \restrict/\unrestrict-Marker an.
|
||||||
|
# Das offizielle postgres-Docker-Image besitzt "public" bereits und bricht
|
||||||
|
# mit ON_ERROR_STOP=1 sonst das komplette Init-Skript ab.
|
||||||
|
(Get-Content $SchemaFile) `
|
||||||
|
-replace '^CREATE SCHEMA public;$', 'CREATE SCHEMA IF NOT EXISTS public;' `
|
||||||
|
-replace '^\\restrict .*$', '' `
|
||||||
|
-replace '^\\unrestrict .*$', '' |
|
||||||
|
Set-Content $SchemaFile
|
||||||
|
|
||||||
|
# 2. Lokale Datenbank neu aufbauen
|
||||||
|
$env:PGPASSWORD = $env:LOCAL_PG_PASSWORD
|
||||||
|
& "$PgBin\psql" -h localhost -U postgres -c "drop database if exists speisekarte;"
|
||||||
|
& "$PgBin\psql" -h localhost -U postgres -c "create database speisekarte;"
|
||||||
|
|
||||||
|
foreach ($file in @("00_stubs.sql", "10_schema.sql", "15_auth_users.sql", "20_data.sql")) {
|
||||||
|
& "$PgBin\psql" -h localhost -U postgres -d speisekarte -q -f (Join-Path $LocalDir $file)
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Lokale Datenbank 'speisekarte' wurde aktualisiert."
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- =============================================================
|
||||||
|
-- Stubs für die LOKALE PostgreSQL-Kopie (ohne Supabase-Stack).
|
||||||
|
-- Stellt die minimalen Supabase-Objekte bereit, die das public-
|
||||||
|
-- Schema referenziert (auth.users, auth.uid(), Rollen, storage).
|
||||||
|
-- Reihenfolge: 00_stubs.sql -> Schema-Dump -> Daten-Dump
|
||||||
|
-- =============================================================
|
||||||
|
|
||||||
|
-- Rollen, die in den RLS-Policies referenziert werden
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (select from pg_roles where rolname = 'anon') then
|
||||||
|
create role anon nologin;
|
||||||
|
end if;
|
||||||
|
if not exists (select from pg_roles where rolname = 'authenticated') then
|
||||||
|
create role authenticated nologin;
|
||||||
|
end if;
|
||||||
|
if not exists (select from pg_roles where rolname = 'service_role') then
|
||||||
|
create role service_role nologin bypassrls;
|
||||||
|
end if;
|
||||||
|
end
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ---------- auth-Schema (Minimal-Stub) ----------
|
||||||
|
create schema if not exists auth;
|
||||||
|
|
||||||
|
create table if not exists auth.users (
|
||||||
|
id uuid primary key,
|
||||||
|
email text,
|
||||||
|
raw_user_meta_data jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Entspricht supabase auth.uid(); liefert lokal null,
|
||||||
|
-- sofern kein JWT-Claim gesetzt ist.
|
||||||
|
create or replace function auth.uid()
|
||||||
|
returns uuid
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $$
|
||||||
|
select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid
|
||||||
|
$$;
|
||||||
|
|
||||||
|
-- ---------- storage-Schema (Minimal-Stub) ----------
|
||||||
|
create schema if not exists storage;
|
||||||
|
|
||||||
|
create table if not exists storage.buckets (
|
||||||
|
id text primary key,
|
||||||
|
name text not null,
|
||||||
|
public boolean not null default false
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists storage.objects (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
bucket_id text references storage.buckets (id),
|
||||||
|
name text,
|
||||||
|
owner uuid,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
--
|
||||||
|
-- PostgreSQL database dump
|
||||||
|
--
|
||||||
|
|
||||||
|
-- Dumped from database version 17.6
|
||||||
|
-- Dumped by pg_dump version 17.10
|
||||||
|
|
||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
|
SET transaction_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SELECT pg_catalog.set_config('search_path', '', false);
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET xmloption = content;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
SET row_security = off;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: public; Type: SCHEMA; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
-- Das "public"-Schema existiert im offiziellen postgres-Image bereits;
|
||||||
|
-- CREATE SCHEMA würde dort mit ON_ERROR_STOP=1 das komplette Init-Skript
|
||||||
|
-- abbrechen.
|
||||||
|
CREATE SCHEMA IF NOT EXISTS public;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: SCHEMA public; Type: COMMENT; Schema: -; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
COMMENT ON SCHEMA public IS 'standard public schema';
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: app_role(); Type: FUNCTION; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE FUNCTION public.app_role() RETURNS text
|
||||||
|
LANGUAGE sql STABLE SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
select role from public.profiles where id = auth.uid()
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: handle_new_user(); Type: FUNCTION; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE FUNCTION public.handle_new_user() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql SECURITY DEFINER
|
||||||
|
SET search_path TO 'public'
|
||||||
|
AS $$
|
||||||
|
begin
|
||||||
|
insert into public.profiles (id, email, full_name, role)
|
||||||
|
values (
|
||||||
|
new.id,
|
||||||
|
new.email,
|
||||||
|
coalesce(new.raw_user_meta_data ->> 'full_name', ''),
|
||||||
|
coalesce(new.raw_user_meta_data ->> 'role', 'staff')
|
||||||
|
)
|
||||||
|
on conflict (id) do nothing;
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: touch_updated_at(); Type: FUNCTION; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE FUNCTION public.touch_updated_at() RETURNS trigger
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
AS $$
|
||||||
|
begin
|
||||||
|
new.updated_at := now();
|
||||||
|
return new;
|
||||||
|
end;
|
||||||
|
$$;
|
||||||
|
|
||||||
|
|
||||||
|
SET default_tablespace = '';
|
||||||
|
|
||||||
|
SET default_table_access_method = heap;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: additives; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.additives (
|
||||||
|
code text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
sort integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: allergens; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.allergens (
|
||||||
|
code text NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
sort integer DEFAULT 0 NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: dishes; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.dishes (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
price numeric(6,2) DEFAULT 0 NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: menu_entries; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.menu_entries (
|
||||||
|
id uuid DEFAULT gen_random_uuid() NOT NULL,
|
||||||
|
menu_date date NOT NULL,
|
||||||
|
section_key text NOT NULL,
|
||||||
|
dish_id uuid NOT NULL,
|
||||||
|
"position" integer DEFAULT 0 NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: profiles; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.profiles (
|
||||||
|
id uuid NOT NULL,
|
||||||
|
email text NOT NULL,
|
||||||
|
full_name text DEFAULT ''::text NOT NULL,
|
||||||
|
role text DEFAULT 'staff'::text NOT NULL,
|
||||||
|
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT profiles_role_check CHECK ((role = ANY (ARRAY['admin'::text, 'staff'::text])))
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: sections; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.sections (
|
||||||
|
key text NOT NULL,
|
||||||
|
label text NOT NULL,
|
||||||
|
time_range text NOT NULL,
|
||||||
|
sort integer NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: settings; Type: TABLE; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TABLE public.settings (
|
||||||
|
id integer NOT NULL,
|
||||||
|
colors jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||||
|
logo_path text,
|
||||||
|
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||||
|
CONSTRAINT settings_id_check CHECK ((id = 1))
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: additives additives_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.additives
|
||||||
|
ADD CONSTRAINT additives_pkey PRIMARY KEY (code);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: allergens allergens_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.allergens
|
||||||
|
ADD CONSTRAINT allergens_pkey PRIMARY KEY (code);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: dishes dishes_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.dishes
|
||||||
|
ADD CONSTRAINT dishes_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: menu_entries menu_entries_menu_date_section_key_dish_id_key; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.menu_entries
|
||||||
|
ADD CONSTRAINT menu_entries_menu_date_section_key_dish_id_key UNIQUE (menu_date, section_key, dish_id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: menu_entries menu_entries_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.menu_entries
|
||||||
|
ADD CONSTRAINT menu_entries_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: profiles profiles_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.profiles
|
||||||
|
ADD CONSTRAINT profiles_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: sections sections_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.sections
|
||||||
|
ADD CONSTRAINT sections_pkey PRIMARY KEY (key);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: settings settings_pkey; Type: CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.settings
|
||||||
|
ADD CONSTRAINT settings_pkey PRIMARY KEY (id);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: menu_entries_date_idx; Type: INDEX; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE INDEX menu_entries_date_idx ON public.menu_entries USING btree (menu_date);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: dishes dishes_touch_updated_at; Type: TRIGGER; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TRIGGER dishes_touch_updated_at BEFORE UPDATE ON public.dishes FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: settings settings_touch_updated_at; Type: TRIGGER; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE TRIGGER settings_touch_updated_at BEFORE UPDATE ON public.settings FOR EACH ROW EXECUTE FUNCTION public.touch_updated_at();
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: menu_entries menu_entries_dish_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.menu_entries
|
||||||
|
ADD CONSTRAINT menu_entries_dish_id_fkey FOREIGN KEY (dish_id) REFERENCES public.dishes(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: menu_entries menu_entries_section_key_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.menu_entries
|
||||||
|
ADD CONSTRAINT menu_entries_section_key_fkey FOREIGN KEY (section_key) REFERENCES public.sections(key);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: profiles profiles_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE ONLY public.profiles
|
||||||
|
ADD CONSTRAINT profiles_id_fkey FOREIGN KEY (id) REFERENCES auth.users(id) ON DELETE CASCADE;
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: additives; Type: ROW SECURITY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.additives ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: additives additives_public_read; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY additives_public_read ON public.additives FOR SELECT TO authenticated, anon USING (true);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: additives additives_staff_write; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY additives_staff_write ON public.additives TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: allergens; Type: ROW SECURITY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.allergens ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: allergens allergens_public_read; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY allergens_public_read ON public.allergens FOR SELECT TO authenticated, anon USING (true);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: allergens allergens_staff_write; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY allergens_staff_write ON public.allergens TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: dishes; Type: ROW SECURITY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.dishes ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: dishes dishes_public_read; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY dishes_public_read ON public.dishes FOR SELECT TO authenticated, anon USING (true);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: dishes dishes_staff_write; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY dishes_staff_write ON public.dishes TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: menu_entries; Type: ROW SECURITY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.menu_entries ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: menu_entries menu_entries_public_read; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY menu_entries_public_read ON public.menu_entries FOR SELECT TO authenticated, anon USING (true);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: menu_entries menu_entries_staff_write; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY menu_entries_staff_write ON public.menu_entries TO authenticated USING ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text]))) WITH CHECK ((public.app_role() = ANY (ARRAY['staff'::text, 'admin'::text])));
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: profiles; Type: ROW SECURITY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: profiles profiles_select_own_or_admin; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY profiles_select_own_or_admin ON public.profiles FOR SELECT TO authenticated USING (((id = ( SELECT auth.uid() AS uid)) OR (public.app_role() = 'admin'::text)));
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: sections; Type: ROW SECURITY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.sections ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: sections sections_public_read; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY sections_public_read ON public.sections FOR SELECT TO authenticated, anon USING (true);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: settings; Type: ROW SECURITY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.settings ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: settings settings_admin_update; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY settings_admin_update ON public.settings FOR UPDATE TO authenticated USING ((public.app_role() = 'admin'::text)) WITH CHECK ((public.app_role() = 'admin'::text));
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Name: settings settings_public_read; Type: POLICY; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
CREATE POLICY settings_public_read ON public.settings FOR SELECT TO authenticated, anon USING (true);
|
||||||
|
|
||||||
|
|
||||||
|
--
|
||||||
|
-- PostgreSQL database dump complete
|
||||||
|
--
|
||||||
|
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Benutzer-Stammdaten für die lokale Kopie (ohne Passwort-Hashes –
|
||||||
|
-- Login ist nur gegen die Supabase-Instanz möglich).
|
||||||
|
insert into auth.users (id, email, raw_user_meta_data) values
|
||||||
|
('cee4160a-ab3a-4109-b706-b9f27d515596', 'fw@wildeflorian.de', '{"full_name": "Florian Wilde", "role": "admin"}'),
|
||||||
|
('102d405c-b010-41f6-9409-244004a3c567', 'kantine@example.com', '{"full_name": "Kantinen-Team", "role": "staff"}')
|
||||||
|
on conflict (id) do nothing;
|
||||||
@@ -0,0 +1,198 @@
|
|||||||
|
--
|
||||||
|
-- PostgreSQL database dump
|
||||||
|
--
|
||||||
|
|
||||||
|
\restrict HfcGijNcMcy9jPlPpFachgl8LfchhPK39kzgfhc84Q6jsOJpHN3jqf6T368FXri
|
||||||
|
|
||||||
|
-- Dumped from database version 17.6
|
||||||
|
-- Dumped by pg_dump version 17.10
|
||||||
|
|
||||||
|
SET statement_timeout = 0;
|
||||||
|
SET lock_timeout = 0;
|
||||||
|
SET idle_in_transaction_session_timeout = 0;
|
||||||
|
SET transaction_timeout = 0;
|
||||||
|
SET client_encoding = 'UTF8';
|
||||||
|
SET standard_conforming_strings = on;
|
||||||
|
SELECT pg_catalog.set_config('search_path', '', false);
|
||||||
|
SET check_function_bodies = false;
|
||||||
|
SET xmloption = content;
|
||||||
|
SET client_min_messages = warning;
|
||||||
|
SET row_security = off;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: additives; Type: TABLE DATA; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
SET SESSION AUTHORIZATION DEFAULT;
|
||||||
|
|
||||||
|
ALTER TABLE public.additives DISABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
COPY public.additives (code, name, sort) FROM stdin;
|
||||||
|
1 mit Farbstoff 1
|
||||||
|
2 mit Konservierungsstoff 2
|
||||||
|
3 mit Antioxidationsmittel 3
|
||||||
|
4 mit Geschmacksverstärker 4
|
||||||
|
5 geschwefelt 5
|
||||||
|
6 geschwärzt 6
|
||||||
|
7 gewachst 7
|
||||||
|
8 mit Phosphat 8
|
||||||
|
9 mit Süßungsmittel(n) 9
|
||||||
|
10 enthält eine Phenylalaninquelle 10
|
||||||
|
11 koffeinhaltig 11
|
||||||
|
\.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.additives ENABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: allergens; Type: TABLE DATA; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.allergens DISABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
COPY public.allergens (code, name, sort) FROM stdin;
|
||||||
|
A Glutenhaltiges Getreide 1
|
||||||
|
B Krebstiere 2
|
||||||
|
C Eier 3
|
||||||
|
D Fisch 4
|
||||||
|
E Erdnüsse 5
|
||||||
|
F Sojabohnen 6
|
||||||
|
G Milch (einschließlich Laktose) 7
|
||||||
|
H Schalenfrüchte (Nüsse) 8
|
||||||
|
I Sellerie 9
|
||||||
|
J Senf 10
|
||||||
|
K Sesamsamen 11
|
||||||
|
L Schwefeldioxid und Sulfite 12
|
||||||
|
M Lupinen 13
|
||||||
|
N Weichtiere 14
|
||||||
|
\.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.allergens ENABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: dishes; Type: TABLE DATA; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.dishes DISABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
COPY public.dishes (id, name, price, created_at, updated_at) FROM stdin;
|
||||||
|
2b136266-a80d-4c76-8520-38001c92a634 1 Stk. Rindskäsewurst mit Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
bf612ae7-8cdd-4149-8b21-f651631ccc02 Hackfleischbaguette 3.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
81c58cc6-7298-4d4a-9997-71d17df5aedf Knusperhähnchen Wrap 3.70 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 Leberkäse im Brötchen 2.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
a987c96f-25a0-4e5e-b8a8-aab683a01feb Chili con Carne mit Brötchen 3.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
14ef6893-93a7-4649-afe3-d6b6bdf5903c Schnitzel Baguette 3.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 Penne mit Grünen Spargel und Garnelenspieße 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
dbe5ee6e-8b04-4bf7-99bb-f936f6564217 Bratkartoffel Gemüse Pfanne mit Hähnchenbrust und Dip 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
c0613fe7-e5c6-489e-afe5-6da2533b2bd4 Schweinefilet im Speckmantel dazu Rahmsoße und Butterspätzle 5.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
c5cfa5b4-e502-4b56-b579-b9fbbf817f25 Penne mit Grünen Spargel, get. Tomaten und Cherrytomaten 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
f8ba1844-1f23-4b23-9e03-f59148f8b2ed Spaghetti mit fruchtiger Tomatensauce und Parmesan 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a Gebackener Feta mit griechischen Bauernsalat und Baguette 5.30 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 Gemüse-Curry mit Basmatireis 4.90 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
ea26f18b-ddf3-4899-95e7-5dbd0878d90c Kartoffelcremesuppe 1.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 Gulaschsuppe 2.50 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
3c8cf0c2-b0a9-43e3-8233-723d5718a157 Tomatensuppe mit Croutons 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
2dd85835-5425-4271-9fc0-e5215bbce476 Kürbiscremesuppe 1.80 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
06df753a-65ed-4bd0-a325-d32c78ec88fa Linseneintopf mit Würstchen 2.20 2026-07-17 15:17:02.979184+00 2026-07-17 15:17:02.979184+00
|
||||||
|
\.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.dishes ENABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: sections; Type: TABLE DATA; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.sections DISABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
COPY public.sections (key, label, time_range, sort) FROM stdin;
|
||||||
|
brotzeit Brotzeit 09:00 - 10:00 1
|
||||||
|
mittagessen Mittagessen 11:30 - 13:30 2
|
||||||
|
mittagessen_veg Mittagessen vegetarisch 11:30 - 13:30 3
|
||||||
|
suppe Suppe 11:30 - 13:30 4
|
||||||
|
\.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.sections ENABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: menu_entries; Type: TABLE DATA; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.menu_entries DISABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
COPY public.menu_entries (id, menu_date, section_key, dish_id, "position", created_at) FROM stdin;
|
||||||
|
5d1ea849-61b3-48ac-b1e9-48b6b2c7d8c1 2026-07-13 brotzeit 2b136266-a80d-4c76-8520-38001c92a634 0 2026-07-17 15:17:02.979184+00
|
||||||
|
70469091-417b-47ed-aab5-747ff97e4ad4 2026-07-14 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00
|
||||||
|
1e1f9b4e-75e7-413f-a747-c2884675d9f7 2026-07-15 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00
|
||||||
|
7b2089f7-7c01-4d6a-9e68-ebfa594bb713 2026-07-16 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00
|
||||||
|
d6e24a0d-cc32-438d-9d91-e39ecbb82d5d 2026-07-17 brotzeit a987c96f-25a0-4e5e-b8a8-aab683a01feb 0 2026-07-17 15:17:02.979184+00
|
||||||
|
c165765a-1eb3-4d7e-9566-3768e9007793 2026-07-13 mittagessen 14ef6893-93a7-4649-afe3-d6b6bdf5903c 0 2026-07-17 15:17:02.979184+00
|
||||||
|
78be0a94-1b31-47ef-af62-40548d75aa4e 2026-07-14 mittagessen ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 0 2026-07-17 15:17:02.979184+00
|
||||||
|
0635198c-6aa9-4811-a8fd-67e31174eb73 2026-07-15 mittagessen dbe5ee6e-8b04-4bf7-99bb-f936f6564217 0 2026-07-17 15:17:02.979184+00
|
||||||
|
f02305b6-7650-4b52-b440-3df7045e17a7 2026-07-16 mittagessen c0613fe7-e5c6-489e-afe5-6da2533b2bd4 0 2026-07-17 15:17:02.979184+00
|
||||||
|
b838a6a4-1a58-469e-9201-af686c7a780a 2026-07-14 mittagessen_veg c5cfa5b4-e502-4b56-b579-b9fbbf817f25 0 2026-07-17 15:17:02.979184+00
|
||||||
|
4886ee1a-f8fd-4548-8483-893a0f2dd9aa 2026-07-15 mittagessen_veg f8ba1844-1f23-4b23-9e03-f59148f8b2ed 0 2026-07-17 15:17:02.979184+00
|
||||||
|
1f6400bb-0b9a-444c-b793-e1cbd96ec215 2026-07-16 mittagessen_veg 7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a 0 2026-07-17 15:17:02.979184+00
|
||||||
|
7df88a12-6fb8-457e-8cf8-5ddc43e06583 2026-07-13 suppe ea26f18b-ddf3-4899-95e7-5dbd0878d90c 0 2026-07-17 15:17:02.979184+00
|
||||||
|
e2bfe62e-12c4-4f68-a7e7-ca1ee30a6039 2026-07-14 suppe 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:17:02.979184+00
|
||||||
|
662902e0-bb5e-4268-b977-cef2ac20dc35 2026-07-15 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00
|
||||||
|
6083a3f3-a54e-40c5-be1f-c96e474eb8a0 2026-07-16 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00
|
||||||
|
36279cd1-b0e2-46a8-b46c-c23f9f5dbb1b 2026-07-17 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00
|
||||||
|
ae3f7ced-23b1-42a9-a5d4-2bfab6c333b7 2026-07-22 brotzeit 2b136266-a80d-4c76-8520-38001c92a634 0 2026-07-17 15:17:02.979184+00
|
||||||
|
bfa36cb8-82d6-466c-b954-bf82c6a67b95 2026-07-23 brotzeit bf612ae7-8cdd-4149-8b21-f651631ccc02 0 2026-07-17 15:17:02.979184+00
|
||||||
|
b86caf93-34eb-46a3-bbe4-3a67dcf79be0 2026-07-21 brotzeit 81c58cc6-7298-4d4a-9997-71d17df5aedf 0 2026-07-17 15:17:02.979184+00
|
||||||
|
d8a962cd-f1cf-4f87-95fb-f5af79cbd0a5 2026-07-20 brotzeit cc7fb9d9-a9ab-4c4c-a376-a8fae0e801c9 0 2026-07-17 15:17:02.979184+00
|
||||||
|
c800ef28-70ba-4439-8f1d-e5dcec3ce1c6 2026-07-24 brotzeit a987c96f-25a0-4e5e-b8a8-aab683a01feb 0 2026-07-17 15:17:02.979184+00
|
||||||
|
a5c54296-1667-4d5d-a62d-3983e1643959 2026-07-22 mittagessen 14ef6893-93a7-4649-afe3-d6b6bdf5903c 0 2026-07-17 15:17:02.979184+00
|
||||||
|
4a06dd6c-7de8-4628-b491-3dafd409f344 2026-07-23 mittagessen ec3c5e37-9cbc-4c92-ab05-1e3fb942c504 0 2026-07-17 15:17:02.979184+00
|
||||||
|
ea9a2936-95bf-4cbd-a013-db7ba995efce 2026-07-20 mittagessen dbe5ee6e-8b04-4bf7-99bb-f936f6564217 0 2026-07-17 15:17:02.979184+00
|
||||||
|
64bc501f-dbcd-474c-b47b-1e99670a3c3e 2026-07-21 mittagessen c0613fe7-e5c6-489e-afe5-6da2533b2bd4 0 2026-07-17 15:17:02.979184+00
|
||||||
|
dd4105b4-76b8-4ea1-8ee0-6db663a1ffa8 2026-07-23 mittagessen_veg c5cfa5b4-e502-4b56-b579-b9fbbf817f25 0 2026-07-17 15:17:02.979184+00
|
||||||
|
bac80cc9-ad0e-4cee-81ae-8aa405cad1b9 2026-07-21 mittagessen_veg f8ba1844-1f23-4b23-9e03-f59148f8b2ed 0 2026-07-17 15:17:02.979184+00
|
||||||
|
62bbb0f9-88e2-4b9f-a7e5-c0dab5ff54c8 2026-07-22 mittagessen_veg 7a6fed05-e0f5-4e97-bdd4-a46c5ef1376a 0 2026-07-17 15:17:02.979184+00
|
||||||
|
167e0869-126d-4ab7-8e74-051e5c782865 2026-07-20 mittagessen_veg ff40ea65-63bc-4d01-b626-e0c9e5d2acb3 0 2026-07-17 15:17:02.979184+00
|
||||||
|
34031197-4c3a-4b7e-bc05-7ad7254e8348 2026-07-21 suppe ea26f18b-ddf3-4899-95e7-5dbd0878d90c 0 2026-07-17 15:17:02.979184+00
|
||||||
|
a6f66639-7a0a-4129-bcea-c6ac9cbccac3 2026-07-20 suppe 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:17:02.979184+00
|
||||||
|
b0dbf487-c704-4ab2-af67-082addc88039 2026-07-23 suppe 3c8cf0c2-b0a9-43e3-8233-723d5718a157 0 2026-07-17 15:17:02.979184+00
|
||||||
|
d7b5d414-85c2-495a-9945-2ffc10de32ea 2026-07-24 suppe 2dd85835-5425-4271-9fc0-e5215bbce476 0 2026-07-17 15:17:02.979184+00
|
||||||
|
7a025e66-44b7-43e7-a51b-5564ab8150b1 2026-07-22 suppe 06df753a-65ed-4bd0-a325-d32c78ec88fa 0 2026-07-17 15:17:02.979184+00
|
||||||
|
\.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.menu_entries ENABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: profiles; Type: TABLE DATA; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.profiles DISABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
COPY public.profiles (id, email, full_name, role, created_at) FROM stdin;
|
||||||
|
cee4160a-ab3a-4109-b706-b9f27d515596 fw@wildeflorian.de Florian Wilde admin 2026-07-17 15:17:33.46696+00
|
||||||
|
102d405c-b010-41f6-9409-244004a3c567 kantine@example.com Kantinen-Team staff 2026-07-17 15:17:34.233848+00
|
||||||
|
\.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.profiles ENABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- Data for Name: settings; Type: TABLE DATA; Schema: public; Owner: -
|
||||||
|
--
|
||||||
|
|
||||||
|
ALTER TABLE public.settings DISABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
COPY public.settings (id, colors, logo_path, updated_at) FROM stdin;
|
||||||
|
1 {"rowBg": "#e3e4f1", "accent": "#ff6900", "addBtn": "#6da54c", "primary": "#003388", "rowBgAlt": "#f4f4fa", "deleteBtn": "#e2371d", "headerText": "#ffffff", "primaryDark": "#0d315c"} \N 2026-07-17 15:39:01.866456+00
|
||||||
|
\.
|
||||||
|
|
||||||
|
|
||||||
|
ALTER TABLE public.settings ENABLE TRIGGER ALL;
|
||||||
|
|
||||||
|
--
|
||||||
|
-- PostgreSQL database dump complete
|
||||||
|
--
|
||||||
|
|
||||||
|
\unrestrict HfcGijNcMcy9jPlPpFachgl8LfchhPK39kzgfhc84Q6jsOJpHN3jqf6T368FXri
|
||||||
|
|
||||||
Reference in New Issue
Block a user