Aktueller Stand

This commit is contained in:
2026-01-17 20:51:25 +01:00
parent 07f41f3ed6
commit f3d350e64e

View File

@@ -39,6 +39,15 @@ type DateBucket = "past" | "today" | "tomorrow" | "future";
type Pane = "calendar" | "list" | "map";
const MAP_FILTER_STORAGE_KEY = "mapFilters";
const CALENDAR_VIEW_STORAGE_KEY = "calendarViewType";
const LIST_FILTER_STORAGE_KEY = "listQuickFilters";
const DEFAULT_CALENDAR_VIEW = "dayGridMonth";
const ALLOWED_CALENDAR_VIEWS = [
"dayGridMonth",
"timeGridWeek",
"dayGridYear",
"listWeek"
];
export default function CalendarBoard() {
const { data, status } = useSession();
@@ -74,27 +83,30 @@ export default function CalendarBoard() {
const [prefillStartAt, setPrefillStartAt] = useState<string | null>(null);
const [viewOrder, setViewOrder] = useState<Array<Pane>>([
"list",
"map",
"calendar"
"calendar",
"map"
]);
const [collapsed, setCollapsed] = useState<Record<Pane, boolean>>({
calendar: false,
list: false,
map: false
});
const [activeRange, setActiveRange] = useState<{
start: Date;
end: Date;
viewType: string;
} | null>(() => {
const now = new Date();
return {
start: new Date(now.getFullYear(), 0, 1),
end: new Date(now.getFullYear() + 1, 0, 1),
viewType: "dayGridYear"
};
const [listQuickRange, setListQuickRange] = useState<
"next7" | "next30" | "nextYear" | null
>("next30");
const [hidePastInList, setHidePastInList] = useState(true);
const [initialView, setInitialView] = useState(() => {
if (typeof window === "undefined") return DEFAULT_CALENDAR_VIEW;
try {
const stored = window.localStorage.getItem(CALENDAR_VIEW_STORAGE_KEY);
if (stored && ALLOWED_CALENDAR_VIEWS.includes(stored)) {
return stored;
}
} catch {
// ignore
}
return DEFAULT_CALENDAR_VIEW;
});
const [initialView, setInitialView] = useState("dayGridYear");
const [dragSource, setDragSource] = useState<Pane | null>(null);
const [dragOver, setDragOver] = useState<Pane | null>(null);
const [isPointerDragging, setIsPointerDragging] = useState(false);
@@ -310,6 +322,39 @@ export default function CalendarBoard() {
}
}, []);
useEffect(() => {
try {
const stored = window.localStorage.getItem(LIST_FILTER_STORAGE_KEY);
if (!stored) return;
const parsed = JSON.parse(stored);
if (
parsed &&
typeof parsed === "object" &&
(parsed.quickRange === "next7" ||
parsed.quickRange === "next30" ||
parsed.quickRange === "nextYear" ||
parsed.quickRange === null) &&
typeof parsed.hidePast === "boolean"
) {
setListQuickRange(parsed.quickRange);
setHidePastInList(parsed.hidePast);
}
} catch {
// ignore
}
}, []);
useEffect(() => {
try {
window.localStorage.setItem(
LIST_FILTER_STORAGE_KEY,
JSON.stringify({ quickRange: listQuickRange, hidePast: hidePastInList })
);
} catch {
// ignore
}
}, [listQuickRange, hidePastInList]);
useEffect(() => {
try {
window.localStorage.setItem(
@@ -527,25 +572,14 @@ export default function CalendarBoard() {
setFormOpen(true);
};
const applyTruncatedTitles = (root: HTMLElement) => {
const applyEventTooltip = (root: HTMLElement, title: string) => {
const elements = Array.from(
root.querySelectorAll<HTMLElement>(".event-shell .truncate")
);
elements.forEach((element) => {
const text = element.textContent?.trim();
if (!text) {
element.removeAttribute("title");
return;
}
const isTruncated =
element.scrollWidth > element.clientWidth ||
element.scrollHeight > element.clientHeight;
if (isTruncated) {
element.setAttribute("title", text);
} else {
element.removeAttribute("title");
}
});
root.setAttribute("title", title);
};
const calendarEvents = useMemo(
@@ -756,6 +790,41 @@ export default function CalendarBoard() {
return sorted;
}, [events, query, categoryFilter, sortKey, sortDir]);
const startOfDay = (value: Date) =>
new Date(value.getFullYear(), value.getMonth(), value.getDate());
const addDays = (value: Date, amount: number) => {
const next = new Date(value);
next.setDate(next.getDate() + amount);
return next;
};
const listRange = useMemo(() => {
if (!listQuickRange && !hidePastInList) return null;
const today = startOfDay(new Date());
const start = hidePastInList
? today
: listQuickRange
? addDays(today, -1)
: today;
let end: Date | null = null;
if (listQuickRange === "next7") {
end = addDays(today, 7);
} else if (listQuickRange === "next30") {
end = addDays(today, 30);
} else if (listQuickRange === "nextYear") {
end = addDays(today, 365);
}
return { start, end };
}, [listQuickRange, hidePastInList]);
const filterButtonClass = (active: boolean) =>
`rounded-full border px-3 py-1 text-xs font-semibold transition ${
active
? "border-slate-900 bg-slate-900 text-white"
: "border-slate-200 text-slate-700 hover:bg-slate-100"
}`;
const categoryOptions = useMemo(() => {
const list = events
.map((event) => event.category?.name || "Ohne Kategorie")
@@ -861,18 +930,38 @@ export default function CalendarBoard() {
};
const displayedEvents = useMemo(() => {
if (!activeRange) {
if (!listRange) {
return filteredEvents;
}
return filteredEvents.filter((event) => {
const start = new Date(event.startAt);
return start >= activeRange.start && start < activeRange.end;
if (Number.isNaN(start.getTime())) return false;
if (start < listRange.start) return false;
if (listRange.end && start >= listRange.end) return false;
return true;
});
}, [filteredEvents, activeRange]);
}, [filteredEvents, listRange]);
const tableWidths = isAdmin
? {
checkbox: "w-[5%]",
date: "w-[18%]",
title: "w-[26%]",
category: "w-[16%]",
location: "w-[23%]",
actions: "w-[12%]"
}
: {
date: "w-[20%]",
title: "w-[30%]",
category: "w-[18%]",
location: "w-[24%]",
actions: "w-[8%]"
};
useEffect(() => {
setBulkSelection((prev) => (prev.size > 0 ? new Set() : prev));
}, [query, categoryFilter, activeRange]);
}, [query, categoryFilter, listRange]);
const toggleSort = (nextKey: string) => {
let nextDir: "asc" | "desc" = "asc";
@@ -1012,7 +1101,7 @@ export default function CalendarBoard() {
headerToolbar={{
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,dayGridYear,listWeek"
right: "timeGridWeek,dayGridMonth,dayGridYear,listWeek"
}}
views={{
dayGridMonth: { buttonText: "Monat" },
@@ -1084,15 +1173,34 @@ export default function CalendarBoard() {
}}
eventDidMount={(info) => {
if (!info.el) return;
requestAnimationFrame(() => applyTruncatedTitles(info.el));
const startLabel = info.event.allDay
? "Ganztägig"
: info.event.start
? info.event.start.toLocaleTimeString("de-DE", {
hour: "2-digit",
minute: "2-digit"
})
: "";
const tooltip = startLabel
? `${info.event.title} - ${startLabel}`
: info.event.title;
requestAnimationFrame(() =>
applyEventTooltip(info.el, tooltip)
);
}}
height="auto"
datesSet={(arg) => {
setActiveRange({
start: arg.start,
end: arg.end,
viewType: arg.view.type
});
if (ALLOWED_CALENDAR_VIEWS.includes(arg.view.type)) {
setInitialView(arg.view.type);
try {
window.localStorage.setItem(
CALENDAR_VIEW_STORAGE_KEY,
arg.view.type
);
} catch {
// ignore
}
}
}}
/>
)}
@@ -1363,12 +1471,56 @@ export default function CalendarBoard() {
{!collapsed.list && (
<>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<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 className="flex flex-wrap items-center gap-2">
<button
type="button"
className={filterButtonClass(listQuickRange === "next7")}
onClick={() =>
setListQuickRange((current) =>
current === "next7" ? null : "next7"
)
}
>
In 7 Tagen
</button>
<button
type="button"
className={filterButtonClass(listQuickRange === "next30")}
onClick={() =>
setListQuickRange((current) =>
current === "next30" ? null : "next30"
)
}
>
In 30 Tagen
</button>
<button
type="button"
className={filterButtonClass(listQuickRange === "nextYear")}
onClick={() =>
setListQuickRange((current) =>
current === "nextYear" ? null : "nextYear"
)
}
>
Nächstes Jahr
</button>
<button
type="button"
className={filterButtonClass(hidePastInList)}
onClick={() => setHidePastInList((current) => !current)}
>
Vergangene ausblenden
</button>
</div>
</div>
{isAdmin && (
<div className="flex flex-wrap items-center gap-2">
<button
@@ -1414,11 +1566,11 @@ export default function CalendarBoard() {
)}
</div>
<div className="overflow-x-auto">
<table className="list-table min-w-full text-left text-sm">
<table className="list-table w-full table-fixed text-left text-sm">
<thead className="text-xs uppercase tracking-[0.2em] text-slate-500">
<tr>
{isAdmin && (
<th className="pb-2 w-[44px] pl-2">
<th className={`pb-2 pl-2 ${tableWidths.checkbox}`}>
<input
type="checkbox"
aria-label="Alle sichtbaren Termine auswählen"
@@ -1434,7 +1586,7 @@ export default function CalendarBoard() {
/>
</th>
)}
<th className="pb-2">
<th className={`pb-2 whitespace-nowrap ${tableWidths.date}`}>
<SortButton
label="Datum"
active={sortKey === "startAt"}
@@ -1442,7 +1594,7 @@ export default function CalendarBoard() {
onClick={() => toggleSort("startAt")}
/>
</th>
<th className="pb-2">
<th className={`pb-2 ${tableWidths.title}`}>
<SortButton
label="Titel"
active={sortKey === "title"}
@@ -1450,8 +1602,8 @@ export default function CalendarBoard() {
onClick={() => toggleSort("title")}
/>
</th>
<th className="pb-2">
<div className="flex items-center gap-2">
<th className={`pb-2 ${tableWidths.category}`}>
<div className="flex flex-wrap items-center gap-2">
<SortButton
label="Kategorie"
active={sortKey === "category"}
@@ -1459,7 +1611,7 @@ export default function CalendarBoard() {
onClick={() => toggleSort("category")}
/>
<select
className="rounded-full border border-slate-200 bg-white px-2 py-0.5 text-xs text-slate-600"
className="max-w-full 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"
@@ -1473,7 +1625,7 @@ export default function CalendarBoard() {
</select>
</div>
</th>
<th className="pb-2">
<th className={`pb-2 ${tableWidths.location}`}>
<SortButton
label="Ort"
active={sortKey === "location"}
@@ -1481,7 +1633,9 @@ export default function CalendarBoard() {
onClick={() => toggleSort("location")}
/>
</th>
<th className="pb-2 w-[96px]">Aktionen</th>
<th className={`pb-2 whitespace-nowrap ${tableWidths.actions}`}>
Aktionen
</th>
</tr>
</thead>
<tbody>
@@ -1495,6 +1649,10 @@ export default function CalendarBoard() {
displayedEvents.map((event) => {
const categoryName = event.category?.name || "Ohne Kategorie";
const locationLabel = formatLocation(event.location);
const dateLabel = new Date(event.startAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short"
});
return (
<tr
key={event.id}
@@ -1510,7 +1668,7 @@ export default function CalendarBoard() {
}`}
>
{isAdmin && (
<td className="py-3 pr-3 pl-2">
<td className="py-2 pr-3 pl-2">
<input
type="checkbox"
aria-label={`${event.title} auswählen`}
@@ -1519,29 +1677,28 @@ export default function CalendarBoard() {
/>
</td>
)}
<td className="py-3 pr-3 pl-2 whitespace-nowrap">
{new Date(event.startAt).toLocaleString("de-DE", {
dateStyle: "medium",
timeStyle: "short"
})}
<td className="py-2 pr-3 pl-2">
<span className="block w-full truncate whitespace-nowrap" title={dateLabel}>
{dateLabel}
</span>
</td>
<td className="py-3 pr-3 font-medium">
<td className="py-2 pr-3 font-medium">
<span
className="block max-w-[200px] truncate sm:max-w-[280px] lg:max-w-[420px] xl:max-w-[520px] 2xl:max-w-[640px]"
className="block w-full truncate"
title={event.title}
>
{event.title}
</span>
</td>
<td className="py-3 pr-3">
<td className="py-2 pr-3">
<span
className="block max-w-[160px] truncate sm:max-w-[220px] lg:max-w-[280px] xl:max-w-[320px]"
className="block w-full truncate"
title={categoryName}
>
{categoryName}
</span>
</td>
<td className="py-3 pr-3">
<td className="py-2 pr-3">
<div className="flex items-center justify-between gap-2">
<span
className="min-w-0 flex-1 truncate"
@@ -1563,12 +1720,13 @@ export default function CalendarBoard() {
)}
</div>
</td>
<td className="py-3 pr-3">
<div className="flex flex-nowrap gap-1">
<td className="py-2 pr-3 whitespace-nowrap">
<div className="flex flex-nowrap justify-end gap-1">
{canManageView && (
<ViewToggleButton
isSelected={selectedEventIds.has(event.id)}
onClick={() => toggleEvent(event.id)}
size="sm"
/>
)}
{isAdmin && (