Files
Calbook/components/admin/appointments-panel.tsx

310 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}