feat(frontend): Kalender-Event-Modal + Zeitzonen-Fix

- Neues CalendarEventModal: Klick auf Termin in Agenda-/Wochenansicht
  öffnet Popup mit Betreff, Datum/Uhrzeit, Ort, Organisator, Teilnehmern
  und Online-Meeting-Link
- CRM-Aktivität: Kontaktsuche direkt im Modal; Termin als MEETING-
  Aktivität beim gewählten CRM-Kontakt speichern
- "Im Outlook öffnen"-Link im Modal-Footer
- Zeitzonen-Fix: MS Graph liefert UTC-Zeiten ohne 'Z'-Suffix →
  toDate() hängt 'Z' an → alle Termine jetzt in korrekter Ortszeit
- DayAgenda + AgendaView + WeekView: <a>-Tags durch klickbare <div>
  (role=button) ersetzt; onEventClick-Prop weitergereicht

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 14:30:56 +01:00
parent 3d75a7f9de
commit 2d56ab6b3d
2 changed files with 591 additions and 42 deletions

View file

@ -619,3 +619,306 @@
letter-spacing: 0.02em; letter-spacing: 0.02em;
text-transform: uppercase; text-transform: uppercase;
} }
/* ── Event-Detail-Modal ──────────────────────────────────────────────────────── */
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
padding: 1.5rem;
}
.modal {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg, var(--radius-md));
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.2);
width: 100%;
max-width: 520px;
position: relative;
padding: 1.75rem;
max-height: 90vh;
overflow-y: auto;
}
.modalClose {
position: absolute;
top: 1rem;
right: 1rem;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
font-size: 1.25rem;
line-height: 1;
transition: all 0.15s;
}
.modalClose:hover {
background: var(--color-bg);
color: var(--color-text);
}
.modalTitle {
font-size: 1.125rem;
font-weight: 700;
color: var(--color-text);
margin: 0 2.25rem 0.5rem 0;
line-height: 1.35;
}
.modalDateTime {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
font-size: 0.875rem;
color: var(--color-text-secondary);
margin-bottom: 0.375rem;
}
.modalDateSep {
color: var(--color-text-muted);
}
.modalLocation {
font-size: 0.875rem;
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
}
/* ── Detailbox ── */
.modalDetails {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 0.875rem 0 1rem;
padding: 0.875rem 1rem;
background: var(--color-bg);
border-radius: var(--radius-md);
border: 1px solid var(--color-border);
}
.detailRow {
display: flex;
align-items: flex-start;
gap: 0.75rem;
}
.detailLabel {
font-size: 0.75rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
min-width: 80px;
flex-shrink: 0;
padding-top: 0.125rem;
}
.detailValue {
font-size: 0.875rem;
color: var(--color-text);
}
.detailLink {
font-size: 0.875rem;
color: var(--color-primary);
text-decoration: none;
}
.detailLink:hover {
text-decoration: underline;
}
.detailEmpty {
font-size: 0.875rem;
color: var(--color-text-muted);
font-style: italic;
margin: 0;
}
.attendeeList {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.attendeeItem {
font-size: 0.8125rem;
color: var(--color-text);
word-break: break-all;
}
/* ── Kontakt-Zuordnung ── */
.contactSection {
margin-bottom: 1rem;
}
.contactSectionTitle {
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.contactSearchWrap {
margin-bottom: 0.375rem;
}
.contactSearchInput {
width: 100%;
padding: 0.5rem 0.75rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
color: var(--color-text);
outline: none;
transition: border-color 0.15s;
box-sizing: border-box;
}
.contactSearchInput:focus {
border-color: var(--color-primary);
}
.contactResultList {
display: flex;
flex-direction: column;
gap: 2px;
max-height: 160px;
overflow-y: auto;
margin-bottom: 0.5rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem;
}
.contactResultItem {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4375rem 0.75rem;
border-radius: 3px;
cursor: pointer;
transition: background 0.1s;
}
.contactResultItem:hover {
background: rgba(59, 130, 246, 0.06);
}
.contactResultItemSelected {
background: rgba(59, 130, 246, 0.1);
outline: 1px solid var(--color-primary);
outline-offset: -1px;
}
.contactResultName {
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contactResultMeta {
font-size: 0.75rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.contactResultEmpty {
font-size: 0.8125rem;
color: var(--color-text-muted);
padding: 0.5rem 0.75rem;
font-style: italic;
}
.saveActivityBtn {
width: 100%;
padding: 0.5625rem 1rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 0.8125rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
text-align: left;
}
.saveActivityBtn:hover:not(:disabled) {
filter: brightness(1.08);
}
.saveActivityBtn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.saveActivityBtnError {
background: #ef4444;
}
.contactSectionSuccess {
background: rgba(34, 197, 94, 0.1);
border: 1px solid rgba(34, 197, 94, 0.3);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
font-size: 0.875rem;
color: #16a34a;
margin-bottom: 1rem;
font-weight: 500;
}
/* ── Modal-Footer ── */
.modalFooter {
display: flex;
justify-content: flex-end;
padding-top: 0.875rem;
border-top: 1px solid var(--color-border);
}
.outlookBtn {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.4375rem 1rem;
background: none;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.8125rem;
text-decoration: none;
transition: all 0.15s;
cursor: pointer;
}
.outlookBtn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}

View file

@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useIntegrations, useOffice365CalendarRange } from '../crm/hooks'; import { useIntegrations, useOffice365CalendarRange, useContacts, useCreateActivity } from '../crm/hooks';
import type { M365CalendarEvent } from '../crm/types'; import type { M365CalendarEvent, Contact } from '../crm/types';
import styles from './DashboardCalendarTab.module.css'; import styles from './DashboardCalendarTab.module.css';
export type CalendarViewMode = 'month' | 'week' | 'agenda'; export type CalendarViewMode = 'month' | 'week' | 'agenda';
@ -46,8 +46,19 @@ function toISODate(date: Date): string {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
function formatTime(iso: string): string { /**
return new Date(iso).toLocaleTimeString('de-DE', { * Timezone-aware Date: MS Graph gibt UTC-Zeiten ohne 'Z'-Suffix zurück.
* Ohne diesen Fix interpretiert JavaScript die Zeiten als Ortszeit 1h Versatz.
*/
function toDate(dateTime: string, timeZone: string): Date {
if (timeZone === 'UTC' && !dateTime.endsWith('Z') && !dateTime.includes('+')) {
return new Date(dateTime + 'Z');
}
return new Date(dateTime);
}
function formatTime(dateTime: string, timeZone: string): string {
return toDate(dateTime, timeZone).toLocaleTimeString('de-DE', {
hour: '2-digit', hour: '2-digit',
minute: '2-digit', minute: '2-digit',
}); });
@ -55,10 +66,11 @@ function formatTime(iso: string): string {
function getEventsForDay(events: M365CalendarEvent[], day: Date): M365CalendarEvent[] { function getEventsForDay(events: M365CalendarEvent[], day: Date): M365CalendarEvent[] {
return events return events
.filter((e) => isSameDay(new Date(e.start.dateTime), day)) .filter((e) => isSameDay(toDate(e.start.dateTime, e.start.timeZone), day))
.sort( .sort(
(a, b) => (a, b) =>
new Date(a.start.dateTime).getTime() - new Date(b.start.dateTime).getTime(), toDate(a.start.dateTime, a.start.timeZone).getTime() -
toDate(b.start.dateTime, b.start.timeZone).getTime(),
); );
} }
@ -70,16 +82,222 @@ function eventColor(id: string): string {
return EVENT_COLORS[h]; return EVENT_COLORS[h];
} }
// ── Calendar Event Detail Modal ────────────────────────────────────────────────
function CalendarEventModal({
event,
onClose,
}: {
event: M365CalendarEvent;
onClose: () => void;
}) {
const createActivity = useCreateActivity();
const [contactSearch, setContactSearch] = useState('');
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
const [actStatus, setActStatus] = useState<'idle' | 'loading' | 'success' | 'error'>('idle');
const contactsQuery = useContacts({
page: 1,
pageSize: 8,
search: contactSearch || undefined,
});
const contacts: Contact[] = contactsQuery.data?.data ?? [];
const startDate = toDate(event.start.dateTime, event.start.timeZone);
const endDate = toDate(event.end.dateTime, event.end.timeZone);
const dateLabel = startDate.toLocaleDateString('de-DE', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric',
});
const timeLabel = `${formatTime(event.start.dateTime, event.start.timeZone)} ${formatTime(event.end.dateTime, event.end.timeZone)} Uhr`;
const contactDisplayName = (c: Contact) =>
[c.firstName, c.lastName].filter(Boolean).join(' ') || c.email || c.id;
const handleSaveActivity = async () => {
if (!selectedContact || actStatus === 'loading') return;
if (actStatus === 'error') {
setActStatus('idle');
return;
}
setActStatus('loading');
try {
const now = new Date();
const isPast = endDate < now;
await createActivity.mutateAsync({
contactId: selectedContact.id,
type: 'MEETING',
subject: event.subject,
description: event.attendees?.length
? `Teilnehmer: ${event.attendees.map((a) => a.emailAddress.name || a.emailAddress.address).join(', ')}`
: undefined,
scheduledAt: startDate.toISOString(),
completedAt: isPast ? endDate.toISOString() : undefined,
});
setActStatus('success');
} catch {
setActStatus('error');
}
};
return (
<div
className={styles.backdrop}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className={styles.modal}>
{/* Schließen */}
<button className={styles.modalClose} onClick={onClose} title="Schließen">
×
</button>
{/* Titel */}
<h2 className={styles.modalTitle}>{event.subject}</h2>
<div className={styles.modalDateTime}>
<span>📅 {dateLabel}</span>
<span className={styles.modalDateSep}>·</span>
<span>🕐 {timeLabel}</span>
{event.isOnlineMeeting && <span className={styles.onlineBadge}>Online</span>}
</div>
{event.location?.displayName && (
<div className={styles.modalLocation}>📍 {event.location.displayName}</div>
)}
{/* Detailbox */}
<div className={styles.modalDetails}>
{event.organizer && (
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Organisator</span>
<span className={styles.detailValue}>
{event.organizer.emailAddress.name || event.organizer.emailAddress.address}
</span>
</div>
)}
{event.attendees && event.attendees.length > 0 && (
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Teilnehmer</span>
<div className={styles.attendeeList}>
{event.attendees.map((a, i) => (
<div key={i} className={styles.attendeeItem}>
{a.emailAddress.name
? `${a.emailAddress.name} <${a.emailAddress.address}>`
: a.emailAddress.address}
</div>
))}
</div>
</div>
)}
{event.onlineMeetingUrl && (
<div className={styles.detailRow}>
<span className={styles.detailLabel}>Meeting</span>
<a
href={event.onlineMeetingUrl}
target="_blank"
rel="noopener noreferrer"
className={styles.detailLink}
>
Beitreten
</a>
</div>
)}
{!event.organizer && (!event.attendees || event.attendees.length === 0) && !event.onlineMeetingUrl && (
<p className={styles.detailEmpty}>Keine weiteren Details verfügbar.</p>
)}
</div>
{/* Zu Kontakt hinzufügen */}
{actStatus !== 'success' ? (
<div className={styles.contactSection}>
<div className={styles.contactSectionTitle}>Als Aktivität bei Kontakt speichern</div>
<div className={styles.contactSearchWrap}>
<input
type="text"
placeholder="Kontakt suchen…"
value={contactSearch}
onChange={(e) => {
setContactSearch(e.target.value);
setSelectedContact(null);
setActStatus('idle');
}}
className={styles.contactSearchInput}
/>
</div>
{(contacts.length > 0 || contactsQuery.isLoading) && (
<div className={styles.contactResultList}>
{contactsQuery.isLoading ? (
<div className={styles.contactResultEmpty}>Suche</div>
) : (
contacts.map((c) => (
<div
key={c.id}
className={`${styles.contactResultItem} ${selectedContact?.id === c.id ? styles.contactResultItemSelected : ''}`}
onClick={() => setSelectedContact(c)}
>
<span className={styles.contactResultName}>{contactDisplayName(c)}</span>
{c.company?.name && (
<span className={styles.contactResultMeta}>{c.company.name}</span>
)}
</div>
))
)}
</div>
)}
{contacts.length === 0 && !contactsQuery.isLoading && contactSearch.length > 0 && (
<div className={styles.contactResultEmpty}>Keine Kontakte gefunden</div>
)}
{selectedContact && (
<button
className={`${styles.saveActivityBtn} ${actStatus === 'error' ? styles.saveActivityBtnError : ''}`}
onClick={handleSaveActivity}
disabled={actStatus === 'loading'}
>
{actStatus === 'loading'
? 'Speichere…'
: actStatus === 'error'
? '✕ Fehler Erneut versuchen'
: `Als Meeting bei „${contactDisplayName(selectedContact)}" speichern`}
</button>
)}
</div>
) : (
<div className={styles.contactSectionSuccess}>
Meeting-Aktivität wurde erfolgreich gespeichert
</div>
)}
{/* Footer */}
<div className={styles.modalFooter}>
<a
href={event.webLink}
target="_blank"
rel="noopener noreferrer"
className={styles.outlookBtn}
>
Im Outlook öffnen
</a>
</div>
</div>
</div>
);
}
// ── Day Agenda (Tages-Detailansicht, auch für Home) ─────────────────────────── // ── Day Agenda (Tages-Detailansicht, auch für Home) ───────────────────────────
export function DayAgenda({ export function DayAgenda({
day, day,
events, events,
fullWidth = false, fullWidth = false,
onEventClick,
}: { }: {
day: Date; day: Date;
events: M365CalendarEvent[]; events: M365CalendarEvent[];
fullWidth?: boolean; fullWidth?: boolean;
onEventClick?: (event: M365CalendarEvent) => void;
}) { }) {
const dayEvents = getEventsForDay(events, day); const dayEvents = getEventsForDay(events, day);
@ -99,18 +317,21 @@ export function DayAgenda({
) : ( ) : (
<div className={styles.agendaList}> <div className={styles.agendaList}>
{dayEvents.map((event) => ( {dayEvents.map((event) => (
<a <div
key={event.id} key={event.id}
href={event.webLink}
target="_blank"
rel="noopener noreferrer"
className={styles.agendaItem} className={styles.agendaItem}
style={{ borderLeftColor: eventColor(event.id) }} style={{ borderLeftColor: eventColor(event.id), cursor: 'pointer' }}
role="button"
tabIndex={0}
onClick={() => onEventClick?.(event)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') onEventClick?.(event);
}}
> >
<div className={styles.agendaTime}> <div className={styles.agendaTime}>
{formatTime(event.start.dateTime)} {formatTime(event.start.dateTime, event.start.timeZone)}
{' '} {' '}
{formatTime(event.end.dateTime)} {formatTime(event.end.dateTime, event.end.timeZone)}
{event.isOnlineMeeting && ( {event.isOnlineMeeting && (
<span className={styles.onlineBadge}>Online</span> <span className={styles.onlineBadge}>Online</span>
)} )}
@ -131,7 +352,7 @@ export function DayAgenda({
{event.attendees.length > 2 && ` +${event.attendees.length - 2}`} {event.attendees.length > 2 && ` +${event.attendees.length - 2}`}
</div> </div>
)} )}
</a> </div>
))} ))}
</div> </div>
)} )}
@ -146,11 +367,13 @@ function AgendaView({
events, events,
selectedDay, selectedDay,
onDayClick, onDayClick,
onEventClick,
}: { }: {
currentDate: Date; currentDate: Date;
events: M365CalendarEvent[]; events: M365CalendarEvent[];
selectedDay: Date; selectedDay: Date;
onDayClick: (d: Date) => void; onDayClick: (d: Date) => void;
onEventClick: (event: M365CalendarEvent) => void;
}) { }) {
const today = new Date(); const today = new Date();
// 14 Tage ab currentDate // 14 Tage ab currentDate
@ -160,7 +383,7 @@ function AgendaView({
<div className={styles.agendaFullView}> <div className={styles.agendaFullView}>
{days.map((day) => { {days.map((day) => {
const dayEvents = getEventsForDay(events, day); const dayEvents = getEventsForDay(events, day);
const isToday = isSameDay(day, today); const isToday = isSameDay(day, today);
const isSelected = isSameDay(day, selectedDay); const isSelected = isSameDay(day, selectedDay);
return ( return (
<div <div
@ -184,23 +407,29 @@ function AgendaView({
<span className={styles.agendaFullEmpty}>Kein Termin</span> <span className={styles.agendaFullEmpty}>Kein Termin</span>
) : ( ) : (
dayEvents.map((e) => ( dayEvents.map((e) => (
<a <div
key={e.id} key={e.id}
href={e.webLink}
target="_blank"
rel="noopener noreferrer"
className={styles.agendaFullEvent} className={styles.agendaFullEvent}
style={{ borderLeftColor: eventColor(e.id) }} style={{ borderLeftColor: eventColor(e.id), cursor: 'pointer' }}
onClick={(ev) => ev.stopPropagation()} role="button"
tabIndex={0}
onClick={(ev) => {
ev.stopPropagation();
onEventClick(e);
}}
onKeyDown={(ev) => {
ev.stopPropagation();
if (ev.key === 'Enter' || ev.key === ' ') onEventClick(e);
}}
> >
<span className={styles.agendaFullEventTime}> <span className={styles.agendaFullEventTime}>
{formatTime(e.start.dateTime)} {formatTime(e.start.dateTime, e.start.timeZone)}
</span> </span>
<span className={styles.agendaFullEventSubj}>{e.subject}</span> <span className={styles.agendaFullEventSubj}>{e.subject}</span>
{e.isOnlineMeeting && ( {e.isOnlineMeeting && (
<span className={styles.onlineBadge}>Online</span> <span className={styles.onlineBadge}>Online</span>
)} )}
</a> </div>
)) ))
)} )}
</div> </div>
@ -226,9 +455,9 @@ function MonthView({
events: M365CalendarEvent[]; events: M365CalendarEvent[];
onDayClick: (d: Date) => void; onDayClick: (d: Date) => void;
}) { }) {
const today = new Date(); const today = new Date();
const cells = getMonthCells(currentDate); const cells = getMonthCells(currentDate);
const month = currentDate.getMonth(); const month = currentDate.getMonth();
return ( return (
<div className={styles.monthGrid}> <div className={styles.monthGrid}>
@ -241,17 +470,17 @@ function MonthView({
{/* Tageszellen */} {/* Tageszellen */}
{cells.map((day, i) => { {cells.map((day, i) => {
const inMonth = day.getMonth() === month; const inMonth = day.getMonth() === month;
const isToday = isSameDay(day, today); const isToday = isSameDay(day, today);
const isSelected = isSameDay(day, selectedDay); const isSelected = isSameDay(day, selectedDay);
const dayEvents = getEventsForDay(events, day); const dayEvents = getEventsForDay(events, day);
return ( return (
<div <div
key={i} key={i}
className={[ className={[
styles.dayCell, styles.dayCell,
!inMonth ? styles.dayCellOther : '', !inMonth ? styles.dayCellOther : '',
isSelected ? styles.dayCellSelected : '', isSelected ? styles.dayCellSelected : '',
] ]
.filter(Boolean) .filter(Boolean)
@ -269,9 +498,9 @@ function MonthView({
key={e.id} key={e.id}
className={styles.eventChip} className={styles.eventChip}
style={{ background: eventColor(e.id) }} style={{ background: eventColor(e.id) }}
title={`${formatTime(e.start.dateTime)} ${e.subject}`} title={`${formatTime(e.start.dateTime, e.start.timeZone)} ${e.subject}`}
> >
{formatTime(e.start.dateTime)} {e.subject} {formatTime(e.start.dateTime, e.start.timeZone)} {e.subject}
</div> </div>
))} ))}
{dayEvents.length > 2 && ( {dayEvents.length > 2 && (
@ -292,21 +521,23 @@ function WeekView({
selectedDay, selectedDay,
events, events,
onDayClick, onDayClick,
onEventClick,
}: { }: {
currentDate: Date; currentDate: Date;
selectedDay: Date; selectedDay: Date;
events: M365CalendarEvent[]; events: M365CalendarEvent[];
onDayClick: (d: Date) => void; onDayClick: (d: Date) => void;
onEventClick: (event: M365CalendarEvent) => void;
}) { }) {
const today = new Date(); const today = new Date();
const weekDays = getWorkWeekDays(currentDate); // nur MoFr const weekDays = getWorkWeekDays(currentDate); // nur MoFr
return ( return (
<div className={styles.weekGrid}> <div className={styles.weekGrid}>
{weekDays.map((day) => { {weekDays.map((day) => {
const isToday = isSameDay(day, today); const isToday = isSameDay(day, today);
const isSelected = isSameDay(day, selectedDay); const isSelected = isSameDay(day, selectedDay);
const dayEvents = getEventsForDay(events, day); const dayEvents = getEventsForDay(events, day);
return ( return (
<div <div
@ -335,11 +566,11 @@ function WeekView({
title={e.subject} title={e.subject}
onClick={(ev) => { onClick={(ev) => {
ev.stopPropagation(); ev.stopPropagation();
window.open(e.webLink, '_blank', 'noopener,noreferrer'); onEventClick(e);
}} }}
> >
<span className={styles.weekEventTime}> <span className={styles.weekEventTime}>
{formatTime(e.start.dateTime)} {formatTime(e.start.dateTime, e.start.timeZone)}
</span> </span>
<span className={styles.weekEventSubj}>{e.subject}</span> <span className={styles.weekEventSubj}>{e.subject}</span>
</div> </div>
@ -361,9 +592,10 @@ export function DashboardCalendarTab() {
(i) => i.provider === 'MICROSOFT_365' && i.connected, (i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false; ) ?? false;
const [viewMode, setViewMode] = useState<CalendarViewMode>('month'); const [viewMode, setViewMode] = useState<CalendarViewMode>('month');
const [currentDate, setCurrentDate] = useState(() => new Date()); const [currentDate, setCurrentDate] = useState(() => new Date());
const [selectedDay, setSelectedDay] = useState(() => new Date()); const [selectedDay, setSelectedDay] = useState(() => new Date());
const [selectedEvent, setSelectedEvent] = useState<M365CalendarEvent | null>(null);
// Datumsbereich berechnen // Datumsbereich berechnen
const rangeStart = const rangeStart =
@ -422,7 +654,7 @@ export function DashboardCalendarTab() {
} }
// agenda // agenda
const end = addDays(currentDate, 13); const end = addDays(currentDate, 13);
const sm = currentDate.getMonth() === end.getMonth(); const sm = currentDate.getMonth() === end.getMonth();
return sm return sm
? `${currentDate.getDate()}. ${end.getDate()}. ${end.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}` ? `${currentDate.getDate()}. ${end.getDate()}. ${end.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}`
: `${currentDate.getDate()}. ${currentDate.toLocaleDateString('de-DE', { month: 'short' })} ${end.getDate()}. ${end.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' })}`; : `${currentDate.getDate()}. ${currentDate.toLocaleDateString('de-DE', { month: 'short' })} ${end.getDate()}. ${end.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' })}`;
@ -529,6 +761,7 @@ export function DashboardCalendarTab() {
selectedDay={selectedDay} selectedDay={selectedDay}
events={events} events={events}
onDayClick={setSelectedDay} onDayClick={setSelectedDay}
onEventClick={setSelectedEvent}
/> />
)} )}
{viewMode === 'agenda' && ( {viewMode === 'agenda' && (
@ -537,16 +770,29 @@ export function DashboardCalendarTab() {
events={events} events={events}
selectedDay={selectedDay} selectedDay={selectedDay}
onDayClick={setSelectedDay} onDayClick={setSelectedDay}
onEventClick={setSelectedEvent}
/> />
)} )}
</div> </div>
{/* Tages-Agenda nur bei Monat- und Wochenansicht */} {/* Tages-Agenda nur bei Monat- und Wochenansicht */}
{(viewMode === 'month' || viewMode === 'week') && ( {(viewMode === 'month' || viewMode === 'week') && (
<DayAgenda day={selectedDay} events={events} /> <DayAgenda
day={selectedDay}
events={events}
onEventClick={setSelectedEvent}
/>
)} )}
</div> </div>
)} )}
{/* Event-Detail-Modal */}
{selectedEvent && (
<CalendarEventModal
event={selectedEvent}
onClose={() => setSelectedEvent(null)}
/>
)}
</div> </div>
); );
} }