Aktueller Stand
This commit is contained in:
@@ -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");
|
||||
}
|
||||
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">
|
||||
<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">
|
||||
<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,14 +1720,15 @@ export default function CalendarBoard() {
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-3 pr-3">
|
||||
<div className="flex flex-nowrap gap-1">
|
||||
{canManageView && (
|
||||
<ViewToggleButton
|
||||
isSelected={selectedEventIds.has(event.id)}
|
||||
onClick={() => toggleEvent(event.id)}
|
||||
/>
|
||||
)}
|
||||
<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 && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
Reference in New Issue
Block a user