2026-06-18 13:36:06 +02:00
|
|
|
"use client";
|
|
|
|
|
|
|
|
|
|
import { useMemo, useState } from "react";
|
2026-06-18 13:42:30 +02:00
|
|
|
import type { CalendarEvent, CalendarSource } from "@/types/dashboard";
|
2026-06-18 13:36:06 +02:00
|
|
|
|
|
|
|
|
type CalendarWidgetProps = {
|
|
|
|
|
widgetId: string;
|
|
|
|
|
editMode: boolean;
|
|
|
|
|
events: CalendarEvent[];
|
|
|
|
|
error: string | null;
|
|
|
|
|
sources: CalendarSource[];
|
|
|
|
|
selectedSourceIds: string[];
|
|
|
|
|
nextEventsCount: number;
|
|
|
|
|
onToggleSource: (widgetId: string, sourceId: string) => void;
|
|
|
|
|
onChangeEventsCount: (widgetId: string, count: number) => void;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type CalendarDay = {
|
|
|
|
|
key: string;
|
|
|
|
|
date: Date;
|
|
|
|
|
inCurrentMonth: boolean;
|
|
|
|
|
isToday: boolean;
|
|
|
|
|
events: CalendarEvent[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const weekdayLabels = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"];
|
|
|
|
|
|
|
|
|
|
function dateKey(date: Date): string {
|
|
|
|
|
const year = date.getFullYear();
|
|
|
|
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
|
|
|
|
const day = String(date.getDate()).padStart(2, "0");
|
|
|
|
|
|
|
|
|
|
return `${year}-${month}-${day}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatEventDate(value: string): string {
|
|
|
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
|
|
|
weekday: "short",
|
|
|
|
|
day: "2-digit",
|
|
|
|
|
month: "2-digit",
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit"
|
|
|
|
|
}).format(new Date(value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatEventTime(value: string): string {
|
|
|
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
|
|
|
hour: "2-digit",
|
|
|
|
|
minute: "2-digit"
|
|
|
|
|
}).format(new Date(value));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatMonthLabel(value: Date): string {
|
|
|
|
|
return new Intl.DateTimeFormat("de-DE", {
|
|
|
|
|
month: "long",
|
|
|
|
|
year: "numeric"
|
|
|
|
|
}).format(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildCalendarDays(monthDate: Date, eventsByDate: Map<string, CalendarEvent[]>): CalendarDay[] {
|
|
|
|
|
const year = monthDate.getFullYear();
|
|
|
|
|
const month = monthDate.getMonth();
|
|
|
|
|
const firstDayOfMonth = new Date(year, month, 1);
|
|
|
|
|
const mondayBasedStartOffset = (firstDayOfMonth.getDay() + 6) % 7;
|
|
|
|
|
|
|
|
|
|
const gridStart = new Date(firstDayOfMonth);
|
|
|
|
|
gridStart.setDate(firstDayOfMonth.getDate() - mondayBasedStartOffset);
|
|
|
|
|
gridStart.setHours(0, 0, 0, 0);
|
|
|
|
|
|
|
|
|
|
const today = dateKey(new Date());
|
|
|
|
|
const days: CalendarDay[] = [];
|
|
|
|
|
|
|
|
|
|
for (let index = 0; index < 42; index += 1) {
|
|
|
|
|
const date = new Date(gridStart);
|
|
|
|
|
date.setDate(gridStart.getDate() + index);
|
|
|
|
|
|
|
|
|
|
const key = dateKey(date);
|
|
|
|
|
|
|
|
|
|
days.push({
|
|
|
|
|
key,
|
|
|
|
|
date,
|
|
|
|
|
inCurrentMonth: date.getMonth() === month,
|
|
|
|
|
isToday: key === today,
|
|
|
|
|
events: eventsByDate.get(key) ?? []
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return days;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function groupEventsByDate(events: CalendarEvent[]): Map<string, CalendarEvent[]> {
|
|
|
|
|
const grouped = new Map<string, CalendarEvent[]>();
|
|
|
|
|
|
|
|
|
|
events.forEach((event) => {
|
|
|
|
|
const key = dateKey(new Date(event.start));
|
|
|
|
|
const existing = grouped.get(key) ?? [];
|
|
|
|
|
|
|
|
|
|
grouped.set(key, [...existing, event]);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return grouped;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function CalendarWidget({
|
|
|
|
|
widgetId,
|
|
|
|
|
editMode,
|
|
|
|
|
events,
|
|
|
|
|
error,
|
|
|
|
|
sources,
|
|
|
|
|
selectedSourceIds,
|
|
|
|
|
nextEventsCount,
|
|
|
|
|
onToggleSource,
|
|
|
|
|
onChangeEventsCount
|
|
|
|
|
}: CalendarWidgetProps) {
|
|
|
|
|
const [calendarMonth, setCalendarMonth] = useState(() => new Date());
|
|
|
|
|
|
|
|
|
|
const eventsByDate = useMemo(() => groupEventsByDate(events), [events]);
|
|
|
|
|
const calendarDays = useMemo(() => buildCalendarDays(calendarMonth, eventsByDate), [calendarMonth, eventsByDate]);
|
|
|
|
|
const clampedEventsCount = Math.max(0, Math.min(10, nextEventsCount));
|
|
|
|
|
const nextEvents = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
[...events]
|
|
|
|
|
.sort((a, b) => new Date(a.start).getTime() - new Date(b.start).getTime())
|
|
|
|
|
.slice(0, clampedEventsCount),
|
|
|
|
|
[events, clampedEventsCount]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{editMode ? (
|
|
|
|
|
<details className="calendarSourcePanel widgetNoDrag">
|
|
|
|
|
<summary>Kalenderauswahl</summary>
|
|
|
|
|
|
|
|
|
|
<div className="calendarSourceForm">
|
|
|
|
|
{sources.length === 0 ? (
|
|
|
|
|
<p className="muted">
|
|
|
|
|
Keine Kalenderquellen angelegt. Lege Kalender in den Benutzereinstellungen an.
|
|
|
|
|
</p>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{sources.map((source) => (
|
|
|
|
|
<label className="calendarSourceCheckbox" key={source.id}>
|
|
|
|
|
<input
|
|
|
|
|
type="checkbox"
|
|
|
|
|
checked={selectedSourceIds.includes(source.id)}
|
|
|
|
|
onChange={() => onToggleSource(widgetId, source.id)}
|
|
|
|
|
/>
|
|
|
|
|
<span className="calendarSourceColorDot" style={{ background: source.color }} />
|
|
|
|
|
<span>
|
|
|
|
|
<strong>{source.name}</strong>
|
|
|
|
|
<small>{source.type === "EXCHANGE_EWS" ? "Exchange" : "ICS"}</small>
|
|
|
|
|
</span>
|
|
|
|
|
</label>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
<label className="fieldLabel">
|
|
|
|
|
Termine unter dem Kalender
|
|
|
|
|
<select
|
|
|
|
|
className="select"
|
|
|
|
|
value={nextEventsCount}
|
|
|
|
|
onChange={(event) => onChangeEventsCount(widgetId, Number(event.target.value))}
|
|
|
|
|
>
|
|
|
|
|
<option value={0}>Ausblenden</option>
|
|
|
|
|
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((n) => (
|
|
|
|
|
<option key={n} value={n}>{n} {n === 1 ? "Termin" : "Termine"}</option>
|
|
|
|
|
))}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="calendarHeader widgetNoDrag">
|
|
|
|
|
<button type="button" className="calendarNavButton" onClick={() => setCalendarMonth((c) => new Date(c.getFullYear(), c.getMonth() - 1, 1))}>
|
|
|
|
|
Zurück
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button type="button" className="calendarMonthButton" onClick={() => setCalendarMonth(new Date())}>
|
|
|
|
|
{formatMonthLabel(calendarMonth)}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<button type="button" className="calendarNavButton" onClick={() => setCalendarMonth((c) => new Date(c.getFullYear(), c.getMonth() + 1, 1))}>
|
|
|
|
|
Weiter
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{selectedSourceIds.length === 0 ? <p className="muted calendarStatus">Keine Kalenderquelle ausgewählt.</p> : null}
|
|
|
|
|
|
|
|
|
|
<div className="calendarWeekdays">
|
|
|
|
|
{weekdayLabels.map((label) => (
|
|
|
|
|
<div className="calendarWeekday" key={label}>{label}</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="calendarGrid">
|
|
|
|
|
{calendarDays.map((day) => (
|
|
|
|
|
<div
|
|
|
|
|
className={[
|
|
|
|
|
"calendarDay",
|
|
|
|
|
day.inCurrentMonth ? "" : "calendarDayMuted",
|
|
|
|
|
day.isToday ? "calendarDayToday" : "",
|
|
|
|
|
day.events.length > 0 ? "calendarDayWithEvents" : ""
|
|
|
|
|
]
|
|
|
|
|
.filter(Boolean)
|
|
|
|
|
.join(" ")}
|
|
|
|
|
key={day.key}
|
|
|
|
|
>
|
|
|
|
|
<span className="calendarDayNumber">{day.date.getDate()}</span>
|
|
|
|
|
|
|
|
|
|
{day.events.length > 0 ? <span className="calendarEventCount">{day.events.length}</span> : null}
|
|
|
|
|
|
|
|
|
|
{day.events.length > 0 ? (
|
|
|
|
|
<div className="calendarTooltip">
|
|
|
|
|
{day.events.slice(0, 5).map((event) => (
|
|
|
|
|
<div className="calendarTooltipItem" key={event.id}>
|
|
|
|
|
<span className="calendarTooltipTime">{formatEventTime(event.start)}</span>
|
|
|
|
|
<span>{event.title}</span>
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{day.events.length > 5 ? <div className="calendarTooltipMore">Weitere Termine vorhanden</div> : null}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</div>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{error ? <p className="muted calendarStatus">{error}</p> : null}
|
|
|
|
|
|
|
|
|
|
{clampedEventsCount > 0 ? (
|
|
|
|
|
<section className="nextEventsBlock">
|
|
|
|
|
<h3>Nächste Termine</h3>
|
|
|
|
|
|
|
|
|
|
{nextEvents.length === 0 ? <p className="muted">Keine nächsten Termine.</p> : null}
|
|
|
|
|
|
|
|
|
|
<div className="eventList">
|
|
|
|
|
{nextEvents.map((event) => (
|
|
|
|
|
<article className="eventItem" key={event.id}>
|
|
|
|
|
<div className="eventDate">{formatEventDate(event.start)}</div>
|
|
|
|
|
<div className="eventTitle">{event.title}</div>
|
|
|
|
|
{event.location ? <div className="eventLocation">{event.location}</div> : null}
|
|
|
|
|
</article>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
) : null}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
}
|