Files
vereinskalender/components/AdminPanel.tsx
2026-01-17 22:32:49 +01:00

982 lines
35 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import Pagination from "./Pagination";
type EventItem = {
id: string;
title: string;
startAt: string;
endAt: string;
status: string;
location?: string | null;
locationPlaceId?: string | null;
locationLat?: number | null;
locationLng?: number | null;
description?: string | null;
publicOverride?: boolean | null;
category?: { id: string; name: string; isPublic?: boolean } | null;
createdBy?: { name?: string | null; email?: string | null } | null;
};
type CategoryItem = {
id: string;
name: string;
isPublic: boolean;
};
export default function AdminPanel() {
const [events, setEvents] = useState<EventItem[]>([]);
const [allEvents, setAllEvents] = useState<EventItem[]>([]);
const [error, setError] = useState<string | null>(null);
const [categories, setCategories] = useState<CategoryItem[]>([]);
const [categoryError, setCategoryError] = useState<string | null>(null);
const [categoryStatus, setCategoryStatus] = useState<string | null>(null);
const [categoryModalOpen, setCategoryModalOpen] = useState(false);
const [categoryModalError, setCategoryModalError] = useState<string | null>(null);
const [categoryModalStatus, setCategoryModalStatus] = useState<string | null>(null);
const [editingCategory, setEditingCategory] = useState<CategoryItem | null>(null);
const [editEvent, setEditEvent] = useState<EventItem | null>(null);
const [editStatus, setEditStatus] = useState<string | null>(null);
const [editError, setEditError] = useState<string | null>(null);
const [isEditOpen, setIsEditOpen] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(20);
const [sortKey, setSortKey] = useState<"startAt" | "title" | "category" | "status">(
"startAt"
);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const [importFile, setImportFile] = useState<File | null>(null);
const [importCategoryId, setImportCategoryId] = useState("");
const [importStatus, setImportStatus] = useState<string | null>(null);
const [importError, setImportError] = useState<string | null>(null);
const [publicAccessEnabled, setPublicAccessEnabled] = useState<boolean | null>(null);
const load = async () => {
try {
const response = await fetch("/api/events?status=PENDING");
if (!response.ok) {
throw new Error("Vorschläge konnten nicht geladen werden.");
}
setEvents(await response.json());
} catch (err) {
setError((err as Error).message);
}
};
const loadAllEvents = async () => {
try {
const response = await fetch("/api/events");
if (!response.ok) {
throw new Error("Termine konnten nicht geladen werden.");
}
setAllEvents(await response.json());
} catch (err) {
setError((err as Error).message);
}
};
const loadCategories = async () => {
try {
const response = await fetch("/api/categories");
if (!response.ok) {
throw new Error("Kategorien konnten nicht geladen werden.");
}
setCategories(await response.json());
} catch (err) {
setCategoryError((err as Error).message);
}
};
const loadSystemSettings = async () => {
try {
const response = await fetch("/api/settings/system");
if (!response.ok) return;
const payload = await response.json();
setPublicAccessEnabled(payload.publicAccessEnabled !== false);
} catch {
// ignore
}
};
useEffect(() => {
load();
loadCategories();
loadAllEvents();
loadSystemSettings();
}, []);
useEffect(() => {
setPage(1);
}, [allEvents.length]);
useEffect(() => {
setPage(1);
}, [pageSize]);
const totalPages = Math.max(1, Math.ceil(allEvents.length / pageSize));
const showPublicControls = publicAccessEnabled === true;
const sortedEvents = [...allEvents].sort((a, b) => {
const dir = sortDir === "asc" ? 1 : -1;
if (sortKey === "title") {
return a.title.localeCompare(b.title) * dir;
}
if (sortKey === "category") {
const aCat = a.category?.name || "Ohne Kategorie";
const bCat = b.category?.name || "Ohne Kategorie";
return aCat.localeCompare(bCat) * dir;
}
if (sortKey === "status") {
return a.status.localeCompare(b.status) * dir;
}
const aDate = new Date(a.startAt).getTime();
const bDate = new Date(b.startAt).getTime();
return (aDate - bDate) * dir;
});
const toggleSort = (nextKey: "startAt" | "title" | "category" | "status") => {
if (sortKey === nextKey) {
setSortDir((prev) => (prev === "asc" ? "desc" : "asc"));
return;
}
setSortKey(nextKey);
setSortDir("asc");
};
const updateStatus = async (id: string, status: "APPROVED" | "REJECTED") => {
await fetch(`/api/events/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ status })
});
load();
loadAllEvents();
};
const deleteEvent = async (id: string) => {
const ok = window.confirm("Termin wirklich löschen?");
if (!ok) return;
await fetch(`/api/events/${id}`, { method: "DELETE" });
load();
loadAllEvents();
};
const formatLocalDateTime = (value?: string | null) => {
if (!value) return "";
const date = new Date(value);
const offset = date.getTimezoneOffset() * 60000;
return new Date(date.getTime() - offset).toISOString().slice(0, 16);
};
const toIsoString = (value: FormDataEntryValue | null) => {
if (!value) return null;
const raw = String(value);
if (!raw) return null;
const date = new Date(raw);
if (Number.isNaN(date.getTime())) return null;
return date.toISOString();
};
const parsePublicOverride = (value: FormDataEntryValue | null) => {
if (!value) return null;
const raw = String(value);
if (raw === "public") return true;
if (raw === "private") return false;
return null;
};
const updateEvent = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setEditStatus(null);
setEditError(null);
if (!editEvent) return;
const formData = new FormData(event.currentTarget);
const payload: Record<string, unknown> = {
title: formData.get("title"),
description: formData.get("description"),
location: formData.get("location"),
locationPlaceId: formData.get("locationPlaceId"),
locationLat: formData.get("locationLat"),
locationLng: formData.get("locationLng"),
startAt: toIsoString(formData.get("startAt")),
endAt: toIsoString(formData.get("endAt")),
categoryId: formData.get("categoryId")
};
if (showPublicControls) {
payload.publicOverride = parsePublicOverride(
formData.get("publicOverride")
);
}
const response = await fetch(`/api/events/${editEvent.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json();
setEditError(data.error || "Termin konnte nicht aktualisiert werden.");
return;
}
const updated = await response.json();
setEditStatus("Termin aktualisiert.");
setEditEvent(updated);
load();
loadAllEvents();
setIsEditOpen(false);
};
const createCategory = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setCategoryError(null);
setCategoryStatus(null);
const formData = new FormData(event.currentTarget);
const rawName = String(formData.get("name") || "").trim();
const isPublic = formData.get("isPublic") === "on";
if (!rawName) {
setCategoryError("Name erforderlich.");
return;
}
const payload: Record<string, unknown> = { name: rawName };
if (showPublicControls) {
payload.isPublic = isPublic;
}
const response = await fetch("/api/categories", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json();
setCategoryError(data.error || "Kategorie konnte nicht angelegt werden.");
return;
}
const created = await response.json();
setCategories((prev) => {
if (prev.some((item) => item.id === created.id)) return prev;
return [...prev, created].sort((a, b) => a.name.localeCompare(b.name));
});
event.currentTarget.reset();
setCategoryStatus("Kategorie angelegt.");
};
const openCategoryModal = (category: CategoryItem) => {
setEditingCategory(category);
setCategoryModalError(null);
setCategoryModalStatus(null);
setCategoryModalOpen(true);
};
const closeCategoryModal = () => {
setEditingCategory(null);
setCategoryModalOpen(false);
};
const updateCategory = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!editingCategory) return;
setCategoryModalError(null);
setCategoryModalStatus(null);
const formData = new FormData(event.currentTarget);
const name = String(formData.get("name") || "").trim();
const isPublic = formData.get("isPublic") === "on";
if (!name) {
setCategoryModalError("Name erforderlich.");
return;
}
const payload: Record<string, unknown> = {
id: editingCategory.id,
name
};
if (showPublicControls) {
payload.isPublic = isPublic;
}
const response = await fetch("/api/categories", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!response.ok) {
const data = await response.json();
setCategoryModalError(data.error || "Kategorie konnte nicht aktualisiert werden.");
return;
}
const updated = await response.json();
setCategories((prev) =>
prev
.map((item) => (item.id === updated.id ? updated : item))
.sort((a, b) => a.name.localeCompare(b.name))
);
setCategoryModalStatus("Kategorie aktualisiert.");
setCategoryModalOpen(false);
};
const deleteCategory = async (categoryId: string) => {
const ok = window.confirm("Kategorie wirklich löschen? Zugeordnete Termine bleiben erhalten.");
if (!ok) return;
const response = await fetch(`/api/categories?id=${categoryId}`, {
method: "DELETE"
});
if (!response.ok) {
const data = await response.json();
setCategoryError(data.error || "Kategorie konnte nicht gelöscht werden.");
return;
}
setCategories((prev) => prev.filter((item) => item.id !== categoryId));
setCategoryStatus("Kategorie gelöscht.");
};
const importIcal = async (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setImportStatus(null);
setImportError(null);
if (!importFile) {
setImportError("Bitte eine iCal-Datei auswählen.");
return;
}
if (!importCategoryId) {
setImportError("Bitte eine Kategorie auswählen.");
return;
}
const formData = new FormData();
formData.append("file", importFile);
formData.append("categoryId", importCategoryId);
const response = await fetch("/api/ical/import", {
method: "POST",
body: formData
});
if (!response.ok) {
const data = await response.json();
setImportError(data.error || "Import fehlgeschlagen.");
return;
}
const data = await response.json();
const details = [
`${data.created || 0} importiert`,
`${data.duplicates || 0} doppelt`,
`${data.skipped || 0} übersprungen`
];
if (data.recurringSkipped) {
details.push(`${data.recurringSkipped} wiederkehrend`);
}
setImportStatus(`Import abgeschlossen: ${details.join(", ")}.`);
setImportFile(null);
setImportCategoryId("");
load();
loadAllEvents();
};
return (
<section className="space-y-4 fade-up">
<section className="card space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Vorschläge
</p>
<h2 className="text-lg font-semibold">Terminvorschläge</h2>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{events.length === 0 ? (
<p className="text-slate-600">Keine offenen Vorschläge.</p>
) : (
<div className="space-y-3">
{events.map((event) => (
<div key={event.id} className="card-muted">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-lg font-medium">{event.title}</h3>
<p className="text-sm text-slate-600">
{new Date(event.startAt).toLocaleString()} - {new Date(event.endAt).toLocaleString()}
</p>
{event.createdBy && (
<p className="text-sm text-slate-600">
Vorschlag von {event.createdBy.name || event.createdBy.email || "Unbekannt"}
</p>
)}
{event.location && (
<p className="text-sm text-slate-600">Ort: {event.location}</p>
)}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => updateStatus(event.id, "APPROVED")}
className="rounded-full bg-emerald-600 px-3 py-1.5 text-sm font-semibold text-white"
>
Freigeben
</button>
<button
type="button"
onClick={() => updateStatus(event.id, "REJECTED")}
className="rounded-full bg-red-600 px-3 py-1.5 text-sm font-semibold text-white"
>
Ablehnen
</button>
</div>
</div>
{event.description && (
<p className="mt-2 text-sm text-slate-700">{event.description}</p>
)}
</div>
))}
</div>
)}
</section>
<section className="card space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Kategorien
</p>
<h2 className="text-lg font-semibold">Kategorien verwalten</h2>
</div>
<form onSubmit={createCategory} className="flex flex-wrap gap-2">
<input
name="name"
required
placeholder="z.B. Training"
className="flex-1 rounded-xl border border-slate-300 px-3 py-2"
/>
{showPublicControls && (
<label className="flex items-center gap-2 text-sm text-slate-700">
<input type="checkbox" name="isPublic" />
Öffentlich
</label>
)}
<button className="btn-accent" type="submit">
Anlegen
</button>
</form>
{categoryStatus && (
<p className="text-sm text-emerald-600">{categoryStatus}</p>
)}
{categoryError && (
<p className="text-sm text-red-600">{categoryError}</p>
)}
<div className="space-y-2">
{categories.length === 0 ? (
<span className="text-sm text-slate-600">
Noch keine Kategorien.
</span>
) : (
<div className="flex flex-wrap gap-2">
{categories.map((category) => (
<div
key={category.id}
className="category-pill flex items-center gap-2 rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-sm text-slate-700"
>
<span className="font-medium">{category.name}</span>
{showPublicControls && category.isPublic && (
<span className="rounded-full bg-emerald-100 px-2 py-0.5 text-xs text-emerald-700">
Öffentlich
</span>
)}
<button
type="button"
className="rounded-full border border-slate-200 p-1 text-slate-600"
onClick={() => openCategoryModal(category)}
aria-label="Kategorie bearbeiten"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
d="M4 20h4l10-10-4-4L4 16v4z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M13 7l4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
className="rounded-full border border-red-200 p-1 text-red-600"
onClick={() => deleteCategory(category.id)}
aria-label="Kategorie löschen"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 6h18" strokeLinecap="round" />
<path d="M8 6V4h8v2" strokeLinecap="round" />
<path d="M19 6l-1 14H6L5 6" strokeLinecap="round" />
<path d="M10 11v6M14 11v6" strokeLinecap="round" />
</svg>
</button>
</div>
))}
</div>
)}
</div>
</section>
{categoryModalOpen && editingCategory && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
<div className="card w-full max-w-md">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Kategorie bearbeiten</h3>
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600 hover:bg-slate-100"
onClick={closeCategoryModal}
aria-label="Schließen"
title="Schließen"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 6l12 12M18 6l-12 12" strokeLinecap="round" />
</svg>
</button>
</div>
<form onSubmit={updateCategory} className="mt-4 space-y-3">
<input
name="name"
defaultValue={editingCategory.name}
required
className="w-full rounded-xl border border-slate-300 px-3 py-2"
/>
{showPublicControls && (
<label className="flex items-center gap-2 text-sm text-slate-700">
<input
type="checkbox"
name="isPublic"
defaultChecked={editingCategory.isPublic}
/>
Öffentlich
</label>
)}
<button type="submit" className="btn-accent w-full">
Speichern
</button>
</form>
{categoryModalStatus && (
<p className="mt-3 text-sm text-emerald-600">{categoryModalStatus}</p>
)}
{categoryModalError && (
<p className="mt-3 text-sm text-red-600">{categoryModalError}</p>
)}
</div>
</div>
)}
<section className="card space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
iCal
</p>
<h2 className="text-lg font-semibold">iCal-Import</h2>
<p className="text-sm text-slate-600">
Lade eine iCal-Datei hoch, um Termine direkt zu importieren.
</p>
</div>
<form onSubmit={importIcal} className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<label className="btn-ghost cursor-pointer">
Datei auswählen
<input
type="file"
accept=".ics,text/calendar"
onChange={(event) =>
setImportFile(event.currentTarget.files?.[0] || null)
}
className="sr-only"
/>
</label>
<span className="text-sm text-slate-600">
{importFile ? importFile.name : "Keine Datei ausgewählt"}
</span>
<select
value={importCategoryId}
onChange={(event) => setImportCategoryId(event.target.value)}
className="rounded-xl border border-slate-300 px-3 py-2 text-sm"
required
>
<option value="" disabled>
Kategorie wählen
</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
<button type="submit" className="btn-accent">
Import starten
</button>
</div>
{importStatus && <p className="text-sm text-emerald-600">{importStatus}</p>}
{importError && <p className="text-sm text-red-600">{importError}</p>}
</form>
</section>
<section className="card space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Termine
</p>
<h2 className="text-lg font-semibold">Alle Termine verwalten</h2>
</div>
{allEvents.length > 0 ? (
<Pagination
page={page}
totalPages={totalPages}
pageSize={pageSize}
onPageChange={setPage}
onPageSizeChange={setPageSize}
/>
) : (
<p className="text-sm text-slate-600">Keine Termine vorhanden.</p>
)}
{isEditOpen && editEvent && (
<div className="fixed inset-0 z-30 flex items-center justify-center bg-black/40 px-4 py-6">
<div className="card w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">Termin bearbeiten</h3>
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600 hover:bg-slate-100"
onClick={() => {
setEditEvent(null);
setEditError(null);
setEditStatus(null);
setIsEditOpen(false);
}}
aria-label="Schließen"
title="Schließen"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M6 6l12 12M18 6l-12 12" strokeLinecap="round" />
</svg>
</button>
</div>
<form onSubmit={updateEvent} className="mt-4 space-y-3">
<div className="grid gap-3 md:grid-cols-2">
<input
name="title"
required
defaultValue={editEvent.title}
className="rounded-xl border border-slate-300 px-3 py-2"
placeholder="Titel"
/>
<input
name="location"
defaultValue={editEvent.location || ""}
className="rounded-xl border border-slate-300 px-3 py-2"
placeholder="Ort"
/>
<input
type="hidden"
name="locationPlaceId"
value={editEvent.locationPlaceId || ""}
/>
<input
type="hidden"
name="locationLat"
value={editEvent.locationLat ?? ""}
/>
<input
type="hidden"
name="locationLng"
value={editEvent.locationLng ?? ""}
/>
<input
name="startAt"
type="datetime-local"
required
defaultValue={formatLocalDateTime(editEvent.startAt)}
className="rounded-xl border border-slate-300 px-3 py-2"
/>
<input
name="endAt"
type="datetime-local"
defaultValue={formatLocalDateTime(editEvent.endAt)}
className="rounded-xl border border-slate-300 px-3 py-2"
/>
<select
name="categoryId"
required
defaultValue={editEvent.category?.id || ""}
className="rounded-xl border border-slate-300 px-3 py-2 md:col-span-2"
>
<option value="" disabled>
Kategorie waehlen
</option>
{categories.map((category) => (
<option key={category.id} value={category.id}>
{category.name}
</option>
))}
</select>
{showPublicControls && (
<div className="rounded-xl border border-slate-200 bg-slate-50/60 p-3 text-sm text-slate-700 md:col-span-2">
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
Öffentlich
</p>
<div className="mt-2 flex flex-wrap gap-3">
<label className="flex items-center gap-2">
<input
type="radio"
name="publicOverride"
value="inherit"
defaultChecked={
editEvent.publicOverride !== true &&
editEvent.publicOverride !== false
}
/>
Kategorie übernehmen
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="publicOverride"
value="public"
defaultChecked={editEvent.publicOverride === true}
/>
Öffentlich
</label>
<label className="flex items-center gap-2">
<input
type="radio"
name="publicOverride"
value="private"
defaultChecked={editEvent.publicOverride === false}
/>
Nicht öffentlich
</label>
</div>
</div>
)}
</div>
<textarea
name="description"
defaultValue={editEvent.description || ""}
placeholder="Beschreibung"
className="min-h-[96px] rounded-xl border border-slate-300 px-3 py-2"
/>
<button type="submit" className="btn-accent">
Speichern
</button>
{editStatus && <p className="text-sm text-emerald-600">{editStatus}</p>}
{editError && <p className="text-sm text-red-600">{editError}</p>}
</form>
</div>
</div>
)}
<div className="overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
<th className="pb-2">
<SortButton
label="Datum"
active={sortKey === "startAt"}
direction={sortDir}
onClick={() => toggleSort("startAt")}
/>
</th>
<th className="pb-2">
<SortButton
label="Titel"
active={sortKey === "title"}
direction={sortDir}
onClick={() => toggleSort("title")}
/>
</th>
<th className="pb-2">
<SortButton
label="Kategorie"
active={sortKey === "category"}
direction={sortDir}
onClick={() => toggleSort("category")}
/>
</th>
<th className="pb-2">
<SortButton
label="Status"
active={sortKey === "status"}
direction={sortDir}
onClick={() => toggleSort("status")}
/>
</th>
<th className="pb-2">Aktion</th>
</tr>
</thead>
<tbody>
{allEvents.length === 0 ? (
<tr>
<td colSpan={5} className="py-4 text-slate-600">
Keine Termine vorhanden.
</td>
</tr>
) : (
sortedEvents
.slice((page - 1) * pageSize, page * pageSize)
.map((event) => (
<tr key={event.id} className="border-t border-slate-200">
<td className="py-3 pr-3">
{new Date(event.startAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short"
})}
</td>
<td className="py-3 pr-3 font-medium">{event.title}</td>
<td className="py-3 pr-3">
{event.category?.name || "Ohne Kategorie"}
</td>
<td className="py-3 pr-3">
<div className="flex items-center gap-2">
<StatusIcon status={event.status} />
</div>
</td>
<td className="py-3 pr-3">
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-full border border-slate-200 p-2 text-slate-600"
onClick={() => {
setEditEvent(event);
setIsEditOpen(true);
}}
aria-label="Termin bearbeiten"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path
d="M4 20h4l10-10-4-4L4 16v4z"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M13 7l4 4" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
<button
type="button"
className="rounded-full border border-red-200 p-2 text-red-600"
onClick={() => deleteEvent(event.id)}
aria-label="Termin löschen"
>
<svg
viewBox="0 0 24 24"
className="h-4 w-4"
fill="none"
stroke="currentColor"
strokeWidth="2"
>
<path d="M3 6h18" strokeLinecap="round" />
<path d="M8 6V4h8v2" strokeLinecap="round" />
<path d="M19 6l-1 14H6L5 6" strokeLinecap="round" />
<path d="M10 11v6M14 11v6" strokeLinecap="round" />
</svg>
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</section>
</section>
);
}
function SortButton({
label,
active,
direction,
onClick
}: {
label: string;
active: boolean;
direction: "asc" | "desc";
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={`inline-flex items-center gap-2 text-xs uppercase tracking-[0.2em] ${
active ? "text-slate-700" : "text-slate-500"
}`}
>
<span>{label}</span>
{active && (
<span aria-hidden="true" className="text-[10px]">
{direction === "asc" ? "▲" : "▼"}
</span>
)}
</button>
);
}
function StatusIcon({ status }: { status: string }) {
if (status === "APPROVED") {
return (
<span title="Freigegeben" aria-label="Freigegeben" className="text-emerald-600">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M5 12l4 4L19 6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
);
}
if (status === "REJECTED") {
return (
<span title="Abgelehnt" aria-label="Abgelehnt" className="text-red-600">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M6 6l12 12M18 6l-12 12" strokeLinecap="round" />
</svg>
</span>
);
}
return (
<span title="Offen" aria-label="Offen" className="text-amber-600">
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 6v6l4 2" strokeLinecap="round" strokeLinejoin="round" />
<circle cx="12" cy="12" r="9" />
</svg>
</span>
);
}