Aktueller Stand

This commit is contained in:
2026-01-15 23:18:42 +01:00
parent 46eae2a2a9
commit dcf45bac3d
32 changed files with 2625 additions and 395 deletions

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import Pagination from "./Pagination";
type EventItem = {
id: string;
@@ -14,6 +15,7 @@ type EventItem = {
locationLng?: number | null;
description?: string | null;
category?: { id: string; name: string } | null;
createdBy?: { name?: string | null; email?: string | null } | null;
};
export default function AdminPanel() {
@@ -33,6 +35,12 @@ export default function AdminPanel() {
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);
@@ -80,6 +88,43 @@ export default function AdminPanel() {
loadAllEvents();
}, []);
useEffect(() => {
setPage(1);
}, [allEvents.length]);
useEffect(() => {
setPage(1);
}, [pageSize]);
const totalPages = Math.max(1, Math.ceil(allEvents.length / pageSize));
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",
@@ -298,53 +343,60 @@ export default function AdminPanel() {
return (
<section className="space-y-4 fade-up">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Admin</p>
<h1 className="text-2xl font-semibold">Offene Vorschläge</h1>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
{events.length === 0 ? (
<div className="card-muted">
<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>
) : (
<div className="space-y-3">
{events.map((event) => (
<div key={event.id} className="card">
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-lg font-medium">{event.title}</h2>
<p className="text-sm text-slate-600">
{new Date(event.startAt).toLocaleString()} - {new Date(event.endAt).toLocaleString()}
</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 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>
{event.description && (
<p className="mt-2 text-sm text-slate-700">{event.description}</p>
)}
</div>
))}
</div>
)}
))}
</div>
)}
</section>
<section className="card space-y-4">
<div>
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
@@ -375,30 +427,56 @@ export default function AdminPanel() {
Noch keine Kategorien.
</span>
) : (
categories.map((category) => (
<div
key={category.id}
className="flex items-center justify-between rounded-xl border border-slate-200 px-3 py-2 text-sm text-slate-700"
>
<span>{category.name}</span>
<div className="flex gap-2">
<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>
<button
type="button"
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
className="rounded-full border border-slate-200 p-1 text-slate-600"
onClick={() => openCategoryModal(category)}
aria-label="Kategorie bearbeiten"
>
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 px-3 py-1 text-xs text-red-600"
className="rounded-full border border-red-200 p-1 text-red-600"
onClick={() => deleteCategory(category.id)}
aria-label="Kategorie löschen"
>
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>
)}
</div>
</section>
@@ -447,14 +525,20 @@ export default function AdminPanel() {
</div>
<form onSubmit={importIcal} className="space-y-3">
<div className="flex flex-wrap items-center gap-3">
<input
type="file"
accept=".ics,text/calendar"
onChange={(event) =>
setImportFile(event.currentTarget.files?.[0] || null)
}
className="block text-sm text-slate-600"
/>
<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)}
@@ -485,6 +569,17 @@ export default function AdminPanel() {
</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">
@@ -581,10 +676,38 @@ export default function AdminPanel() {
<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">Datum</th>
<th className="pb-2">Titel</th>
<th className="pb-2">Kategorie</th>
<th className="pb-2">Status</th>
<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>
@@ -596,7 +719,9 @@ export default function AdminPanel() {
</td>
</tr>
) : (
allEvents.map((event) => (
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", {
@@ -617,20 +742,46 @@ export default function AdminPanel() {
<div className="flex flex-wrap gap-2">
<button
type="button"
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
className="rounded-full border border-slate-200 p-2 text-slate-600"
onClick={() => {
setEditEvent(event);
setIsEditOpen(true);
}}
aria-label="Termin bearbeiten"
>
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 px-3 py-1 text-xs text-red-600"
className="rounded-full border border-red-200 p-2 text-red-600"
onClick={() => deleteEvent(event.id)}
aria-label="Termin löschen"
>
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>
@@ -645,6 +796,34 @@ export default function AdminPanel() {
);
}
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 (