Add top nav bar and Heimservice page

Adds a Bestellübersicht/Heimservice/Abmelden nav bar to the layout, and
builds out the Heimservice page with a per-restaurant control panel
(availability windows, ORDER WebApp link, password reset, mobile-number
editor, and a consolidated call popup) mirroring the old interface's
button layout in the current design system.
This commit is contained in:
Pascal Linxweiler
2026-08-10 12:08:44 +02:00
parent 401b199978
commit 1126665195
6 changed files with 421 additions and 2 deletions

View File

@@ -0,0 +1,21 @@
import { RestaurantPanel } from "@/components/heimservice/restaurant-panel";
import { restaurants } from "@/lib/data";
export default function HeimservicePage() {
return (
<main className="mx-auto w-full max-w-[1600px] flex-1 px-6 py-8">
<header className="mb-6">
<h1 className="text-2xl font-semibold tracking-tight">Heimservice</h1>
<p className="text-sm text-muted-foreground">
Zeitfenster, Zugangsdaten und Rufnummern je Gastronom verwalten.
</p>
</header>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{restaurants.map((restaurant) => (
<RestaurantPanel key={restaurant.name} restaurant={restaurant} />
))}
</div>
</main>
);
}

View File

@@ -3,6 +3,7 @@ import { Geist, Geist_Mono } from "next/font/google";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
import { TopNav } from "@/components/top-nav";
import "./globals.css";
@@ -28,7 +29,10 @@ export default function RootLayout({ children }: LayoutProps<"/">) {
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col bg-background text-foreground">
<TooltipProvider delay={300}>{children}</TooltipProvider>
<TooltipProvider delay={300}>
<TopNav />
{children}
</TooltipProvider>
{/* App läuft bewusst nur im Light Mode – Systemtheme nicht übernehmen. */}
<Toaster theme="light" />
</body>

View File

@@ -0,0 +1,328 @@
"use client";
import * as React from "react";
import {
Check,
ExternalLink,
KeyRound,
Pencil,
Phone,
PhoneCall,
Power,
Smartphone,
Voicemail,
type LucideIcon,
} from "lucide-react";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { telHref } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { Restaurant } from "@/lib/types";
type Props = { restaurant: Restaurant };
/** Ein Kontroll-Panel je Gastronom – Nachbau der 10 Buttons der alten Heimservice-Oberfläche. */
export function RestaurantPanel({ restaurant }: Props) {
const [windowOn, setWindowOn] = React.useState<[boolean, boolean]>([true, true]);
const [resetOpen, setResetOpen] = React.useState(false);
const [editOpen, setEditOpen] = React.useState(false);
const [callOpen, setCallOpen] = React.useState(false);
const [mobile1, setMobile1] = React.useState(restaurant.mobile ?? "");
const [mobile2, setMobile2] = React.useState(restaurant.mobile2 ?? "");
const numbers = [
{ label: "Telefon 1", phone: restaurant.phone1, icon: PhoneCall },
restaurant.phone2 && { label: "Telefon 2", phone: restaurant.phone2, icon: PhoneCall },
mobile1 && { label: "Mobil 1", phone: mobile1, icon: Smartphone },
mobile2 && { label: "Mobil 2", phone: mobile2, icon: Smartphone },
].filter((n): n is { label: string; phone: string; icon: LucideIcon } => Boolean(n));
const [window1, window2] = restaurant.heimserviceWindows;
function toggleWindow(index: 0 | 1) {
setWindowOn((prev) => {
const next: [boolean, boolean] = [...prev];
next[index] = !next[index];
toast.success(
next[index] ? `Zeitfenster ${index + 1} aktiviert` : `Zeitfenster ${index + 1} deaktiviert`,
{ description: restaurant.name },
);
return next;
});
}
function resetPassword() {
setResetOpen(false);
toast.success("Passwort zurückgesetzt", {
description: `Neue Zugangsdaten für ${restaurant.name} wurden versendet.`,
});
}
function saveMobiles() {
setEditOpen(false);
toast.success("Mobilrufnummern aktualisiert", { description: restaurant.name });
}
function sendVoiceTest() {
toast.success("Voice-Message an Telefon 1 gesendet", { description: restaurant.name });
}
return (
<Card>
<CardHeader>
<CardTitle className="text-left text-base">{restaurant.name}</CardTitle>
<p className="mt-0.5 text-left text-sm text-muted-foreground">{restaurant.city}</p>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<div className="flex flex-wrap gap-1.5 text-xs text-muted-foreground">
<span>
Zeitfenster 1: <span className="tabular-nums">{window1}</span>
</span>
{window2 && (
<>
<span aria-hidden>·</span>
<span>
Zeitfenster 2: <span className="tabular-nums">{window2}</span>
</span>
</>
)}
</div>
<Section label="Verfügbarkeit">
<WindowToggle
index={0}
time={window1}
on={windowOn[0]}
onToggle={() => toggleWindow(0)}
/>
<WindowToggle
index={1}
time={window2}
on={windowOn[1]}
onToggle={() => toggleWindow(1)}
disabled={!window2}
/>
<Tile
icon={ExternalLink}
label="ORDER WebApp"
sublabel="Öffnen"
nativeButton={false}
render={<a href={restaurant.orderUrl} target="_blank" rel="noreferrer" />}
/>
</Section>
<Section label="Zugang">
<Tile
icon={KeyRound}
label="Benutzer / Passwort"
sublabel="Zurücksetzen"
onClick={() => setResetOpen(true)}
/>
<Tile
icon={Pencil}
label="Mobilrufnummern"
sublabel="Bearbeiten"
onClick={() => setEditOpen(true)}
/>
</Section>
<Section label="Kontakt">
<Tile
icon={PhoneCall}
label="Anrufen"
sublabel={`${numbers.length} ${numbers.length === 1 ? "Nummer" : "Nummern"}`}
onClick={() => setCallOpen(true)}
/>
<Tile
icon={Voicemail}
label="Voice-Message"
sublabel={`Telefon 1 · ${restaurant.phone1}`}
onClick={sendVoiceTest}
/>
</Section>
</CardContent>
<Dialog open={callOpen} onOpenChange={setCallOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Anrufen</DialogTitle>
<DialogDescription>{restaurant.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2">
{numbers.map(({ label, phone, icon: Icon }) => (
<Button
key={label}
variant="outline"
className="justify-start"
nativeButton={false}
render={<a href={telHref(phone)} />}
>
<Icon />
<span className="truncate text-left">
{label}
<span className="ml-1.5 text-muted-foreground tabular-nums">{phone}</span>
</span>
</Button>
))}
</div>
</DialogContent>
</Dialog>
<AlertDialog open={resetOpen} onOpenChange={setResetOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Passwort zurücksetzen?</AlertDialogTitle>
<AlertDialogDescription>
Die ORDER-Zugangsdaten von {restaurant.name} werden neu vergeben und an den
Gastronomen versendet.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Abbrechen</AlertDialogCancel>
<AlertDialogAction onClick={resetPassword}>Zurücksetzen</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={editOpen} onOpenChange={setEditOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Mobilrufnummern bearbeiten</DialogTitle>
<DialogDescription>{restaurant.name}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-3">
<div className="flex flex-col gap-1.5">
<Label htmlFor={`mobile1-${restaurant.name}`}>Mobil 1</Label>
<Input
id={`mobile1-${restaurant.name}`}
value={mobile1}
onChange={(e) => setMobile1(e.target.value)}
placeholder="+49 …"
/>
</div>
<div className="flex flex-col gap-1.5">
<Label htmlFor={`mobile2-${restaurant.name}`}>Mobil 2</Label>
<Input
id={`mobile2-${restaurant.name}`}
value={mobile2}
onChange={(e) => setMobile2(e.target.value)}
placeholder="+49 …"
/>
</div>
</div>
<DialogFooter>
<DialogClose render={<Button variant="outline" />}>Abbrechen</DialogClose>
<Button onClick={saveMobiles}>
<Check />
Speichern
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Card>
);
}
function Section({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-2">
<p className="text-xs font-medium text-muted-foreground uppercase">{label}</p>
<div className="grid grid-cols-2 gap-2">{children}</div>
</div>
);
}
function WindowToggle({
index,
time,
on,
onToggle,
disabled,
}: {
index: 0 | 1;
time: string | undefined;
on: boolean;
onToggle: () => void;
disabled?: boolean;
}) {
return (
<Button
variant="outline"
disabled={disabled}
onClick={onToggle}
className={cn(
"h-auto w-full flex-col items-start gap-0.5 py-2 whitespace-normal",
!disabled && on && "border-emerald-500/40 bg-emerald-500/10 hover:bg-emerald-500/15",
)}
>
<span className="flex w-full items-center gap-1.5 text-left text-sm font-medium">
<Power className={on && !disabled ? "text-emerald-600" : undefined} />
Zeitfenster {index + 1}
</span>
<span className="w-full text-left text-xs font-normal text-muted-foreground tabular-nums">
{disabled ? "kein Zeitfenster" : `${time} · ${on ? "aktiv" : "pausiert"}`}
</span>
</Button>
);
}
function Tile({
icon: Icon,
label,
sublabel,
onClick,
nativeButton,
render,
}: {
icon: typeof Phone;
label: string;
sublabel: string;
onClick?: () => void;
nativeButton?: boolean;
render?: React.ReactElement;
}) {
return (
<Button
variant="outline"
className="h-auto w-full flex-col items-start gap-0.5 py-2 whitespace-normal"
onClick={onClick}
nativeButton={nativeButton}
render={render}
>
<span className="flex w-full items-center gap-1.5 text-left text-sm font-medium">
<Icon />
{label}
</span>
<span className="w-full truncate text-left text-xs font-normal text-muted-foreground">
{sublabel}
</span>
</Button>
);
}

View File

@@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { LogOut } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
const NAV_ITEMS = [
{ href: "/", label: "Bestellübersicht" },
{ href: "/heimservice", label: "Heimservice" },
];
export function TopNav() {
const pathname = usePathname();
return (
<header className="border-b bg-background">
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center gap-6 px-6">
<span className="text-sm font-semibold tracking-tight">Speisekarte24</span>
<nav className="flex items-center gap-1">
{NAV_ITEMS.map((item) => {
const active = pathname === item.href;
return (
<Link
key={item.href}
href={item.href}
className={cn(
"rounded-md px-3 py-1.5 text-sm font-medium transition-colors",
active
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground",
)}
>
{item.label}
</Link>
);
})}
</nav>
<Button
variant="ghost"
className="ml-auto"
onClick={() => toast.success("Abgemeldet")}
>
<LogOut />
Abmelden
</Button>
</div>
</header>
);
}

View File

@@ -7,7 +7,7 @@ import {
type Restaurant,
} from "./types";
const restaurants: Restaurant[] = [
export const restaurants: Restaurant[] = [
{
name: "Muster Ristorante",
city: "Taben-Rodt",
@@ -15,7 +15,9 @@ const restaurants: Restaurant[] = [
phone1: "+49 6582 123456",
phone2: "+49 6582 123457",
mobile: "+49 171 2223344",
mobile2: "+49 171 2223345",
orderUrl: "https://order.speisekarte24.de/muster-ristorante",
heimserviceWindows: ["10:00 bis 14:30", "17:00 bis 23:00"],
},
{
name: "Pizzeria Da Vinci",
@@ -24,6 +26,7 @@ const restaurants: Restaurant[] = [
phone1: "+49 651 998877",
mobile: "+49 160 5544332",
orderUrl: "https://order.speisekarte24.de/da-vinci",
heimserviceWindows: ["11:00 bis 14:30", "17:30 bis 22:30"],
},
{
name: "Sushi Yama",
@@ -32,7 +35,9 @@ const restaurants: Restaurant[] = [
phone1: "+49 681 445566",
phone2: "+49 681 445567",
mobile: "+49 175 1112233",
mobile2: "+49 175 1112234",
orderUrl: "https://order.speisekarte24.de/sushi-yama",
heimserviceWindows: ["12:00 bis 15:00", "18:00 bis 23:00"],
},
{
name: "Burger Station",
@@ -41,6 +46,7 @@ const restaurants: Restaurant[] = [
phone1: "+49 6501 334455",
mobile: "+49 152 9988776",
orderUrl: "https://order.speisekarte24.de/burger-station",
heimserviceWindows: ["11:30 bis 15:00", "17:00 bis 21:00"],
},
{
name: "Athen Grill",
@@ -48,6 +54,8 @@ const restaurants: Restaurant[] = [
hours: "17:00 bis 23:30",
phone1: "+49 6571 220011",
orderUrl: "https://order.speisekarte24.de/athen-grill",
// Nur Abendschicht, kein zweites Zeitfenster.
heimserviceWindows: ["17:00 bis 23:30", undefined],
},
];

View File

@@ -36,7 +36,10 @@ export type Restaurant = {
phone1: string;
phone2?: string;
mobile?: string;
mobile2?: string;
orderUrl: string;
/** Zeitfenster für den Heimservice, z. B. Mittags- und Abendschicht. Zweites Fenster optional. */
heimserviceWindows: [string, string | undefined];
};
export type Customer = {