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:
21
src/app/heimservice/page.tsx
Normal file
21
src/app/heimservice/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
328
src/components/heimservice/restaurant-panel.tsx
Normal file
328
src/components/heimservice/restaurant-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/components/top-nav.tsx
Normal file
55
src/components/top-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user