1060 lines
36 KiB
TypeScript
1060 lines
36 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
import FullCalendar from "@fullcalendar/react";
|
|
import dayGridPlugin from "@fullcalendar/daygrid";
|
|
import timeGridPlugin from "@fullcalendar/timegrid";
|
|
import listPlugin from "@fullcalendar/list";
|
|
import interactionPlugin from "@fullcalendar/interaction";
|
|
import deLocale from "@fullcalendar/core/locales/de";
|
|
import { useSession } from "next-auth/react";
|
|
import EventForm from "./EventForm";
|
|
|
|
type EventItem = {
|
|
id: string;
|
|
title: string;
|
|
description?: string | null;
|
|
location?: string | null;
|
|
locationPlaceId?: string | null;
|
|
locationLat?: number | null;
|
|
locationLng?: number | null;
|
|
startAt: string;
|
|
endAt?: string | null;
|
|
status: string;
|
|
category?: { id: string; name: string } | null;
|
|
};
|
|
|
|
type ViewItem = {
|
|
id: string;
|
|
token: string;
|
|
items: { eventId: string }[];
|
|
categories: { categoryId: string; category?: { name: string } }[];
|
|
exclusions: { eventId: string }[];
|
|
};
|
|
|
|
export default function CalendarBoard() {
|
|
const { data } = useSession();
|
|
const [events, setEvents] = useState<EventItem[]>([]);
|
|
const [view, setView] = useState<ViewItem | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [query, setQuery] = useState("");
|
|
const [categoryFilter, setCategoryFilter] = useState("ALL");
|
|
const [sortKey, setSortKey] = useState("startAt");
|
|
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
|
|
const [detailsEvent, setDetailsEvent] = useState<EventItem | null>(null);
|
|
const [placesKey, setPlacesKey] = useState("");
|
|
const [placesProvider, setPlacesProvider] = useState<"google" | "osm">("osm");
|
|
const [formOpen, setFormOpen] = useState(false);
|
|
const [prefillStartAt, setPrefillStartAt] = useState<string | null>(null);
|
|
const [viewOrder, setViewOrder] = useState<Array<"calendar" | "list">>([
|
|
"calendar",
|
|
"list"
|
|
]);
|
|
const [collapsed, setCollapsed] = useState<{ calendar: boolean; list: boolean }>({
|
|
calendar: false,
|
|
list: false
|
|
});
|
|
const [activeRange, setActiveRange] = useState<{
|
|
start: Date;
|
|
end: Date;
|
|
viewType: string;
|
|
} | null>(null);
|
|
const [initialView, setInitialView] = useState("dayGridMonth");
|
|
const [dragSource, setDragSource] = useState<"calendar" | "list" | null>(null);
|
|
const [dragOver, setDragOver] = useState<"calendar" | "list" | null>(null);
|
|
const calendarRef = useRef<HTMLDivElement | null>(null);
|
|
const listRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const fetchEvents = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const [eventsResponse, viewResponse] = await Promise.all([
|
|
fetch("/api/events"),
|
|
fetch("/api/views/default")
|
|
]);
|
|
if (!eventsResponse.ok) {
|
|
throw new Error("Events konnten nicht geladen werden.");
|
|
}
|
|
const payload = await eventsResponse.json();
|
|
setEvents(payload);
|
|
if (viewResponse.ok) {
|
|
setView(await viewResponse.json());
|
|
}
|
|
} catch (err) {
|
|
setError((err as Error).message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (data?.user) {
|
|
fetchEvents();
|
|
}
|
|
}, [data?.user]);
|
|
|
|
useEffect(() => {
|
|
const loadKey = async () => {
|
|
try {
|
|
const response = await fetch("/api/settings/google-places");
|
|
if (!response.ok) return;
|
|
const payload = await response.json();
|
|
setPlacesKey(payload.apiKey || "");
|
|
setPlacesProvider(payload.provider === "google" ? "google" : "osm");
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
if (data?.user) {
|
|
loadKey();
|
|
}
|
|
}, [data?.user]);
|
|
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
fetchEvents();
|
|
};
|
|
window.addEventListener("views-updated", handler);
|
|
return () => window.removeEventListener("views-updated", handler);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const stored = window.localStorage.getItem("calendarLastView");
|
|
if (stored) {
|
|
setInitialView(stored);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const stored = window.localStorage.getItem("listSort");
|
|
if (!stored) return;
|
|
const parsed = JSON.parse(stored);
|
|
if (
|
|
parsed &&
|
|
typeof parsed === "object" &&
|
|
typeof parsed.key === "string" &&
|
|
(parsed.dir === "asc" || parsed.dir === "desc")
|
|
) {
|
|
setSortKey(parsed.key);
|
|
setSortDir(parsed.dir);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const stored = window.localStorage.getItem("calendarViewOrder");
|
|
if (!stored) return;
|
|
const parsed = JSON.parse(stored);
|
|
if (
|
|
Array.isArray(parsed) &&
|
|
parsed.length === 2 &&
|
|
parsed.includes("calendar") &&
|
|
parsed.includes("list")
|
|
) {
|
|
setViewOrder(parsed);
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
try {
|
|
const stored = window.localStorage.getItem("calendarViewCollapsed");
|
|
if (!stored) return;
|
|
const parsed = JSON.parse(stored);
|
|
if (
|
|
parsed &&
|
|
typeof parsed === "object" &&
|
|
typeof parsed.calendar === "boolean" &&
|
|
typeof parsed.list === "boolean"
|
|
) {
|
|
setCollapsed({ calendar: parsed.calendar, list: parsed.list });
|
|
}
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}, []);
|
|
|
|
const persistOrder = (nextOrder: Array<"calendar" | "list">) => {
|
|
setViewOrder(nextOrder);
|
|
try {
|
|
window.localStorage.setItem("calendarViewOrder", JSON.stringify(nextOrder));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const toggleCollapse = (section: "calendar" | "list") => {
|
|
const next = { ...collapsed, [section]: !collapsed[section] };
|
|
setCollapsed(next);
|
|
try {
|
|
window.localStorage.setItem("calendarViewCollapsed", JSON.stringify(next));
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
const swapOrder = (source: "calendar" | "list", target: "calendar" | "list") => {
|
|
if (source === target) return;
|
|
const next = viewOrder.map((item) => (item === source ? target : source)) as Array<
|
|
"calendar" | "list"
|
|
>;
|
|
persistOrder(next);
|
|
};
|
|
|
|
const onDragStart = (
|
|
event: React.DragEvent<HTMLDivElement>,
|
|
section: "calendar" | "list"
|
|
) => {
|
|
event.dataTransfer.setData("text/plain", section);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
setDragSource(section);
|
|
setDragOver(section);
|
|
const node = section === "calendar" ? calendarRef.current : listRef.current;
|
|
if (node) {
|
|
event.dataTransfer.setDragImage(node, 20, 20);
|
|
}
|
|
};
|
|
|
|
const onDragEnter = (target: "calendar" | "list") => {
|
|
setDragOver(target);
|
|
};
|
|
|
|
const onDragEnd = () => {
|
|
setDragSource(null);
|
|
setDragOver(null);
|
|
};
|
|
|
|
const onDrop = (event: React.DragEvent<HTMLDivElement>, target: "calendar" | "list") => {
|
|
event.preventDefault();
|
|
const source = event.dataTransfer.getData("text/plain") as
|
|
| "calendar"
|
|
| "list";
|
|
if (source) {
|
|
swapOrder(source, target);
|
|
}
|
|
setDragSource(null);
|
|
setDragOver(null);
|
|
};
|
|
|
|
const openFormForDate = (date: Date | null) => {
|
|
if (date) {
|
|
const prefill = new Date(date);
|
|
prefill.setHours(12, 0, 0, 0);
|
|
setPrefillStartAt(prefill.toISOString());
|
|
} else {
|
|
setPrefillStartAt(null);
|
|
}
|
|
setFormOpen(true);
|
|
};
|
|
|
|
const calendarEvents = useMemo(
|
|
() =>
|
|
events.map((event) => ({
|
|
id: event.id,
|
|
title: event.title,
|
|
start: event.startAt,
|
|
end: event.endAt || undefined,
|
|
extendedProps: {
|
|
status: event.status,
|
|
location: event.location,
|
|
description: event.description,
|
|
category: event.category?.name
|
|
}
|
|
})),
|
|
[events]
|
|
);
|
|
|
|
const selectedEventIds = useMemo(() => {
|
|
if (!view) return new Set<string>();
|
|
const ids = new Set(view.items.map((item) => item.eventId));
|
|
const excluded = new Set(view.exclusions.map((item) => item.eventId));
|
|
const subscribedCategoryIds = new Set(
|
|
view.categories.map((item) => item.categoryId)
|
|
);
|
|
events.forEach((event) => {
|
|
if (event.category?.id && subscribedCategoryIds.has(event.category.id)) {
|
|
ids.add(event.id);
|
|
}
|
|
});
|
|
excluded.forEach((eventId) => ids.delete(eventId));
|
|
return ids;
|
|
}, [view, events]);
|
|
|
|
const subscribedCategoryIds = useMemo(() => {
|
|
return new Set(view?.categories.map((item) => item.categoryId) || []);
|
|
}, [view]);
|
|
|
|
const toggleEvent = async (eventId: string) => {
|
|
if (!view) return;
|
|
const isSelected = selectedEventIds.has(eventId);
|
|
await fetch(`/api/views/${view.id}/items`, {
|
|
method: isSelected ? "DELETE" : "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify({ eventId })
|
|
});
|
|
await fetchEvents();
|
|
window.dispatchEvent(new Event("views-updated"));
|
|
};
|
|
|
|
|
|
const filteredEvents = useMemo(() => {
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
const filtered = events.filter((event) => {
|
|
const categoryName = event.category?.name || "Ohne Kategorie";
|
|
if (categoryFilter !== "ALL" && categoryName !== categoryFilter) {
|
|
return false;
|
|
}
|
|
if (!normalizedQuery) return true;
|
|
return (
|
|
event.title.toLowerCase().includes(normalizedQuery) ||
|
|
(event.description || "").toLowerCase().includes(normalizedQuery) ||
|
|
(event.location || "").toLowerCase().includes(normalizedQuery) ||
|
|
categoryName.toLowerCase().includes(normalizedQuery)
|
|
);
|
|
});
|
|
|
|
const sorted = [...filtered].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 === "location") {
|
|
const aLoc = a.location || "";
|
|
const bLoc = b.location || "";
|
|
return aLoc.localeCompare(bLoc) * 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;
|
|
});
|
|
|
|
return sorted;
|
|
}, [events, query, categoryFilter, sortKey, sortDir]);
|
|
|
|
const categoryOptions = useMemo(() => {
|
|
const list = events
|
|
.map((event) => event.category?.name || "Ohne Kategorie")
|
|
.filter(Boolean);
|
|
return Array.from(new Set(list)).sort();
|
|
}, [events]);
|
|
|
|
const displayedEvents = useMemo(() => {
|
|
if (!activeRange) {
|
|
return filteredEvents;
|
|
}
|
|
return filteredEvents.filter((event) => {
|
|
const start = new Date(event.startAt);
|
|
return start >= activeRange.start && start < activeRange.end;
|
|
});
|
|
}, [filteredEvents, activeRange]);
|
|
|
|
const toggleSort = (nextKey: string) => {
|
|
let nextDir: "asc" | "desc" = "asc";
|
|
if (sortKey === nextKey) {
|
|
nextDir = sortDir === "asc" ? "desc" : "asc";
|
|
setSortDir(nextDir);
|
|
} else {
|
|
setSortKey(nextKey);
|
|
setSortDir("asc");
|
|
}
|
|
try {
|
|
const key = sortKey === nextKey ? sortKey : nextKey;
|
|
window.localStorage.setItem(
|
|
"listSort",
|
|
JSON.stringify({ key, dir: nextDir })
|
|
);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
};
|
|
|
|
if (!data?.user) {
|
|
return (
|
|
<div className="card-muted text-center">
|
|
<p className="text-slate-700">
|
|
Bitte anmelden, um die Vereinskalender zu sehen.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section className="space-y-4 fade-up">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">Kalender</p>
|
|
<h2 className="text-2xl font-semibold">Termine im Blick</h2>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="btn-accent"
|
|
onClick={() => openFormForDate(null)}
|
|
>
|
|
Neuer Termin
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={fetchEvents}
|
|
className="btn-ghost"
|
|
>
|
|
Aktualisieren
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<EventForm
|
|
variant="inline"
|
|
showTrigger={false}
|
|
open={formOpen}
|
|
onOpenChange={(open) => {
|
|
setFormOpen(open);
|
|
if (!open) {
|
|
setPrefillStartAt(null);
|
|
}
|
|
}}
|
|
prefillStartAt={prefillStartAt}
|
|
/>
|
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
|
{loading && <p className="text-sm text-slate-500">Lade Termine...</p>}
|
|
{viewOrder.map((section) => {
|
|
if (section === "calendar") {
|
|
return (
|
|
<div
|
|
key="calendar"
|
|
ref={calendarRef}
|
|
className={`card drag-card ${
|
|
dragSource === "calendar" ? "dragging" : ""
|
|
} ${
|
|
dragOver === "calendar" && dragSource && dragSource !== "calendar"
|
|
? "drag-target"
|
|
: ""
|
|
} ${
|
|
dragSource === "list" && dragOver === "calendar" ? "shift-down" : ""
|
|
}`}
|
|
onDragOver={(event) => event.preventDefault()}
|
|
onDragEnter={() => onDragEnter("calendar")}
|
|
onDrop={(event) => onDrop(event, "calendar")}
|
|
>
|
|
<div className="mb-3 flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-slate-700">Kalender</h3>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="rounded-full border border-slate-200 px-2 py-1 text-xs text-slate-600"
|
|
onClick={() => toggleCollapse("calendar")}
|
|
aria-label={collapsed.calendar ? "Kalender aufklappen" : "Kalender zuklappen"}
|
|
title={collapsed.calendar ? "Aufklappen" : "Zuklappen"}
|
|
>
|
|
{collapsed.calendar ? <IconChevronDown /> : <IconChevronUp />}
|
|
</button>
|
|
<div
|
|
className="drag-handle"
|
|
draggable
|
|
onDragStart={(event) => onDragStart(event, "calendar")}
|
|
onDragEnd={onDragEnd}
|
|
title="Zum Tauschen ziehen"
|
|
aria-label="Kalender verschieben"
|
|
>
|
|
<IconGrip />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{!collapsed.calendar && (
|
|
<FullCalendar
|
|
plugins={[dayGridPlugin, timeGridPlugin, listPlugin, interactionPlugin]}
|
|
locale={deLocale}
|
|
initialView={initialView}
|
|
firstDay={1}
|
|
headerToolbar={{
|
|
left: "prev,next today",
|
|
center: "title",
|
|
right: "dayGridMonth,timeGridWeek,dayGridYear,listWeek"
|
|
}}
|
|
views={{
|
|
dayGridMonth: { buttonText: "Monat" },
|
|
timeGridWeek: { buttonText: "Woche" },
|
|
dayGridYear: { buttonText: "Jahr" },
|
|
listWeek: { buttonText: "Übersicht" }
|
|
}}
|
|
buttonText={{ today: "Heute" }}
|
|
events={calendarEvents}
|
|
dayCellContent={(arg) => {
|
|
if (arg.view.type !== "dayGridMonth") {
|
|
return arg.dayNumberText;
|
|
}
|
|
return (
|
|
<div className="flex items-center justify-between">
|
|
<span>{arg.dayNumberText}</span>
|
|
<button
|
|
type="button"
|
|
className="rounded-full border border-slate-200 bg-white/80 px-2 py-0.5 text-xs text-slate-600 hover:bg-slate-100"
|
|
aria-label={`Termin am ${arg.date.toLocaleDateString("de-DE")} anlegen`}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
openFormForDate(arg.date);
|
|
}}
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
);
|
|
}}
|
|
eventClick={(info) => {
|
|
const match = events.find((event) => event.id === info.event.id);
|
|
if (match) {
|
|
setDetailsEvent(match);
|
|
}
|
|
}}
|
|
eventContent={(arg) => {
|
|
const status = arg.event.extendedProps.status as string;
|
|
const isSelected = selectedEventIds.has(arg.event.id);
|
|
return (
|
|
<div className="event-shell">
|
|
<div>
|
|
<div className="text-sm font-medium">{arg.event.title}</div>
|
|
<div className="text-xs text-slate-600">
|
|
{arg.event.extendedProps.category || "Ohne Kategorie"}
|
|
</div>
|
|
<div className="mt-1 flex items-center gap-1">
|
|
<StatusIcon status={status} />
|
|
</div>
|
|
</div>
|
|
<ViewToggleButton
|
|
isSelected={isSelected}
|
|
onClick={(event) => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
toggleEvent(arg.event.id);
|
|
}}
|
|
size="sm"
|
|
/>
|
|
</div>
|
|
);
|
|
}}
|
|
height="auto"
|
|
datesSet={(arg) => {
|
|
setActiveRange({
|
|
start: arg.start,
|
|
end: arg.end,
|
|
viewType: arg.view.type
|
|
});
|
|
try {
|
|
window.localStorage.setItem("calendarLastView", arg.view.type);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
key="list"
|
|
ref={listRef}
|
|
className={`card space-y-4 drag-card ${
|
|
dragSource === "list" ? "dragging" : ""
|
|
} ${
|
|
dragOver === "list" && dragSource && dragSource !== "list" ? "drag-target" : ""
|
|
} ${
|
|
dragSource === "calendar" && dragOver === "list" ? "shift-up" : ""
|
|
}`}
|
|
onDragOver={(event) => event.preventDefault()}
|
|
onDragEnter={() => onDragEnter("list")}
|
|
onDrop={(event) => onDrop(event, "list")}
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-semibold text-slate-700">Liste</h3>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
className="rounded-full border border-slate-200 px-2 py-1 text-xs text-slate-600"
|
|
onClick={() => toggleCollapse("list")}
|
|
aria-label={collapsed.list ? "Liste aufklappen" : "Liste zuklappen"}
|
|
title={collapsed.list ? "Aufklappen" : "Zuklappen"}
|
|
>
|
|
{collapsed.list ? <IconChevronDown /> : <IconChevronUp />}
|
|
</button>
|
|
<div
|
|
className="drag-handle"
|
|
draggable
|
|
onDragStart={(event) => onDragStart(event, "list")}
|
|
onDragEnd={onDragEnd}
|
|
title="Zum Tauschen ziehen"
|
|
aria-label="Liste verschieben"
|
|
>
|
|
<IconGrip />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{!collapsed.list && (
|
|
<>
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<input
|
|
value={query}
|
|
onChange={(event) => setQuery(event.target.value)}
|
|
placeholder="Suchen..."
|
|
className="min-w-[220px] rounded-xl border border-slate-300 px-3 py-2 text-sm"
|
|
/>
|
|
</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">
|
|
<div className="flex items-center gap-2">
|
|
<SortButton
|
|
label="Kategorie"
|
|
active={sortKey === "category"}
|
|
direction={sortDir}
|
|
onClick={() => toggleSort("category")}
|
|
/>
|
|
<select
|
|
className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-xs text-slate-600"
|
|
value={categoryFilter}
|
|
onChange={(event) => setCategoryFilter(event.target.value)}
|
|
aria-label="Kategorie filtern"
|
|
>
|
|
<option value="ALL">Alle</option>
|
|
{categoryOptions.map((category) => (
|
|
<option key={category} value={category}>
|
|
{category}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
</th>
|
|
<th className="pb-2">
|
|
<SortButton
|
|
label="Ort"
|
|
active={sortKey === "location"}
|
|
direction={sortDir}
|
|
onClick={() => toggleSort("location")}
|
|
/>
|
|
</th>
|
|
<th className="pb-2">
|
|
<SortButton
|
|
label="Status"
|
|
active={sortKey === "status"}
|
|
direction={sortDir}
|
|
onClick={() => toggleSort("status")}
|
|
/>
|
|
</th>
|
|
<th className="pb-2">Aktionen</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{displayedEvents.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="py-4 text-slate-600">
|
|
Keine Termine für die aktuelle Auswahl.
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
displayedEvents.map((event) => (
|
|
<tr key={event.id} className="border-t border-slate-200">
|
|
<td className="py-3 pr-3 whitespace-nowrap">
|
|
{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">
|
|
<span>{formatLocation(event.location)}</span>
|
|
{event.locationLat && event.locationLng && (
|
|
<a
|
|
className="inline-flex items-center justify-center rounded-full border border-slate-200 p-1 text-slate-600 hover:bg-slate-100"
|
|
href={`https://maps.google.com/?q=${event.locationLat},${event.locationLng}&z=14`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
title="Google Maps"
|
|
aria-label="Google Maps"
|
|
>
|
|
<IconMapPin />
|
|
</a>
|
|
)}
|
|
</div>
|
|
</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-nowrap gap-2">
|
|
<ViewToggleButton
|
|
isSelected={selectedEventIds.has(event.id)}
|
|
onClick={() => toggleEvent(event.id)}
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700"
|
|
onClick={() => setDetailsEvent(event)}
|
|
>
|
|
Details
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{detailsEvent && (
|
|
<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 Details</h3>
|
|
<button
|
|
type="button"
|
|
className="text-sm text-slate-600"
|
|
onClick={() => setDetailsEvent(null)}
|
|
>
|
|
Schließen
|
|
</button>
|
|
</div>
|
|
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
|
Titel
|
|
</p>
|
|
<p className="text-sm font-semibold">{detailsEvent.title}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
|
Zeitpunkt
|
|
</p>
|
|
<p className="text-sm">
|
|
{new Date(detailsEvent.startAt).toLocaleString("de-DE", {
|
|
dateStyle: "full",
|
|
timeStyle: "short"
|
|
})}
|
|
</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
|
Ort
|
|
</p>
|
|
<p className="text-sm">{detailsEvent.location || "-"}</p>
|
|
</div>
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
|
Kategorie
|
|
</p>
|
|
<p className="text-sm">
|
|
{detailsEvent.category?.name || "Ohne Kategorie"}
|
|
</p>
|
|
</div>
|
|
{detailsEvent.description && (
|
|
<div>
|
|
<p className="text-xs uppercase tracking-[0.2em] text-slate-500">
|
|
Beschreibung
|
|
</p>
|
|
<p className="text-sm text-slate-700">
|
|
{detailsEvent.description}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{detailsEvent.locationLat && detailsEvent.locationLng && (
|
|
<div className="flex flex-wrap gap-2">
|
|
<a
|
|
className="btn-ghost"
|
|
href={`https://maps.google.com/?q=${detailsEvent.locationLat},${detailsEvent.locationLng}&z=14`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Google Maps
|
|
</a>
|
|
<a
|
|
className="btn-ghost"
|
|
href={`https://maps.apple.com/?ll=${detailsEvent.locationLat},${detailsEvent.locationLng}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
Apple Karten
|
|
</a>
|
|
<a
|
|
className="btn-ghost"
|
|
href={`https://www.openstreetmap.org/?mlat=${detailsEvent.locationLat}&mlon=${detailsEvent.locationLng}#map=17/${detailsEvent.locationLat}/${detailsEvent.locationLng}`}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
OpenStreetMap
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="min-h-[240px] overflow-hidden rounded-xl border border-slate-200 bg-slate-100">
|
|
{placesProvider === "google" &&
|
|
placesKey &&
|
|
(detailsEvent.locationPlaceId ||
|
|
(detailsEvent.locationLat && detailsEvent.locationLng)) ? (
|
|
<iframe
|
|
title="Karte"
|
|
className="h-[320px] w-full"
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer-when-downgrade"
|
|
src={
|
|
detailsEvent.locationPlaceId
|
|
? `https://www.google.com/maps/embed/v1/place?key=${placesKey}&q=place_id:${detailsEvent.locationPlaceId}&zoom=14`
|
|
: `https://www.google.com/maps/embed/v1/place?key=${placesKey}&q=${detailsEvent.locationLat},${detailsEvent.locationLng}&zoom=14`
|
|
}
|
|
/>
|
|
) : placesProvider === "osm" &&
|
|
detailsEvent.locationLat &&
|
|
detailsEvent.locationLng ? (
|
|
<iframe
|
|
title="Karte"
|
|
className="h-[320px] w-full"
|
|
loading="lazy"
|
|
src={`https://www.openstreetmap.org/export/embed.html?bbox=${
|
|
detailsEvent.locationLng - 0.0045
|
|
},${detailsEvent.locationLat - 0.0045},${
|
|
detailsEvent.locationLng + 0.0045
|
|
},${detailsEvent.locationLat + 0.0045}&layer=mapnik&marker=${detailsEvent.locationLat},${detailsEvent.locationLng}`}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center text-sm text-slate-500">
|
|
Keine Karte verfügbar.
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function formatLocation(value?: string | null) {
|
|
if (!value) return "-";
|
|
const parts = value.split(",").map((part) => part.trim()).filter(Boolean);
|
|
if (parts.length === 0) return value;
|
|
const postal = parts.find((part) => /\b\d{5}\b/.test(part));
|
|
const street = parts[0] || "";
|
|
const city = parts[1] || "";
|
|
if (postal && city) {
|
|
return street ? `${street}, ${postal} ${city}` : `${postal} ${city}`;
|
|
}
|
|
if (postal) {
|
|
return street ? `${street}, ${postal}` : postal;
|
|
}
|
|
const main = parts.slice(0, 2).join(", ");
|
|
return main || value;
|
|
}
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
function ViewToggleButton({
|
|
isSelected,
|
|
onClick,
|
|
size = "md"
|
|
}: {
|
|
isSelected: boolean;
|
|
onClick: (event: React.MouseEvent<HTMLButtonElement>) => void;
|
|
size?: "sm" | "md";
|
|
}) {
|
|
const base =
|
|
size === "sm"
|
|
? "event-toggle rounded-full bg-white/80 px-2 py-1 text-[10px] text-slate-700"
|
|
: "rounded-full border border-slate-200 px-3 py-1 text-xs text-slate-700";
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={base}
|
|
onClick={onClick}
|
|
aria-label={isSelected ? "In Ansicht" : "Ausblenden"}
|
|
title={isSelected ? "In Ansicht" : "Ausblenden"}
|
|
>
|
|
{isSelected ? <IconBell /> : <IconSleep />}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function IconBell() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M6 8a6 6 0 1112 0c0 7 2 7 2 7H4s2 0 2-7" strokeLinecap="round" strokeLinejoin="round" />
|
|
<path d="M10 19a2 2 0 004 0" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconSleep() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M3 5h6l-6 6h6" strokeLinecap="round" strokeLinejoin="round" />
|
|
<path d="M9 9h6l-6 6h6" strokeLinecap="round" strokeLinejoin="round" />
|
|
<path d="M15 13h6l-6 6h6" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconGrip() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<circle cx="8" cy="6" r="1" />
|
|
<circle cx="16" cy="6" r="1" />
|
|
<circle cx="8" cy="12" r="1" />
|
|
<circle cx="16" cy="12" r="1" />
|
|
<circle cx="8" cy="18" r="1" />
|
|
<circle cx="16" cy="18" r="1" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconChevronDown() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconChevronUp() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M6 15l6-6 6 6" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
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-1 text-xs uppercase tracking-[0.2em] ${
|
|
active ? "text-slate-900" : "text-slate-500"
|
|
}`}
|
|
title="Sortieren"
|
|
>
|
|
{label}
|
|
<SortIcon direction={direction} active={active} />
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SortIcon({
|
|
direction,
|
|
active
|
|
}: {
|
|
direction: "asc" | "desc";
|
|
active: boolean;
|
|
}) {
|
|
return (
|
|
<svg
|
|
viewBox="0 0 24 24"
|
|
className={`h-3 w-3 ${active ? "opacity-100" : "opacity-40"}`}
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
{direction === "asc" ? (
|
|
<path d="M6 15l6-6 6 6" strokeLinecap="round" strokeLinejoin="round" />
|
|
) : (
|
|
<path d="M6 9l6 6 6-6" strokeLinecap="round" strokeLinejoin="round" />
|
|
)}
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function IconMapPin() {
|
|
return (
|
|
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
|
|
<path d="M12 21s6-6.5 6-11a6 6 0 10-12 0c0 4.5 6 11 6 11z" strokeLinecap="round" strokeLinejoin="round" />
|
|
<circle cx="12" cy="10" r="2.5" />
|
|
</svg>
|
|
);
|
|
}
|