310 lines
16 KiB
TypeScript
310 lines
16 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { format, startOfMonth, endOfMonth, eachDayOfInterval, isSameDay, isToday, isBefore, startOfDay } from "date-fns";
|
||
import { de } from "date-fns/locale";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
|
||
import { toast } from "sonner";
|
||
import { Calendar, CheckSquare, ChevronLeft, ChevronRight, EyeOff, Search, XCircle } from "lucide-react";
|
||
import { cn } from "@/lib/utils";
|
||
|
||
type Appointment = {
|
||
id: string;
|
||
bookingGroupId: string | null;
|
||
customerFirstName: string;
|
||
customerLastName: string;
|
||
customerEmail: string;
|
||
customerPhone: string | null;
|
||
notes: string | null;
|
||
startAt: string;
|
||
endAt: string;
|
||
status: "CONFIRMED" | "CANCELLED";
|
||
noShowAt: string | null;
|
||
staff: { id: string; name: string; email: string; slug: string };
|
||
};
|
||
|
||
type GroupedAppointment = {
|
||
key: string;
|
||
id: string;
|
||
status: "CONFIRMED" | "CANCELLED";
|
||
noShowAt: string | null;
|
||
customerFirstName: string;
|
||
customerLastName: string;
|
||
customerEmail: string;
|
||
customerPhone: string | null;
|
||
notes: string | null;
|
||
startAt: string;
|
||
endAt: string;
|
||
staffNames: string[];
|
||
staffCount: number;
|
||
};
|
||
|
||
const STATUS_TABS = [
|
||
{ value: "" as const, label: "Alle" },
|
||
{ value: "CONFIRMED" as const, label: "Bestätigt" },
|
||
{ value: "CANCELLED" as const, label: "Storniert" }
|
||
];
|
||
|
||
export function AppointmentsPanel() {
|
||
const [appointments, setAppointments] = useState<Appointment[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [statusFilter, setStatusFilter] = useState<"" | "CONFIRMED" | "CANCELLED">("");
|
||
const [noShowFilter, setNoShowFilter] = useState(false);
|
||
const [search, setSearch] = useState("");
|
||
const [miniMonth, setMiniMonth] = useState(new Date());
|
||
const [cancelConfirm, setCancelConfirm] = useState<string | null>(null);
|
||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||
const [bulkConfirm, setBulkConfirm] = useState<"" | "cancel" | "noshow" | null>(null);
|
||
const [busy, setBusy] = useState(false);
|
||
|
||
async function loadAppointments() {
|
||
setLoading(true);
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (statusFilter) params.set("status", statusFilter);
|
||
if (noShowFilter) params.set("noShow", "true");
|
||
if (search.trim()) params.set("q", search.trim());
|
||
const res = await fetch(`/api/admin/termine?${params.toString()}`, { cache: "no-store" });
|
||
const data = await res.json();
|
||
setAppointments(data.termine ?? []);
|
||
setSelectedIds(new Set());
|
||
} catch { toast.error("Termine konnten nicht geladen werden."); }
|
||
finally { setLoading(false); }
|
||
}
|
||
|
||
useEffect(() => { void loadAppointments(); }, [statusFilter, noShowFilter]); // eslint-disable-line react-hooks/exhaustive-deps
|
||
|
||
const groupedAppointments = useMemo<GroupedAppointment[]>(() => {
|
||
const groups = new Map<string, GroupedAppointment>();
|
||
for (const a of appointments) {
|
||
const key = a.bookingGroupId ?? a.id;
|
||
const existing = groups.get(key);
|
||
if (!existing) {
|
||
groups.set(key, {
|
||
key, id: a.id, status: a.status, noShowAt: a.noShowAt,
|
||
customerFirstName: a.customerFirstName, customerLastName: a.customerLastName,
|
||
customerEmail: a.customerEmail, customerPhone: a.customerPhone, notes: a.notes,
|
||
startAt: a.startAt, endAt: a.endAt, staffNames: [a.staff.name], staffCount: 1
|
||
});
|
||
} else {
|
||
if (!existing.staffNames.includes(a.staff.name)) existing.staffNames.push(a.staff.name);
|
||
existing.staffCount = existing.staffNames.length;
|
||
if (!existing.noShowAt && a.noShowAt) existing.noShowAt = a.noShowAt;
|
||
}
|
||
}
|
||
return Array.from(groups.values()).sort((a, b) => a.startAt.localeCompare(b.startAt));
|
||
}, [appointments]);
|
||
|
||
const dayCounts = useMemo(() => {
|
||
const map = new Map<string, number>();
|
||
for (const a of appointments) map.set(format(new Date(a.startAt), "yyyy-MM-dd"), (map.get(format(new Date(a.startAt), "yyyy-MM-dd")) ?? 0) + 1);
|
||
return map;
|
||
}, [appointments]);
|
||
|
||
const selectableAppointments = useMemo(() =>
|
||
groupedAppointments.filter((a) => a.status === "CONFIRMED"),
|
||
[groupedAppointments]
|
||
);
|
||
|
||
async function cancelAppointment(id: string) {
|
||
const res = await fetch("/api/admin/termine", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, status: "CANCELLED" }) });
|
||
if (!res.ok) { toast.error("Termin konnte nicht storniert werden"); setCancelConfirm(null); return; }
|
||
toast.success("Termin storniert");
|
||
setCancelConfirm(null);
|
||
await loadAppointments();
|
||
}
|
||
|
||
async function toggleNoShow(id: string, noShow: boolean) {
|
||
const res = await fetch("/api/admin/termine", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, noShow }) });
|
||
if (!res.ok) { toast.error(noShow ? "No-Show konnte nicht gesetzt werden" : "No-Show konnte nicht entfernt werden"); return; }
|
||
toast.success(noShow ? "No-Show markiert" : "No-Show entfernt");
|
||
await loadAppointments();
|
||
}
|
||
|
||
function toggleSelect(id: string) {
|
||
setSelectedIds((prev) => { const next = new Set(prev); if (next.has(id)) next.delete(id); else next.add(id); return next; });
|
||
}
|
||
|
||
function selectAll() {
|
||
setSelectedIds(new Set(selectableAppointments.map((a) => a.key)));
|
||
}
|
||
|
||
function deselectAll() {
|
||
setSelectedIds(new Set());
|
||
}
|
||
|
||
async function bulkAction(action: "cancel" | "noshow") {
|
||
setBusy(true);
|
||
const ids = [...selectedIds];
|
||
let ok = 0;
|
||
let fail = 0;
|
||
for (const id of ids) {
|
||
try {
|
||
const body = action === "cancel" ? { id, status: "CANCELLED" } : { id, noShow: true };
|
||
const res = await fetch("/api/admin/termine", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||
if (res.ok) ok++; else fail++;
|
||
} catch { fail++; }
|
||
}
|
||
toast.success(`${ok} ${action === "cancel" ? "storniert" : "als No-Show markiert"}${fail > 0 ? `, ${fail} fehlgeschlagen` : ""}`);
|
||
setSelectedIds(new Set());
|
||
setBulkConfirm(null);
|
||
setBusy(false);
|
||
await loadAppointments();
|
||
}
|
||
|
||
const monthStart = startOfMonth(miniMonth);
|
||
const monthEnd = endOfMonth(miniMonth);
|
||
const days = eachDayOfInterval({ start: monthStart, end: monthEnd });
|
||
const today = new Date();
|
||
|
||
return (
|
||
<div className="max-w-5xl mx-auto">
|
||
<div className="mb-6">
|
||
<h1 className="text-3xl font-black tracking-tight text-slate-950">Termine</h1>
|
||
<p className="mt-1 text-sm font-medium text-slate-500">{groupedAppointments.length} Termine gefunden</p>
|
||
</div>
|
||
|
||
<div className="mb-5 flex flex-wrap items-center gap-2">
|
||
<div className="flex rounded-xl border border-slate-200 bg-white p-0.5">
|
||
{STATUS_TABS.map((tab) => (
|
||
<button key={tab.value} type="button" onClick={() => setStatusFilter(tab.value)}
|
||
className={cn("rounded-lg px-3 py-1.5 text-xs font-bold transition-all", statusFilter === tab.value ? "bg-slate-900 text-white shadow-sm" : "text-slate-500 hover:text-slate-700")}>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<button type="button" onClick={() => setNoShowFilter(!noShowFilter)}
|
||
className={cn("rounded-xl border px-3 py-1.5 text-xs font-bold transition-all", noShowFilter ? "border-amber-300 bg-amber-50 text-amber-800" : "border-slate-200 bg-white text-slate-500 hover:border-slate-300")}>
|
||
{noShowFilter ? "Nur No-Show" : <><EyeOff className="mr-1 inline h-3 w-3" />No-Show</>}
|
||
</button>
|
||
{selectableAppointments.length > 0 && (
|
||
<button type="button" onClick={selectedIds.size > 0 ? deselectAll : selectAll}
|
||
className="rounded-xl border border-slate-200 bg-white px-3 py-1.5 text-xs font-bold text-slate-500 hover:border-indigo-300 hover:text-indigo-600 transition">
|
||
<CheckSquare className="mr-1 inline h-3 w-3" />
|
||
{selectedIds.size > 0 ? `${selectedIds.size} abwählen` : "Alle auswählen"}
|
||
</button>
|
||
)}
|
||
<div className="flex-1" />
|
||
<div className="relative">
|
||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
|
||
<Input value={search} onChange={(e) => setSearch(e.target.value)} onKeyDown={(e) => e.key === "Enter" && loadAppointments()} placeholder="Name oder E-Mail..." className="h-10 pl-9 w-56" />
|
||
</div>
|
||
<Button type="button" variant="secondary" size="sm" onClick={() => void loadAppointments()}>Suchen</Button>
|
||
</div>
|
||
|
||
<div className="grid gap-6 lg:grid-cols-[260px_1fr]">
|
||
<div className="rounded-2xl border border-slate-200 bg-white shadow-sm p-4 h-fit lg:sticky lg:top-4">
|
||
<div className="flex items-center justify-between mb-3">
|
||
<button onClick={() => setMiniMonth((m) => new Date(m.getFullYear(), m.getMonth() - 1))} className="rounded-lg p-1 hover:bg-slate-100"><ChevronLeft className="h-4 w-4 text-slate-500" /></button>
|
||
<p className="text-sm font-bold text-slate-900">{format(miniMonth, "MMMM yyyy", { locale: de })}</p>
|
||
<button onClick={() => setMiniMonth((m) => new Date(m.getFullYear(), m.getMonth() + 1))} className="rounded-lg p-1 hover:bg-slate-100"><ChevronRight className="h-4 w-4 text-slate-500" /></button>
|
||
</div>
|
||
<div className="grid grid-cols-7 text-center text-[10px] font-bold uppercase tracking-wider text-slate-400 mb-1">{[ "Mo","Di","Mi","Do","Fr","Sa","So" ].map((d) => <div key={d}>{d}</div>)}</div>
|
||
<div className="grid grid-cols-7 gap-0.5">
|
||
{days.map((day) => {
|
||
const key = format(day, "yyyy-MM-dd");
|
||
const count = dayCounts.get(key) ?? 0;
|
||
const isPast = isBefore(day, startOfDay(today));
|
||
return (
|
||
<div key={key} className={cn("aspect-square flex flex-col items-center justify-center rounded-lg text-xs", isToday(day) && "bg-indigo-50 ring-1 ring-indigo-200", isPast && !isToday(day) && "opacity-30")}>
|
||
<span className={cn("font-medium", isToday(day) ? "text-indigo-700" : "text-slate-600")}>{format(day, "d")}</span>
|
||
{count > 0 && <span className={cn("text-[9px] font-bold", isToday(day) ? "text-indigo-500" : "text-slate-400")}>{count}</span>}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
<div>
|
||
{loading ? (
|
||
<div className="space-y-3"><Skeleton className="h-28" /><Skeleton className="h-28" /></div>
|
||
) : groupedAppointments.length === 0 ? (
|
||
<div className="flex flex-col items-center justify-center py-16 text-slate-400">
|
||
<Calendar className="mb-3 h-12 w-12 opacity-20" />
|
||
<p className="text-sm font-bold">Keine Termine gefunden</p>
|
||
<p className="text-xs mt-1">Neue Buchungen erscheinen hier automatisch.</p>
|
||
</div>
|
||
) : (
|
||
<div className="space-y-2">
|
||
{groupedAppointments.map((a) => {
|
||
const isSelected = selectedIds.has(a.key);
|
||
const isSelectable = a.status === "CONFIRMED";
|
||
return (
|
||
<div key={a.key}
|
||
className={cn(
|
||
"rounded-xl border bg-white p-4 transition",
|
||
isSelected ? "border-indigo-400 ring-1 ring-indigo-100" : "border-slate-200 hover:border-slate-300"
|
||
)}
|
||
>
|
||
<div className="flex items-start gap-3">
|
||
{isSelectable && (
|
||
<label className="mt-0.5 shrink-0 cursor-pointer" onClick={(e) => e.stopPropagation()}>
|
||
<input type="checkbox" checked={isSelected} onChange={() => toggleSelect(a.key)} className="h-4 w-4 rounded border-slate-300" />
|
||
</label>
|
||
)}
|
||
<div className="flex-1 min-w-0">
|
||
<div className="flex items-start justify-between gap-3 mb-2">
|
||
<div>
|
||
<p className="text-sm font-bold text-slate-900">{a.customerFirstName} {a.customerLastName}</p>
|
||
<p className="text-xs text-slate-500">{a.customerEmail}</p>
|
||
</div>
|
||
<span className={cn("shrink-0 rounded-full px-2.5 py-0.5 text-[10px] font-black uppercase tracking-wider",
|
||
a.status === "CANCELLED" ? "bg-slate-100 text-slate-500" : a.noShowAt ? "bg-amber-100 text-amber-800" : "bg-emerald-100 text-emerald-800")}>
|
||
{a.status === "CANCELLED" ? "Storniert" : a.noShowAt ? "No-Show" : "Bestätigt"}
|
||
</span>
|
||
</div>
|
||
<div className="grid gap-1 text-xs text-slate-500 mb-3">
|
||
<p><span className="font-bold text-slate-700">{format(new Date(a.startAt), "dd.MM.yyyy HH:mm", { locale: de })} – {format(new Date(a.endAt), "HH:mm", { locale: de })}</span></p>
|
||
<p>Personen: <span className="font-medium text-slate-700">{a.staffNames.join(", ")}</span></p>
|
||
{a.customerPhone && <p>Tel: {a.customerPhone}</p>}
|
||
{a.notes && <p className="italic">Notiz: {a.notes}</p>}
|
||
</div>
|
||
{a.status === "CONFIRMED" && !isSelected && (
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button size="sm" variant="destructive" onClick={() => setCancelConfirm(a.id)}><XCircle className="mr-1 h-3.5 w-3.5" /> Stornieren</Button>
|
||
{a.noShowAt ? (
|
||
<Button size="sm" variant="secondary" onClick={() => void toggleNoShow(a.id, false)}>No-Show zurücknehmen</Button>
|
||
) : new Date(a.startAt) < new Date() ? (
|
||
<Button size="sm" variant="secondary" onClick={() => void toggleNoShow(a.id, true)}>Als No-Show markieren</Button>
|
||
) : null}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Bulk action bar */}
|
||
{selectedIds.size > 0 && (
|
||
<div className="fixed bottom-6 left-1/2 -translate-x-1/2 z-40 flex items-center gap-3 rounded-2xl border border-slate-300 bg-white px-5 py-3 shadow-xl">
|
||
<span className="text-sm font-bold text-slate-900">{selectedIds.size} ausgewählt</span>
|
||
<Button size="sm" variant="destructive" disabled={busy} onClick={() => setBulkConfirm("cancel")}>
|
||
<XCircle className="mr-1 h-3.5 w-3.5" /> Stornieren
|
||
</Button>
|
||
<Button size="sm" variant="secondary" disabled={busy} onClick={() => setBulkConfirm("noshow")}>
|
||
Als No-Show markieren
|
||
</Button>
|
||
<button onClick={deselectAll} className="rounded-lg p-1.5 text-slate-400 hover:text-slate-600"><XCircle className="h-4 w-4" /></button>
|
||
</div>
|
||
)}
|
||
|
||
<ConfirmDialog open={cancelConfirm !== null} title="Termin stornieren" message="Der Termin wird storniert. Eine Stornierungs-Mail wird an den Kunden gesendet." confirmLabel="Stornieren" variant="danger"
|
||
onConfirm={() => { if (cancelConfirm) void cancelAppointment(cancelConfirm); }} onCancel={() => setCancelConfirm(null)} />
|
||
|
||
<ConfirmDialog open={bulkConfirm !== null}
|
||
title={bulkConfirm === "cancel" ? `${selectedIds.size} Termine stornieren` : `${selectedIds.size} Termine als No-Show markieren`}
|
||
message={bulkConfirm === "cancel" ? "Alle ausgewählten bestätigten Termine werden storniert. Stornierungs-Mails werden versendet." : "Alle ausgewählten Termine werden als No-Show markiert."}
|
||
confirmLabel={bulkConfirm === "cancel" ? "Alle stornieren" : "Alle markieren"} variant={bulkConfirm === "cancel" ? "danger" : "default"} loading={busy}
|
||
onConfirm={() => { if (bulkConfirm) void bulkAction(bulkConfirm); }} onCancel={() => setBulkConfirm(null)} />
|
||
</div>
|
||
);
|
||
}
|