962 lines
34 KiB
TypeScript
962 lines
34 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="text-sm text-slate-600"
|
|
onClick={closeCategoryModal}
|
|
>
|
|
Schließen
|
|
</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="text-sm text-slate-600"
|
|
onClick={() => {
|
|
setEditEvent(null);
|
|
setEditError(null);
|
|
setEditStatus(null);
|
|
setIsEditOpen(false);
|
|
}}
|
|
>
|
|
Schließen
|
|
</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>
|
|
);
|
|
}
|