Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bb6093dbfe | ||
|
|
088df8a26b | ||
|
|
39181be3fb | ||
|
|
72459d8da5 | ||
|
|
00745ae48b | ||
|
|
675503e1f4 | ||
|
|
1db89a54bf | ||
|
|
29ca8e1227 | ||
|
|
4e4abed0ca |
@@ -16,3 +16,6 @@ SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
|
||||
# SUPABASE_ACCESS_TOKEN=<token> node scripts/configure-passkeys.mjs
|
||||
# ausführen, damit Passkeys auf der neuen Domain funktionieren.
|
||||
APP_URL=http://localhost:3000
|
||||
|
||||
# Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist.
|
||||
SITE_TITLE=Canteen Menu
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Changelog
|
||||
|
||||
Alle nennenswerten Änderungen an diesem Projekt werden hier dokumentiert.
|
||||
Format angelehnt an [Keep a Changelog](https://keepachangelog.com/de/1.1.0/),
|
||||
Versionierung nach [Semantic Versioning](https://semver.org/lang/de/).
|
||||
|
||||
## [1.2.0] – 2026-07-18
|
||||
|
||||
### Hinzugefügt
|
||||
- „Heute"-Button in der Wochennavigation (öffentliche Speisekarte und
|
||||
Wochenplan-Editor)
|
||||
- Mobile Ansicht der öffentlichen Speisekarte: umschaltbar zwischen
|
||||
Tag (Standard) und Woche, unabhängig von der Desktop-Tabelle
|
||||
- Allergene und Zusatzstoffe wieder pro Gericht auswählbar (Chip-Auswahl
|
||||
im Formular) und als kleine Kürzel neben dem Gerichtnamen sichtbar –
|
||||
in Speisekarte, mobiler Ansicht und Wochenplan-Editor
|
||||
- Passwort-Wiederholung beim Anlegen neuer Benutzer und beim optionalen
|
||||
Passwort-Reset
|
||||
- Passwort-Anmeldung pro Konto deaktivierbar (nur wenn mindestens ein
|
||||
Passkey registriert ist), Durchsetzung im Login-Flow
|
||||
- `SITE_TITLE`-Umgebungsvariable für Browser-Tab-Titel und Überschrift
|
||||
ohne Logo
|
||||
- Austauschbares Favicon über den Admin-Design-Bereich
|
||||
|
||||
### Geändert
|
||||
- Gemeinsame Navigation für Kantinen- und Admin-Bereich statt zwei
|
||||
getrennter Menüs
|
||||
- Live-Suche bei den Gerichten
|
||||
- Mehrfachauswahl zum Batch-Löschen von Gerichten
|
||||
- App erreichbar auf `0.0.0.0` statt nur `localhost` (Docker und
|
||||
nativer Betrieb)
|
||||
- Projekt umbenannt von „Speisekarte“ zu „canteen-menu“ (Docker-,
|
||||
npm- und Supabase-Namen; deutsche In-App-Texte unverändert)
|
||||
- Öffentliche URL über `APP_URL` konfigurierbar (CSRF-Origin-Check
|
||||
hinter Reverse-Proxy, Passkey-Domain)
|
||||
|
||||
### Behoben
|
||||
- Next.js band `app/favicon.ico` zusätzlich zu einem dynamisch
|
||||
gesetzten Favicon ein – Favicon wird jetzt immer explizit gesetzt
|
||||
- Weiterleitungen (Logout, Zugriffsschutz) übernahmen nach der
|
||||
0.0.0.0-Bindung fälschlich die Server-Adresse statt des
|
||||
aufgerufenen Hosts
|
||||
|
||||
## [1.1.0] – 2026-07-17
|
||||
|
||||
### Hinzugefügt
|
||||
- Versionsnummer im Fußbereich jeder Seite
|
||||
|
||||
## [1.0.0] – 2026-07-17
|
||||
|
||||
Erste vollständige Version.
|
||||
|
||||
### Hinzugefügt
|
||||
- Öffentliche Speisekarte ohne Login: Wochenansicht Mo–Fr mit den
|
||||
Bereichen Brotzeit, Mittagessen, Mittagessen vegetarisch und Suppe;
|
||||
Wochen-Navigation; Logo zentriert; Allergene & Zusatzstoffe unterhalb
|
||||
der Karte
|
||||
- Anmeldung mit E-Mail/Passwort oder Passkey
|
||||
- Kantinen-Bereich: Gerichte anlegen/bearbeiten/löschen, Wochenplan
|
||||
pflegen, globale Allergen-/Zusatzstofflisten verwalten
|
||||
- Admin-Bereich: Benutzerverwaltung, Farbgebung, Logo-Upload
|
||||
- Eigenes Konto verwalten: Name, E-Mail, Passwort, Passkeys
|
||||
- Row-Level-Security, HTTP-Security-Header, CSRF-Origin-Check
|
||||
- Docker-Setup (App-Image, lokale PostgreSQL-Kopie, Dev-Profil ohne
|
||||
lokales Node.js)
|
||||
- Lokale PostgreSQL-Kopie mit Sync-Skript
|
||||
@@ -7,16 +7,21 @@ Admin-Bereich für Benutzerverwaltung und Branding.
|
||||
**Stack:** Next.js 16 (App Router, TypeScript, Tailwind CSS) · Supabase
|
||||
(PostgreSQL, Auth inkl. Passkeys, Storage, Row Level Security)
|
||||
|
||||
Änderungen je Version stehen in [CHANGELOG.md](CHANGELOG.md), Git-Tags
|
||||
markieren die jeweiligen Versionsstände (`git tag`).
|
||||
|
||||
## Funktionen
|
||||
|
||||
| Bereich | Funktionen |
|
||||
| --- | --- |
|
||||
| Ö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 |
|
||||
| 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 |
|
||||
| Admin (`/admin`) | Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo hochladen/entfernen |
|
||||
| Ö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/Heute; auf schmalen Bildschirmen umschaltbare Tages-/Wochenansicht (Tag ist Standard); Logo zentriert über der Karte; Allergen-/Zusatzstoff-Kürzel je Gericht, Legende unterhalb der Karte |
|
||||
| Kantinen-Team (`/intern`) | Gerichte anlegen/bearbeiten/löschen (inkl. Live-Suche und Mehrfachauswahl zum Batch-Löschen) mit Allergenen/Zusatzstoffen je Gericht; Gerichte Tagen/Bereichen zuweisen (mehrere pro Zelle) und wieder entfernen; globale Allergen-/Zusatzstofflisten pflegen; eigenes Konto verwalten (Name, E-Mail, Passwort, Passkeys, Passwort-Anmeldung an/aus) |
|
||||
| Admin (`/admin`) | Kantinenmitarbeiter anlegen/bearbeiten/löschen (inkl. Passwort-Reset mit Wiederholung und Rollenvergabe); Farbgebung der Speisekarte anpassen; Logo und Favicon hochladen/entfernen |
|
||||
|
||||
Anmeldung mit E-Mail + Passwort oder alternativ per **Passkey**
|
||||
(Registrierung im Bereich „Passkeys“ nach dem ersten Login).
|
||||
(Registrierung im eigenen Konto nach dem ersten Login). Die
|
||||
Passwort-Anmeldung kann pro Konto deaktiviert werden, sobald mindestens
|
||||
ein Passkey registriert ist.
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
@@ -67,6 +72,7 @@ Beim Webserver/Hoster müssen genau diese drei Variablen hinterlegt werden
|
||||
| `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!** |
|
||||
| `APP_URL` | Öffentliche URL der App, z. B. `https://menu.example.de` (Origin-Check hinter Reverse-Proxy; zugleich Passkey-Domain) | nein |
|
||||
| `SITE_TITLE` | Titel im Browser-Tab und Überschrift ohne Logo, Default `Canteen Menu` | nein |
|
||||
|
||||
Build & Start auf dem Server – **mit Docker** (empfohlen, Node.js und
|
||||
PostgreSQL müssen nicht installiert sein):
|
||||
|
||||
@@ -108,3 +108,31 @@ body {
|
||||
color: #4b5563;
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
|
||||
/* ---------- Chip-Auswahl (Allergene/Zusatzstoffe je Gericht) ---------- */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-radius: 9999px;
|
||||
padding: 0.2rem 0.65rem;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
border: 1px solid #d1d5db;
|
||||
background: #f9fafb;
|
||||
color: #374151;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
.chip:hover { border-color: var(--c-primary); }
|
||||
.chip-active {
|
||||
background: var(--c-primary);
|
||||
border-color: var(--c-primary);
|
||||
color: #ffffff;
|
||||
}
|
||||
.dish-codes {
|
||||
font-size: 0.65rem;
|
||||
color: #6b7280;
|
||||
vertical-align: super;
|
||||
margin-left: 0.15rem;
|
||||
}
|
||||
|
||||
+2
-1
@@ -12,7 +12,7 @@ export default async function KontoPage() {
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("role, full_name")
|
||||
.select("role, full_name, password_auth_enabled")
|
||||
.eq("id", user.id)
|
||||
.single();
|
||||
if (!profile || !["staff", "admin"].includes(profile.role)) redirect("/");
|
||||
@@ -28,6 +28,7 @@ export default async function KontoPage() {
|
||||
role={profile.role}
|
||||
initialFullName={profile.full_name ?? ""}
|
||||
initialEmail={user.email ?? ""}
|
||||
initialPasswordAuthEnabled={profile.password_auth_enabled}
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+22
-5
@@ -2,9 +2,10 @@ import type { Metadata } from "next";
|
||||
import { Geist } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import { COLOR_VARS, DEFAULT_COLORS } from "@/lib/types";
|
||||
import { COLOR_VARS, DEFAULT_COLORS, faviconUrl, SITE_TITLE } from "@/lib/types";
|
||||
import packageJson from "@/package.json";
|
||||
|
||||
// Farben/Logo sollen Änderungen sofort widerspiegeln.
|
||||
// Farben/Logo/Favicon sollen Änderungen sofort widerspiegeln.
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -12,10 +13,23 @@ const geistSans = Geist({
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Canteen Menu",
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const supabase = await createClient();
|
||||
const { data: settings } = await supabase
|
||||
.from("settings")
|
||||
.select("favicon_path")
|
||||
.eq("id", 1)
|
||||
.maybeSingle();
|
||||
|
||||
return {
|
||||
title: SITE_TITLE,
|
||||
description: "Speisekarte der Betriebskantine",
|
||||
};
|
||||
// Immer explizit setzen: app/favicon.ico würde sonst zusätzlich zu
|
||||
// einem eigenen Favicon eingebunden (Next.js kombiniert Datei-
|
||||
// Konvention und Metadata statt sie zu ersetzen).
|
||||
icons: { icon: faviconUrl(settings?.favicon_path) ?? "/favicon.ico" },
|
||||
};
|
||||
}
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
@@ -41,6 +55,9 @@ export default async function RootLayout({
|
||||
<html lang="de" className={`${geistSans.variable} h-full antialiased`}>
|
||||
<body style={style} className="min-h-full flex flex-col">
|
||||
{children}
|
||||
<footer className="mt-auto px-4 py-2 text-right text-xs text-gray-400 select-none">
|
||||
Version {packageJson.version}
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
+16
-1
@@ -46,7 +46,22 @@ export default function LoginPage() {
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
await redirectByRole(data.user.id);
|
||||
|
||||
const { data: profile } = await supabase
|
||||
.from("profiles")
|
||||
.select("role, password_auth_enabled")
|
||||
.eq("id", data.user.id)
|
||||
.single();
|
||||
if (profile?.password_auth_enabled === false) {
|
||||
await supabase.auth.signOut();
|
||||
setError(
|
||||
"Die Anmeldung mit Passwort ist für dieses Konto deaktiviert. Bitte mit Passkey anmelden."
|
||||
);
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
router.replace(profile?.role === "admin" ? "/admin" : "/intern");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
async function signInWithPasskey() {
|
||||
|
||||
+28
-12
@@ -1,15 +1,18 @@
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import { createClient } from "@/lib/supabase/server";
|
||||
import {
|
||||
addDays,
|
||||
dishCodes,
|
||||
entriesFor,
|
||||
formatDateLong,
|
||||
formatPrice,
|
||||
mondayOf,
|
||||
todayISO,
|
||||
WEEKDAYS,
|
||||
} from "@/lib/menu";
|
||||
import { logoUrl } from "@/lib/types";
|
||||
import { logoUrl, SITE_TITLE } from "@/lib/types";
|
||||
import type { CodeItem, MenuEntry, Section } from "@/lib/types";
|
||||
import PublicMobileMenu from "@/components/PublicMobileMenu";
|
||||
|
||||
export default async function Home({
|
||||
searchParams,
|
||||
@@ -19,6 +22,7 @@ export default async function Home({
|
||||
const { woche } = await searchParams;
|
||||
const monday = mondayOf(woche);
|
||||
const friday = addDays(monday, 4);
|
||||
const currentWeek = mondayOf(todayISO());
|
||||
const days = WEEKDAYS.map((weekday, i) => ({
|
||||
weekday,
|
||||
iso: addDays(monday, i),
|
||||
@@ -30,7 +34,9 @@ export default async function Home({
|
||||
supabase.from("sections").select("*").order("sort"),
|
||||
supabase
|
||||
.from("menu_entries")
|
||||
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)")
|
||||
.select(
|
||||
"id, menu_date, section_key, position, dish:dishes(id, name, price, allergens, additives)"
|
||||
)
|
||||
.gte("menu_date", monday)
|
||||
.lte("menu_date", friday)
|
||||
.order("position")
|
||||
@@ -46,9 +52,6 @@ export default async function Home({
|
||||
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);
|
||||
|
||||
return (
|
||||
<div className="flex-1 px-4 py-6 max-w-[1600px] mx-auto w-full">
|
||||
{/* Kopfbereich: Logo zentriert, Login-Link rechts */}
|
||||
@@ -59,7 +62,7 @@ export default async function Home({
|
||||
<img src={logo} alt="Logo" className="h-20 object-contain" />
|
||||
) : (
|
||||
<h1 className="text-4xl font-extrabold tracking-tight text-[var(--c-primary)]">
|
||||
Speisekarte
|
||||
{SITE_TITLE}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
@@ -71,17 +74,24 @@ export default async function Home({
|
||||
</Link>
|
||||
</header>
|
||||
|
||||
{/* Wochen-Navigation */}
|
||||
{/* Mobil: Tag (Standard) oder Woche umschaltbar */}
|
||||
<PublicMobileMenu monday={monday} sections={sections} entries={entries} />
|
||||
|
||||
{/* Ab "sm": klassische Wochentabelle */}
|
||||
<div className="hidden sm:block">
|
||||
<div className="flex gap-3 mb-4">
|
||||
<Link href={`/?woche=${addDays(monday, -7)}`} className="btn btn-add">
|
||||
← Vorherige Woche
|
||||
</Link>
|
||||
<Link href={`/?woche=${currentWeek}`} className="btn btn-primary">
|
||||
Heute
|
||||
</Link>
|
||||
<Link href={`/?woche=${addDays(monday, 7)}`} className="btn btn-add">
|
||||
Nächste Woche →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Speisekarte */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="menu-table">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -104,21 +114,27 @@ export default async function Home({
|
||||
</th>
|
||||
{days.map((d) => (
|
||||
<td key={d.iso} className="cell">
|
||||
{cell(d.iso, s.key).map((e) => (
|
||||
{entriesFor(entries, d.iso, s.key).map((e) => {
|
||||
const codes = dishCodes(e.dish);
|
||||
return (
|
||||
<div key={e.id} className="dish">
|
||||
<span className="font-bold">{e.dish.name}</span>
|
||||
{codes && <sup className="dish-codes">{codes}</sup>}
|
||||
{" – "}
|
||||
{formatPrice(e.dish.price)}
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Allergene & Zusatzstoffe (gelten für die komplette Speisekarte) */}
|
||||
{/* Allergene & Zusatzstoffe: Legende zu den Kürzeln bei den Gerichten */}
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import PasskeyManager from "@/components/PasskeyManager";
|
||||
@@ -24,10 +24,12 @@ export default function AccountManager({
|
||||
role,
|
||||
initialFullName,
|
||||
initialEmail,
|
||||
initialPasswordAuthEnabled,
|
||||
}: {
|
||||
role: string;
|
||||
initialFullName: string;
|
||||
initialEmail: string;
|
||||
initialPasswordAuthEnabled: boolean;
|
||||
}) {
|
||||
const supabase = useMemo(() => createClient(), []);
|
||||
const router = useRouter();
|
||||
@@ -52,6 +54,21 @@ export default function AccountManager({
|
||||
const [pwInfo, setPwInfo] = useState<string | null>(null);
|
||||
const [pwBusy, setPwBusy] = useState(false);
|
||||
|
||||
// Passwort-Anmeldung an/aus
|
||||
const [passwordAuthEnabled, setPasswordAuthEnabled] = useState(
|
||||
initialPasswordAuthEnabled
|
||||
);
|
||||
const [passkeyCount, setPasskeyCount] = useState<number | null>(null);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [authInfo, setAuthInfo] = useState<string | null>(null);
|
||||
const [authBusy, setAuthBusy] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
supabase.auth.passkey
|
||||
.list()
|
||||
.then(({ data }) => setPasskeyCount((data ?? []).length));
|
||||
}, [supabase]);
|
||||
|
||||
async function saveName(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setNameError(null);
|
||||
@@ -129,6 +146,40 @@ export default function AccountManager({
|
||||
setPwBusy(false);
|
||||
}
|
||||
|
||||
async function togglePasswordAuth() {
|
||||
setAuthError(null);
|
||||
setAuthInfo(null);
|
||||
const next = !passwordAuthEnabled;
|
||||
if (!next && (passkeyCount ?? 0) === 0) {
|
||||
setAuthError(
|
||||
"Ohne registrierten Passkey kannst du die Passwort-Anmeldung nicht deaktivieren – sonst wärst du ausgesperrt. Registriere zuerst einen Passkey weiter unten."
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!next &&
|
||||
!window.confirm(
|
||||
"Anmeldung mit E-Mail und Passwort wirklich deaktivieren? Du kannst dich danach nur noch per Passkey anmelden."
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setAuthBusy(true);
|
||||
const { error } = await supabase
|
||||
.from("profiles")
|
||||
.update({ password_auth_enabled: next })
|
||||
.eq("id", (await supabase.auth.getUser()).data.user?.id ?? "");
|
||||
if (error) {
|
||||
setAuthError(`Speichern fehlgeschlagen: ${error.message}`);
|
||||
} else {
|
||||
setPasswordAuthEnabled(next);
|
||||
setAuthInfo(
|
||||
next ? "Passwort-Anmeldung aktiviert." : "Passwort-Anmeldung deaktiviert."
|
||||
);
|
||||
}
|
||||
setAuthBusy(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
<div>
|
||||
@@ -256,6 +307,30 @@ export default function AccountManager({
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2 className="font-semibold mb-1">Anmeldung mit Passwort</h2>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Du kannst die Anmeldung mit E-Mail und Passwort für dein Konto
|
||||
deaktivieren und dich künftig ausschließlich per Passkey anmelden.
|
||||
Dafür muss mindestens ein Passkey registriert sein.
|
||||
</p>
|
||||
{authError && (
|
||||
<p className="mb-2 text-sm text-[var(--c-del)]" role="alert">
|
||||
{authError}
|
||||
</p>
|
||||
)}
|
||||
{authInfo && <p className="mb-2 text-sm text-green-700">{authInfo}</p>}
|
||||
<label className="flex items-center gap-2 cursor-pointer text-sm w-fit">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={passwordAuthEnabled}
|
||||
onChange={togglePasswordAuth}
|
||||
disabled={authBusy || passkeyCount === null}
|
||||
/>
|
||||
Anmeldung mit E-Mail und Passwort erlaubt
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<PasskeyManager />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client";
|
||||
|
||||
import type { CodeItem } from "@/lib/types";
|
||||
|
||||
export default function ChipPicker({
|
||||
items,
|
||||
selected,
|
||||
onToggle,
|
||||
label,
|
||||
}: {
|
||||
items: CodeItem[];
|
||||
selected: string[];
|
||||
onToggle: (code: string) => void;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<label className="label">{label}</label>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Keine Einträge vorhanden.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{items.map((item) => {
|
||||
const active = selected.includes(item.code);
|
||||
return (
|
||||
<button
|
||||
key={item.code}
|
||||
type="button"
|
||||
onClick={() => onToggle(item.code)}
|
||||
className={`chip ${active ? "chip-active" : ""}`}
|
||||
aria-pressed={active}
|
||||
title={item.name}
|
||||
>
|
||||
<span className="font-semibold">{item.code}</span> {item.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+80
-21
@@ -3,13 +3,21 @@
|
||||
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";
|
||||
import { COLOR_LABELS, DEFAULT_COLORS, faviconUrl, logoUrl } from "@/lib/types";
|
||||
|
||||
type AssetKind = "logo" | "favicon";
|
||||
|
||||
const ASSET_COLUMN: Record<AssetKind, "logo_path" | "favicon_path"> = {
|
||||
logo: "logo_path",
|
||||
favicon: "favicon_path",
|
||||
};
|
||||
|
||||
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 [faviconPath, setFaviconPath] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [info, setInfo] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -17,7 +25,7 @@ export default function DesignEditor() {
|
||||
const load = useCallback(async () => {
|
||||
const { data, error } = await supabase
|
||||
.from("settings")
|
||||
.select("colors, logo_path")
|
||||
.select("colors, logo_path, favicon_path")
|
||||
.eq("id", 1)
|
||||
.maybeSingle();
|
||||
if (error) {
|
||||
@@ -26,6 +34,7 @@ export default function DesignEditor() {
|
||||
}
|
||||
setColors({ ...DEFAULT_COLORS, ...(data?.colors ?? {}) });
|
||||
setLogoPath(data?.logo_path ?? null);
|
||||
setFaviconPath(data?.favicon_path ?? null);
|
||||
}, [supabase]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -52,20 +61,21 @@ export default function DesignEditor() {
|
||||
setColors(DEFAULT_COLORS);
|
||||
}
|
||||
|
||||
async function uploadLogo(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
async function uploadAsset(kind: AssetKind, 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).");
|
||||
setError("Bitte eine Bilddatei auswählen (PNG, JPG, SVG, WebP, ICO).");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setInfo(null);
|
||||
|
||||
const oldPath = kind === "logo" ? logoPath : faviconPath;
|
||||
const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
|
||||
const path = `logo-${Date.now()}.${ext}`;
|
||||
const path = `${kind}-${Date.now()}.${ext}`;
|
||||
const { error: uploadError } = await supabase.storage
|
||||
.from("branding")
|
||||
.upload(path, file, { contentType: file.type });
|
||||
@@ -75,46 +85,53 @@ export default function DesignEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
const oldPath = logoPath;
|
||||
const { error: settingsError } = await supabase
|
||||
.from("settings")
|
||||
.update({ logo_path: path })
|
||||
.update({ [ASSET_COLUMN[kind]]: 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.");
|
||||
if (oldPath) await supabase.storage.from("branding").remove([oldPath]);
|
||||
|
||||
if (kind === "logo") setLogoPath(path);
|
||||
else setFaviconPath(path);
|
||||
setInfo(kind === "logo" ? "Logo aktualisiert." : "Favicon aktualisiert.");
|
||||
router.refresh();
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
async function removeLogo() {
|
||||
if (!logoPath) return;
|
||||
if (!window.confirm("Logo wirklich entfernen?")) return;
|
||||
async function removeAsset(kind: AssetKind) {
|
||||
const path = kind === "logo" ? logoPath : faviconPath;
|
||||
if (!path) return;
|
||||
if (
|
||||
!window.confirm(
|
||||
`${kind === "logo" ? "Logo" : "Favicon"} wirklich entfernen?`
|
||||
)
|
||||
)
|
||||
return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const { error } = await supabase
|
||||
.from("settings")
|
||||
.update({ logo_path: null })
|
||||
.update({ [ASSET_COLUMN[kind]]: null })
|
||||
.eq("id", 1);
|
||||
if (error) {
|
||||
setError(`Speichern fehlgeschlagen: ${error.message}`);
|
||||
} else {
|
||||
await supabase.storage.from("branding").remove([logoPath]);
|
||||
setLogoPath(null);
|
||||
setInfo("Logo entfernt.");
|
||||
await supabase.storage.from("branding").remove([path]);
|
||||
if (kind === "logo") setLogoPath(null);
|
||||
else setFaviconPath(null);
|
||||
setInfo(kind === "logo" ? "Logo entfernt." : "Favicon entfernt.");
|
||||
router.refresh();
|
||||
}
|
||||
setBusy(false);
|
||||
}
|
||||
|
||||
const currentLogo = logoUrl(logoPath);
|
||||
const currentFavicon = faviconUrl(faviconPath);
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-6">
|
||||
@@ -149,7 +166,7 @@ export default function DesignEditor() {
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={uploadLogo}
|
||||
onChange={(e) => uploadAsset("logo", e)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
@@ -157,7 +174,7 @@ export default function DesignEditor() {
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-del"
|
||||
onClick={removeLogo}
|
||||
onClick={() => removeAsset("logo")}
|
||||
disabled={busy}
|
||||
>
|
||||
Logo entfernen
|
||||
@@ -166,6 +183,48 @@ export default function DesignEditor() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2 className="font-semibold mb-3">Favicon</h2>
|
||||
<p className="text-sm text-gray-600 mb-3">
|
||||
Wird als Icon im Browser-Tab angezeigt. Ohne eigenes Favicon gilt
|
||||
das Standard-Icon der Anwendung.
|
||||
</p>
|
||||
{currentFavicon ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={currentFavicon}
|
||||
alt="Aktuelles Favicon"
|
||||
className="h-10 w-10 object-contain mb-3 border border-gray-200 rounded p-1"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-sm text-gray-400 mb-3">
|
||||
Kein eigenes Favicon hinterlegt.
|
||||
</p>
|
||||
)}
|
||||
<div className="flex gap-2 items-center">
|
||||
<label className="btn btn-primary cursor-pointer">
|
||||
Favicon hochladen
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => uploadAsset("favicon", e)}
|
||||
disabled={busy}
|
||||
/>
|
||||
</label>
|
||||
{currentFavicon && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-del"
|
||||
onClick={() => removeAsset("favicon")}
|
||||
disabled={busy}
|
||||
>
|
||||
Favicon entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2 className="font-semibold mb-3">Farbgebung</h2>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
|
||||
+77
-15
@@ -2,20 +2,31 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
import { formatPrice } from "@/lib/menu";
|
||||
import type { Dish } from "@/lib/types";
|
||||
import { dishCodes, formatPrice } from "@/lib/menu";
|
||||
import type { CodeItem, Dish } from "@/lib/types";
|
||||
import ChipPicker from "@/components/ChipPicker";
|
||||
|
||||
interface DishForm {
|
||||
id: string | null; // null = neues Gericht
|
||||
name: string;
|
||||
price: string;
|
||||
allergens: string[];
|
||||
additives: string[];
|
||||
}
|
||||
|
||||
const EMPTY_FORM: DishForm = { id: null, name: "", price: "" };
|
||||
const EMPTY_FORM: DishForm = {
|
||||
id: null,
|
||||
name: "",
|
||||
price: "",
|
||||
allergens: [],
|
||||
additives: [],
|
||||
};
|
||||
|
||||
export default function DishManager() {
|
||||
const supabase = useMemo(() => createClient(), []);
|
||||
const [dishes, setDishes] = useState<Dish[]>([]);
|
||||
const [allergens, setAllergens] = useState<CodeItem[]>([]);
|
||||
const [additives, setAdditives] = useState<CodeItem[]>([]);
|
||||
const [form, setForm] = useState<DishForm | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -30,12 +41,18 @@ export default function DishManager() {
|
||||
}, [dishes, search]);
|
||||
|
||||
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[]);
|
||||
const [dishesRes, allergensRes, additivesRes] = await Promise.all([
|
||||
supabase.from("dishes").select("*").order("name"),
|
||||
supabase.from("allergens").select("*").order("sort"),
|
||||
supabase.from("additives").select("*").order("sort"),
|
||||
]);
|
||||
const problem = dishesRes.error ?? allergensRes.error ?? additivesRes.error;
|
||||
if (problem) setError(`Laden fehlgeschlagen: ${problem.message}`);
|
||||
else {
|
||||
setDishes(dishesRes.data as Dish[]);
|
||||
setAllergens(allergensRes.data as CodeItem[]);
|
||||
setAdditives(additivesRes.data as CodeItem[]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [supabase]);
|
||||
|
||||
@@ -52,7 +69,12 @@ export default function DishManager() {
|
||||
setError("Bitte einen gültigen Preis angeben, z. B. 3,50");
|
||||
return;
|
||||
}
|
||||
const payload = { name: form.name.trim(), price };
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
price,
|
||||
allergens: form.allergens,
|
||||
additives: form.additives,
|
||||
};
|
||||
const { error } = form.id
|
||||
? await supabase.from("dishes").update(payload).eq("id", form.id)
|
||||
: await supabase.from("dishes").insert(payload);
|
||||
@@ -122,6 +144,26 @@ export default function DishManager() {
|
||||
await load();
|
||||
}
|
||||
|
||||
function toggleAllergen(code: string) {
|
||||
if (!form) return;
|
||||
setForm({
|
||||
...form,
|
||||
allergens: form.allergens.includes(code)
|
||||
? form.allergens.filter((c) => c !== code)
|
||||
: [...form.allergens, code],
|
||||
});
|
||||
}
|
||||
|
||||
function toggleAdditive(code: string) {
|
||||
if (!form) return;
|
||||
setForm({
|
||||
...form,
|
||||
additives: form.additives.includes(code)
|
||||
? form.additives.filter((c) => c !== code)
|
||||
: [...form.additives, code],
|
||||
});
|
||||
}
|
||||
|
||||
const allSelected =
|
||||
visible.length > 0 && visible.every((d) => selected.has(d.id));
|
||||
|
||||
@@ -147,7 +189,7 @@ export default function DishManager() {
|
||||
)}
|
||||
|
||||
{form && (
|
||||
<form onSubmit={save} className="card mb-4 space-y-3">
|
||||
<form onSubmit={save} className="card mb-4 space-y-4">
|
||||
<h2 className="font-semibold">
|
||||
{form.id ? "Gericht bearbeiten" : "Neues Gericht"}
|
||||
</h2>
|
||||
@@ -177,6 +219,18 @@ export default function DishManager() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<ChipPicker
|
||||
label="Allergene"
|
||||
items={allergens}
|
||||
selected={form.allergens}
|
||||
onToggle={toggleAllergen}
|
||||
/>
|
||||
<ChipPicker
|
||||
label="Zusatzstoffe"
|
||||
items={additives}
|
||||
selected={form.additives}
|
||||
onToggle={toggleAdditive}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="btn btn-add">
|
||||
Speichern
|
||||
@@ -230,7 +284,9 @@ export default function DishManager() {
|
||||
</div>
|
||||
)}
|
||||
<ul className="divide-y divide-gray-200 border border-gray-200 rounded-md bg-white">
|
||||
{visible.map((dish) => (
|
||||
{visible.map((dish) => {
|
||||
const codes = dishCodes(dish);
|
||||
return (
|
||||
<li key={dish.id} className="flex items-center gap-3 px-4 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -239,8 +295,11 @@ export default function DishManager() {
|
||||
aria-label={`${dish.name} auswählen`}
|
||||
/>
|
||||
<span className="mr-auto">
|
||||
<span className="font-semibold">{dish.name}</span>{" "}
|
||||
<span className="text-gray-500">– {formatPrice(dish.price)}</span>
|
||||
<span className="font-semibold">{dish.name}</span>
|
||||
{codes && <sup className="dish-codes">{codes}</sup>}{" "}
|
||||
<span className="text-gray-500">
|
||||
– {formatPrice(dish.price)}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
@@ -250,6 +309,8 @@ export default function DishManager() {
|
||||
id: dish.id,
|
||||
name: dish.name,
|
||||
price: String(dish.price).replace(".", ","),
|
||||
allergens: dish.allergens ?? [],
|
||||
additives: dish.additives ?? [],
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -263,7 +324,8 @@ export default function DishManager() {
|
||||
Löschen
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{dishes.length === 0 && (
|
||||
<li className="px-4 py-3 text-gray-500">
|
||||
Noch keine Gerichte angelegt.
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"use client";
|
||||
|
||||
// Mobile Ansicht der öffentlichen Speisekarte: Tag (Standard) oder Woche,
|
||||
// umschaltbar ohne Seiten-Reload. Ab dem "sm"-Breakpoint übernimmt die
|
||||
// normale Wochentabelle (siehe app/page.tsx), diese Komponente blendet
|
||||
// sich dort per CSS aus.
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
addDays,
|
||||
dishCodes,
|
||||
entriesFor,
|
||||
formatDateLong,
|
||||
formatPrice,
|
||||
mondayOf,
|
||||
todayISO,
|
||||
weekdayIndex,
|
||||
WEEKDAYS,
|
||||
WEEKDAYS_SHORT,
|
||||
} from "@/lib/menu";
|
||||
import type { MenuEntry, Section } from "@/lib/types";
|
||||
|
||||
export default function PublicMobileMenu({
|
||||
monday,
|
||||
sections,
|
||||
entries,
|
||||
}: {
|
||||
monday: string;
|
||||
sections: Section[];
|
||||
entries: MenuEntry[];
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const days = WEEKDAYS.map((weekday, i) => ({
|
||||
weekday,
|
||||
short: WEEKDAYS_SHORT[i],
|
||||
iso: addDays(monday, i),
|
||||
}));
|
||||
const isCurrentWeek = monday === mondayOf(todayISO());
|
||||
const todayIdx = isCurrentWeek ? weekdayIndex(todayISO()) : -1;
|
||||
|
||||
const [tab, setTab] = useState<"tag" | "woche">("tag");
|
||||
const [selectedIdx, setSelectedIdx] = useState(todayIdx >= 0 ? todayIdx : 0);
|
||||
|
||||
function goToday() {
|
||||
const currentMonday = mondayOf(todayISO());
|
||||
if (currentMonday === monday) {
|
||||
const idx = weekdayIndex(todayISO());
|
||||
setSelectedIdx(idx >= 0 ? idx : 0);
|
||||
setTab("tag");
|
||||
} else {
|
||||
router.push(`/?woche=${currentMonday}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderDay(day: { weekday: string; iso: string }) {
|
||||
return (
|
||||
<div key={day.iso} className="mb-4 last:mb-0">
|
||||
<h3 className="font-bold text-[var(--c-primary)] mb-2">
|
||||
{day.weekday} · {formatDateLong(day.iso)}
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{sections.map((s) => {
|
||||
const items = entriesFor(entries, day.iso, s.key);
|
||||
return (
|
||||
<div
|
||||
key={s.key}
|
||||
className="rounded-md border border-gray-200 overflow-hidden"
|
||||
>
|
||||
<div className="bg-[var(--c-primary)] text-[var(--c-header-text)] px-3 py-1.5 text-sm">
|
||||
<span className="font-semibold">{s.label}</span>{" "}
|
||||
<span className="opacity-80">· {s.time_range}</span>
|
||||
</div>
|
||||
<div className="bg-[var(--c-row-bg)] px-3 py-2 text-sm">
|
||||
{items.length === 0 ? (
|
||||
<p className="text-gray-500">–</p>
|
||||
) : (
|
||||
items.map((e) => {
|
||||
const codes = dishCodes(e.dish);
|
||||
return (
|
||||
<p key={e.id} className="mb-1 last:mb-0">
|
||||
<span className="font-semibold">{e.dish.name}</span>
|
||||
{codes && <sup className="dish-codes">{codes}</sup>}{" "}
|
||||
– {formatPrice(e.dish.price)}
|
||||
</p>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sm:hidden mb-6">
|
||||
<div className="flex gap-2 mb-3">
|
||||
<Link
|
||||
href={`/?woche=${addDays(monday, -7)}`}
|
||||
className="btn btn-add flex-1 justify-center"
|
||||
>
|
||||
← Woche
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={goToday}
|
||||
className="btn btn-primary flex-1"
|
||||
>
|
||||
Heute
|
||||
</button>
|
||||
<Link
|
||||
href={`/?woche=${addDays(monday, 7)}`}
|
||||
className="btn btn-add flex-1 justify-center"
|
||||
>
|
||||
Woche →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 mb-3" role="tablist" aria-label="Ansicht">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "tag"}
|
||||
onClick={() => setTab("tag")}
|
||||
className={`btn flex-1 ${tab === "tag" ? "btn-primary" : "btn-neutral"}`}
|
||||
>
|
||||
Tag
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === "woche"}
|
||||
onClick={() => setTab("woche")}
|
||||
className={`btn flex-1 ${tab === "woche" ? "btn-primary" : "btn-neutral"}`}
|
||||
>
|
||||
Woche
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "tag" ? (
|
||||
<>
|
||||
<div className="flex gap-1 mb-3">
|
||||
{days.map((d, i) => (
|
||||
<button
|
||||
key={d.iso}
|
||||
type="button"
|
||||
onClick={() => setSelectedIdx(i)}
|
||||
className={`flex-1 rounded-md py-1.5 text-sm font-semibold border transition-colors ${
|
||||
i === selectedIdx
|
||||
? "bg-[var(--c-primary)] text-white border-[var(--c-primary)]"
|
||||
: "bg-white text-gray-600 border-gray-200"
|
||||
} ${i === todayIdx ? "ring-2 ring-[var(--c-accent)] ring-offset-1" : ""}`}
|
||||
>
|
||||
{d.short}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{renderDay(days[selectedIdx])}
|
||||
</>
|
||||
) : (
|
||||
<div>{days.map((d) => renderDay(d))}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ interface UserForm {
|
||||
full_name: string;
|
||||
role: Role;
|
||||
password: string; // bei Bearbeitung optional (leer = unverändert)
|
||||
passwordRepeat: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: UserForm = {
|
||||
@@ -18,6 +19,7 @@ const EMPTY_FORM: UserForm = {
|
||||
full_name: "",
|
||||
role: "staff",
|
||||
password: "",
|
||||
passwordRepeat: "",
|
||||
};
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
@@ -74,6 +76,10 @@ export default function UserManager() {
|
||||
async function save(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!form) return;
|
||||
if (form.password && form.password !== form.passwordRepeat) {
|
||||
setError("Die Passwörter stimmen nicht überein.");
|
||||
return;
|
||||
}
|
||||
const ok = form.id
|
||||
? await callApi("PATCH", {
|
||||
id: form.id,
|
||||
@@ -186,6 +192,25 @@ export default function UserManager() {
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
{(!form.id || form.password) && (
|
||||
<div className="max-w-60">
|
||||
<label className="label" htmlFor="user-password-repeat">
|
||||
Passwort wiederholen
|
||||
</label>
|
||||
<input
|
||||
id="user-password-repeat"
|
||||
type="password"
|
||||
className="input"
|
||||
value={form.passwordRepeat}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, passwordRepeat: e.target.value })
|
||||
}
|
||||
minLength={8}
|
||||
required={!form.id || !!form.password}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2">
|
||||
<button type="submit" className="btn btn-add" disabled={busy}>
|
||||
Speichern
|
||||
@@ -221,6 +246,7 @@ export default function UserManager() {
|
||||
full_name: p.full_name,
|
||||
role: p.role,
|
||||
password: "",
|
||||
passwordRepeat: "",
|
||||
})
|
||||
}
|
||||
>
|
||||
|
||||
@@ -3,7 +3,16 @@
|
||||
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 {
|
||||
addDays,
|
||||
dishCodes,
|
||||
entriesFor,
|
||||
formatDateLong,
|
||||
formatPrice,
|
||||
mondayOf,
|
||||
todayISO,
|
||||
WEEKDAYS,
|
||||
} from "@/lib/menu";
|
||||
import type { Dish, MenuEntry, Section } from "@/lib/types";
|
||||
|
||||
export default function WeekEditor({ monday }: { monday: string }) {
|
||||
@@ -32,7 +41,9 @@ export default function WeekEditor({ monday }: { monday: string }) {
|
||||
supabase.from("dishes").select("*").order("name"),
|
||||
supabase
|
||||
.from("menu_entries")
|
||||
.select("id, menu_date, section_key, position, dish:dishes(id, name, price)")
|
||||
.select(
|
||||
"id, menu_date, section_key, position, dish:dishes(id, name, price, allergens, additives)"
|
||||
)
|
||||
.gte("menu_date", monday)
|
||||
.lte("menu_date", friday)
|
||||
.order("position")
|
||||
@@ -79,9 +90,6 @@ export default function WeekEditor({ monday }: { monday: string }) {
|
||||
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">
|
||||
@@ -91,6 +99,12 @@ export default function WeekEditor({ monday }: { monday: string }) {
|
||||
<Link href={`/intern?woche=${addDays(monday, -7)}`} className="btn btn-add">
|
||||
← Vorherige Woche
|
||||
</Link>
|
||||
<Link
|
||||
href={`/intern?woche=${mondayOf(todayISO())}`}
|
||||
className="btn btn-primary"
|
||||
>
|
||||
Heute
|
||||
</Link>
|
||||
<Link href={`/intern?woche=${addDays(monday, 7)}`} className="btn btn-add">
|
||||
Nächste Woche →
|
||||
</Link>
|
||||
@@ -129,9 +143,12 @@ export default function WeekEditor({ monday }: { monday: string }) {
|
||||
adding?.iso === d.iso && adding?.section === s.key;
|
||||
return (
|
||||
<td key={d.iso} className="cell">
|
||||
{cell(d.iso, s.key).map((e) => (
|
||||
{entriesFor(entries, d.iso, s.key).map((e) => {
|
||||
const codes = dishCodes(e.dish);
|
||||
return (
|
||||
<div key={e.id} className="dish">
|
||||
<span className="font-bold">{e.dish.name}</span>
|
||||
{codes && <sup className="dish-codes">{codes}</sup>}
|
||||
{" – "}
|
||||
{formatPrice(e.dish.price)}
|
||||
<div className="mt-1">
|
||||
@@ -144,7 +161,8 @@ export default function WeekEditor({ monday }: { monday: string }) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{isAdding ? (
|
||||
<div className="mt-2 space-y-1 text-left">
|
||||
<select
|
||||
|
||||
@@ -34,6 +34,8 @@ services:
|
||||
# Wird für den CSRF-Origin-Check genutzt; dieselbe URL ist die
|
||||
# Passkey-Domain (siehe scripts/configure-passkeys.mjs).
|
||||
APP_URL: ${APP_URL:-http://localhost:3000}
|
||||
# Titel im Browser-Tab und Überschrift, solange kein Logo hochgeladen ist.
|
||||
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
|
||||
ports:
|
||||
- "0.0.0.0:3000:3000"
|
||||
restart: unless-stopped
|
||||
@@ -68,6 +70,7 @@ services:
|
||||
NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY}
|
||||
SUPABASE_SERVICE_ROLE_KEY: ${SUPABASE_SERVICE_ROLE_KEY}
|
||||
APP_URL: ${APP_URL:-http://localhost:3000}
|
||||
SITE_TITLE: ${SITE_TITLE:-Canteen Menu}
|
||||
ports:
|
||||
- "0.0.0.0:3000:3000"
|
||||
volumes:
|
||||
|
||||
+28
@@ -2,6 +2,8 @@
|
||||
// Alle Berechnungen laufen über UTC, damit Server und Browser
|
||||
// unabhängig von der Zeitzone dieselbe Woche ermitteln.
|
||||
|
||||
import type { MenuEntry } from "./types";
|
||||
|
||||
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function toDate(iso: string): Date {
|
||||
@@ -31,7 +33,19 @@ export function addDays(iso: string, days: number): string {
|
||||
return toISO(d);
|
||||
}
|
||||
|
||||
/** Heutiges Datum als YYYY-MM-DD (UTC). */
|
||||
export function todayISO(): string {
|
||||
return toISO(new Date());
|
||||
}
|
||||
|
||||
/** Index (0=Montag…4=Freitag) von `iso` innerhalb seiner Woche, sonst -1 (Wochenende). */
|
||||
export function weekdayIndex(iso: string): number {
|
||||
const day = (toDate(iso).getUTCDay() + 6) % 7;
|
||||
return day <= 4 ? day : -1;
|
||||
}
|
||||
|
||||
export const WEEKDAYS = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag"];
|
||||
export const WEEKDAYS_SHORT = ["Mo", "Di", "Mi", "Do", "Fr"];
|
||||
|
||||
/** z. B. "13. Juli 2026" */
|
||||
export function formatDateLong(iso: string): string {
|
||||
@@ -47,3 +61,17 @@ export function formatDateLong(iso: string): string {
|
||||
export function formatPrice(price: number | string): string {
|
||||
return Number(price).toFixed(2).replace(".", ",") + "€";
|
||||
}
|
||||
|
||||
/** Allergen-/Zusatzstoff-Kürzel eines Gerichts, z. B. "A,G,2" */
|
||||
export function dishCodes(dish: { allergens: string[]; additives: string[] }): string {
|
||||
return [...(dish.allergens ?? []), ...(dish.additives ?? [])].join(",");
|
||||
}
|
||||
|
||||
/** Einträge eines Tages/Bereichs aus der geladenen Wochenliste herausfiltern. */
|
||||
export function entriesFor(
|
||||
entries: MenuEntry[],
|
||||
iso: string,
|
||||
sectionKey: string
|
||||
): MenuEntry[] {
|
||||
return entries.filter((e) => e.menu_date === iso && e.section_key === sectionKey);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Profile {
|
||||
email: string;
|
||||
full_name: string;
|
||||
role: Role;
|
||||
password_auth_enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -19,6 +20,8 @@ export interface Dish {
|
||||
id: string;
|
||||
name: string;
|
||||
price: number;
|
||||
allergens: string[];
|
||||
additives: string[];
|
||||
}
|
||||
|
||||
export interface MenuEntry {
|
||||
@@ -73,3 +76,10 @@ 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}`;
|
||||
}
|
||||
|
||||
export function faviconUrl(path: string | null | undefined): string | null {
|
||||
if (!path) return null;
|
||||
return `${process.env.NEXT_PUBLIC_SUPABASE_URL}/storage/v1/object/public/branding/${path}`;
|
||||
}
|
||||
|
||||
export const SITE_TITLE = process.env.SITE_TITLE?.trim() || "Canteen Menu";
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "canteen-menu",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "canteen-menu",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.0",
|
||||
"dependencies": {
|
||||
"@supabase/ssr": "^0.12.3",
|
||||
"@supabase/supabase-js": "^2.110.7",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "canteen-menu",
|
||||
"version": "0.1.0",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -H 0.0.0.0",
|
||||
|
||||
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -2,6 +2,7 @@
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
|
||||
-- Dumped from database version 17.6
|
||||
-- Dumped by pg_dump version 17.10
|
||||
|
||||
@@ -21,9 +22,6 @@ 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;
|
||||
|
||||
|
||||
@@ -132,7 +130,9 @@ CREATE TABLE public.dishes (
|
||||
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
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
allergens text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
additives text[] DEFAULT '{}'::text[] NOT NULL
|
||||
);
|
||||
|
||||
|
||||
@@ -160,6 +160,7 @@ CREATE TABLE public.profiles (
|
||||
full_name text DEFAULT ''::text NOT NULL,
|
||||
role text DEFAULT 'staff'::text NOT NULL,
|
||||
created_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
password_auth_enabled boolean DEFAULT true NOT NULL,
|
||||
CONSTRAINT profiles_role_check CHECK ((role = ANY (ARRAY['admin'::text, 'staff'::text])))
|
||||
);
|
||||
|
||||
@@ -185,6 +186,7 @@ CREATE TABLE public.settings (
|
||||
colors jsonb DEFAULT '{}'::jsonb NOT NULL,
|
||||
logo_path text,
|
||||
updated_at timestamp with time zone DEFAULT now() NOT NULL,
|
||||
favicon_path text,
|
||||
CONSTRAINT settings_id_check CHECK ((id = 1))
|
||||
);
|
||||
|
||||
@@ -392,10 +394,10 @@ CREATE POLICY profiles_select_own_or_admin ON public.profiles FOR SELECT TO auth
|
||||
|
||||
|
||||
--
|
||||
-- Name: profiles profiles_update_own_name; Type: POLICY; Schema: public; Owner: -
|
||||
-- Name: profiles profiles_update_own; Type: POLICY; Schema: public; Owner: -
|
||||
--
|
||||
|
||||
CREATE POLICY profiles_update_own_name ON public.profiles FOR UPDATE TO authenticated USING ((id = ( SELECT auth.uid() AS uid))) WITH CHECK ((id = ( SELECT auth.uid() AS uid)));
|
||||
CREATE POLICY profiles_update_own ON public.profiles FOR UPDATE TO authenticated USING ((id = ( SELECT auth.uid() AS uid))) WITH CHECK ((id = ( SELECT auth.uid() AS uid)));
|
||||
|
||||
|
||||
--
|
||||
@@ -435,3 +437,4 @@ CREATE POLICY settings_public_read ON public.settings FOR SELECT TO authenticate
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
|
||||
|
||||
+28
-28
@@ -2,7 +2,7 @@
|
||||
-- PostgreSQL database dump
|
||||
--
|
||||
|
||||
\restrict HfcGijNcMcy9jPlPpFachgl8LfchhPK39kzgfhc84Q6jsOJpHN3jqf6T368FXri
|
||||
\restrict WmQEI7C1lM8AheyDeBFMpJzZHr1ZhZm5NphQHzcUeS7IAzg73qb94aso6w4qDad
|
||||
|
||||
-- Dumped from database version 17.6
|
||||
-- Dumped by pg_dump version 17.10
|
||||
@@ -76,25 +76,24 @@ ALTER TABLE public.allergens ENABLE TRIGGER ALL;
|
||||
|
||||
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
|
||||
COPY public.dishes (id, name, price, created_at, updated_at, allergens, additives) FROM stdin;
|
||||
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 {} {}
|
||||
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 {} {}
|
||||
a987c96f-25a0-4e5e-b8a8-aab683a01feb Chili con Carne mit Brötchen 3.90 2026-07-17 15:17:02.979184+00 2026-07-17 22:21:35.276672+00 {A} {4}
|
||||
\.
|
||||
|
||||
|
||||
@@ -123,7 +122,6 @@ ALTER TABLE public.sections ENABLE TRIGGER ALL;
|
||||
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
|
||||
@@ -140,7 +138,6 @@ e2bfe62e-12c4-4f68-a7e7-ca1ee30a6039 2026-07-14 suppe 6222d5f4-05e3-4af4-a41c-3f
|
||||
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
|
||||
@@ -158,6 +155,7 @@ a6f66639-7a0a-4129-bcea-c6ac9cbccac3 2026-07-20 suppe 6222d5f4-05e3-4af4-a41c-3f
|
||||
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
|
||||
2eb81b52-3c50-4449-bef9-bb12aac9991e 2026-07-13 brotzeit 6222d5f4-05e3-4af4-a41c-3f8b4c9c5c78 0 2026-07-17 15:43:22.829239+00
|
||||
\.
|
||||
|
||||
|
||||
@@ -169,9 +167,11 @@ ALTER TABLE public.menu_entries ENABLE TRIGGER ALL;
|
||||
|
||||
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
|
||||
COPY public.profiles (id, email, full_name, role, created_at, password_auth_enabled) FROM stdin;
|
||||
cee4160a-ab3a-4109-b706-b9f27d515596 fw@wildeflorian.de Florian Wilde admin 2026-07-17 15:17:33.46696+00 t
|
||||
6e3ebbd4-a00a-4b08-a799-1a445a3f9eaa test@test.de Hans admin 2026-07-17 16:56:13.331443+00 t
|
||||
102d405c-b010-41f6-9409-244004a3c567 kantine@example.com Kantinen-Team staff 2026-07-17 15:17:34.233848+00 t
|
||||
952b21ad-65a0-4a12-aed1-6c86709e0eb2 kantine@kantine.de Kantine staff 2026-07-17 22:02:36.854844+00 t
|
||||
\.
|
||||
|
||||
|
||||
@@ -183,8 +183,8 @@ ALTER TABLE public.profiles ENABLE TRIGGER ALL;
|
||||
|
||||
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
|
||||
COPY public.settings (id, colors, logo_path, updated_at, favicon_path) FROM stdin;
|
||||
1 {"rowBg": "#e3e4f1", "accent": "#ff6900", "addBtn": "#6da54c", "primary": "#003388", "rowBgAlt": "#f4f4fa", "deleteBtn": "#e2371d", "headerText": "#ffffff", "primaryDark": "#0d315c"} logo-1784305413466.webp 2026-07-17 22:40:52.997491+00 \N
|
||||
\.
|
||||
|
||||
|
||||
@@ -194,5 +194,5 @@ ALTER TABLE public.settings ENABLE TRIGGER ALL;
|
||||
-- PostgreSQL database dump complete
|
||||
--
|
||||
|
||||
\unrestrict HfcGijNcMcy9jPlPpFachgl8LfchhPK39kzgfhc84Q6jsOJpHN3jqf6T368FXri
|
||||
\unrestrict WmQEI7C1lM8AheyDeBFMpJzZHr1ZhZm5NphQHzcUeS7IAzg73qb94aso6w4qDad
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
-- =============================================================
|
||||
-- Allergene/Zusatzstoffe pro Gericht, Passwort-Login abschaltbar,
|
||||
-- austauschbares Favicon
|
||||
-- =============================================================
|
||||
|
||||
-- ---------- Allergene/Zusatzstoffe je Gericht ----------
|
||||
-- Referenziert Kürzel aus den globalen Listen (allergens/additives),
|
||||
-- die weiterhin unterhalb der Speisekarte als Legende dienen.
|
||||
alter table public.dishes
|
||||
add column allergens text[] not null default '{}',
|
||||
add column additives text[] not null default '{}';
|
||||
|
||||
-- ---------- Passwort-Login pro Benutzer abschaltbar ----------
|
||||
alter table public.profiles
|
||||
add column password_auth_enabled boolean not null default true;
|
||||
|
||||
-- Selbstverwaltung: Nutzer dürfen zusätzlich zum Namen (Migration 0003)
|
||||
-- jetzt auch dieses eine Flag für ihr eigenes Konto setzen. Weiterhin
|
||||
-- kein Zugriff auf "role" oder "email" über diesen Weg.
|
||||
grant update (password_auth_enabled) on public.profiles to authenticated;
|
||||
|
||||
-- Klarerer Name für die inzwischen zwei selbstverwaltbaren Spalten.
|
||||
drop policy "profiles_update_own_name" on public.profiles;
|
||||
create policy "profiles_update_own"
|
||||
on public.profiles for update
|
||||
to authenticated
|
||||
using (id = (select auth.uid()))
|
||||
with check (id = (select auth.uid()));
|
||||
|
||||
-- ---------- Austauschbares Favicon ----------
|
||||
alter table public.settings
|
||||
add column favicon_path text;
|
||||
Reference in New Issue
Block a user