Files
Calbook/components/admin/latest-bookings-panel.tsx

254 lines
8.7 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import { format } from "date-fns";
import { de } from "date-fns/locale";
import { Archive, Calendar, Mail, Trash2, User } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { ConfirmDialog } from "@/components/ui/confirm-dialog";
type SortOption =
| "date_desc"
| "date_asc"
| "customer_asc"
| "customer_desc"
| "person_asc"
| "person_desc";
type BookingRow = {
key: string;
id: string;
customerFirstName: string;
customerLastName: string;
customerEmail: string;
startAt: string;
staffNames: string[];
staffCount: number;
};
const SORT_OPTIONS: Array<{ value: SortOption; label: string }> = [
{ value: "date_desc", label: "Datum: Neueste zuerst" },
{ value: "date_asc", label: "Datum: Älteste zuerst" },
{ value: "customer_asc", label: "Kunde: A-Z" },
{ value: "customer_desc", label: "Kunde: Z-A" },
{ value: "person_asc", label: "Person: A-Z" },
{ value: "person_desc", label: "Person: Z-A" }
];
export function LatestBookingsPanel(props: {
monthTotal: number;
monthCancelled: number;
monthNoShow: number;
}) {
const [sort, setSort] = useState<SortOption>("date_desc");
const [rows, setRows] = useState<BookingRow[]>([]);
const [loading, setLoading] = useState(true);
const [busyId, setBusyId] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState<string | null>(null);
async function loadRows(nextSort: SortOption) {
setLoading(true);
try {
const params = new URLSearchParams({ sort: nextSort });
const res = await fetch(`/api/admin/letzte-buchungen?${params.toString()}`, {
cache: "no-store"
});
const data = await res.json();
if (!res.ok) {
toast.error(data?.message ?? "Buchungen konnten nicht geladen werden.");
return;
}
setRows(data.bookings ?? []);
} catch {
toast.error("Buchungen konnten nicht geladen werden.");
} finally {
setLoading(false);
}
}
useEffect(() => {
void loadRows(sort);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [sort]);
async function execDelete(id: string) {
setBusyId(id);
try {
const res = await fetch("/api/admin/letzte-buchungen", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, action: "delete" })
});
const data = await res.json();
if (!res.ok) {
toast.error(data?.message ?? "Aktion fehlgeschlagen.");
return;
}
toast.success("Buchung gelöscht.");
await loadRows(sort);
} catch {
toast.error("Aktion fehlgeschlagen.");
} finally {
setBusyId(null);
setConfirmDelete(null);
}
}
async function runAction(id: string, action: "archive" | "delete") {
if (action === "delete") {
setConfirmDelete(id);
return;
}
setBusyId(id);
try {
const res = await fetch("/api/admin/letzte-buchungen", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id, action })
});
const data = await res.json();
if (!res.ok) {
toast.error(data?.message ?? "Aktion fehlgeschlagen.");
return;
}
toast.success(action === "archive" ? "Buchung archiviert." : "Buchung gelöscht.");
await loadRows(sort);
} catch {
toast.error("Aktion fehlgeschlagen.");
} finally {
setBusyId(null);
}
}
return (
<div className="bg-white border border-slate-200 rounded-[24px] overflow-hidden flex-1">
<div className="p-6 border-b border-slate-200 flex flex-col gap-3">
<div className="flex flex-col gap-2 sm:flex-row sm:justify-between sm:items-center">
<h2 className="text-lg font-bold text-slate-900">Letzte Buchungen</h2>
<div className="flex flex-wrap gap-2 text-xs font-bold text-slate-500">
<span>Monat gesamt: {props.monthTotal}</span>
<span></span>
<span>Stornos: {props.monthCancelled}</span>
<span></span>
<span>No-Show: {props.monthNoShow}</span>
</div>
</div>
<div className="max-w-xs">
<select
value={sort}
onChange={(event) => setSort(event.target.value as SortOption)}
className="h-10 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm text-slate-700"
>
{SORT_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-slate-100 bg-slate-50/50">
<th className="px-6 py-4 font-bold text-slate-500 max-w-[220px]">Kunde</th>
<th className="px-6 py-4 font-bold text-slate-500">Datum & Zeit</th>
<th className="px-6 py-4 font-bold text-slate-500">Person(en)</th>
<th className="px-6 py-4 font-bold text-slate-500 text-right">Aktionen</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{loading ? (
<tr>
<td colSpan={4} className="px-6 py-8 text-center text-slate-500">
Lade Buchungen...
</td>
</tr>
) : rows.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center">
<p className="text-lg font-bold text-slate-900 mb-1">Keine Buchungen vorhanden</p>
<p className="text-slate-500 font-medium">Deine ausstehenden Termine erscheinen hier.</p>
</td>
</tr>
) : (
rows.map((row) => (
<tr key={row.key} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4">
<div className="flex flex-col gap-1">
<span className="font-bold text-slate-900 flex items-center gap-2">
<User className="w-4 h-4 text-slate-400" />
{row.customerFirstName} {row.customerLastName}
</span>
<span className="text-xs font-medium text-slate-500 flex items-center gap-2">
<Mail className="w-4 h-4 text-slate-400" />
{row.customerEmail}
</span>
</div>
</td>
<td className="px-6 py-4">
<span className="font-bold text-slate-900 flex items-center gap-2">
<Calendar className="w-4 h-4 text-slate-400" />
{format(new Date(row.startAt), "dd.MM.yyyy HH:mm", { locale: de })}
</span>
</td>
<td className="px-6 py-4 text-slate-600 font-medium">
{row.staffNames.join(", ")} ({row.staffCount})
</td>
<td className="px-6 py-4">
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="secondary"
disabled={busyId === row.id}
onClick={() => {
void runAction(row.id, "archive");
}}
>
<Archive className="mr-1 h-3.5 w-3.5" />
Archivieren
</Button>
<Button
size="sm"
variant="destructive"
disabled={busyId === row.id}
onClick={() => {
void runAction(row.id, "delete");
}}
>
<Trash2 className="mr-1 h-3.5 w-3.5" />
Löschen
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<ConfirmDialog
open={confirmDelete !== null}
title="Buchung löschen"
message="Diese Buchung wird dauerhaft gelöscht und kann nicht wiederhergestellt werden."
confirmLabel="Löschen"
variant="danger"
loading={busyId !== null}
onConfirm={() => {
if (confirmDelete) void execDelete(confirmDelete);
}}
onCancel={() => setConfirmDelete(null)}
/>
</div>
);
}