254 lines
8.7 KiB
TypeScript
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>
|
|
);
|
|
}
|