INSIGHT-MVP/packages/frontend/src/shell/DashboardCalendarTab.tsx
Thomas Reitz 2d56ab6b3d 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>
2026-03-13 14:30:56 +01:00

798 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { useIntegrations, useOffice365CalendarRange, useContacts, useCreateActivity } from '../crm/hooks';
import type { M365CalendarEvent, Contact } from '../crm/types';
import styles from './DashboardCalendarTab.module.css';
export type CalendarViewMode = 'month' | 'week' | 'agenda';
// ── Date Helpers ───────────────────────────────────────────────────────────────
function startOfWeekMonday(date: Date): Date {
const d = new Date(date);
const dow = (d.getDay() + 6) % 7; // Mon=0 … Sun=6
d.setDate(d.getDate() - dow);
d.setHours(0, 0, 0, 0);
return d;
}
function addDays(date: Date, n: number): Date {
const d = new Date(date);
d.setDate(d.getDate() + n);
return d;
}
function isSameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
/** 6×7 Tageszellen für die Monatsansicht */
function getMonthCells(date: Date): Date[] {
const firstDay = new Date(date.getFullYear(), date.getMonth(), 1);
const gridStart = startOfWeekMonday(firstDay);
return Array.from({ length: 42 }, (_, i) => addDays(gridStart, i));
}
/** 5 Arbeitstage der Woche (MoFr) */
function getWorkWeekDays(date: Date): Date[] {
const monday = startOfWeekMonday(date);
return Array.from({ length: 5 }, (_, i) => addDays(monday, i));
}
function toISODate(date: Date): string {
return date.toISOString().slice(0, 10);
}
/**
* 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',
minute: '2-digit',
});
}
function getEventsForDay(events: M365CalendarEvent[], day: Date): M365CalendarEvent[] {
return events
.filter((e) => isSameDay(toDate(e.start.dateTime, e.start.timeZone), day))
.sort(
(a, b) =>
toDate(a.start.dateTime, a.start.timeZone).getTime() -
toDate(b.start.dateTime, b.start.timeZone).getTime(),
);
}
// Deterministische Farbe anhand Event-ID
const EVENT_COLORS = ['#3b82f6', '#8b5cf6', '#ec4899', '#f59e0b', '#10b981', '#ef4444'];
function eventColor(id: string): string {
let h = 0;
for (const ch of id) h = (h * 31 + ch.charCodeAt(0)) % EVENT_COLORS.length;
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) ───────────────────────────
export function DayAgenda({
day,
events,
fullWidth = false,
onEventClick,
}: {
day: Date;
events: M365CalendarEvent[];
fullWidth?: boolean;
onEventClick?: (event: M365CalendarEvent) => void;
}) {
const dayEvents = getEventsForDay(events, day);
return (
<aside className={`${styles.agenda}${fullWidth ? ` ${styles.agendaFull}` : ''}`}>
<div className={styles.agendaHeader}>
<span className={styles.agendaWeekday}>
{day.toLocaleDateString('de-DE', { weekday: 'long' })}
</span>
<span className={styles.agendaDateNum}>
{day.toLocaleDateString('de-DE', { day: 'numeric', month: 'long' })}
</span>
</div>
{dayEvents.length === 0 ? (
<p className={styles.agendaEmpty}>Keine Termine</p>
) : (
<div className={styles.agendaList}>
{dayEvents.map((event) => (
<div
key={event.id}
className={styles.agendaItem}
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}>
{formatTime(event.start.dateTime, event.start.timeZone)}
{' '}
{formatTime(event.end.dateTime, event.end.timeZone)}
{event.isOnlineMeeting && (
<span className={styles.onlineBadge}>Online</span>
)}
</div>
<div className={styles.agendaSubject}>{event.subject}</div>
{event.location?.displayName && (
<div className={styles.agendaMeta}>
📍 {event.location.displayName}
</div>
)}
{event.attendees && event.attendees.length > 0 && (
<div className={styles.agendaMeta}>
👥{' '}
{event.attendees
.slice(0, 2)
.map((a) => a.emailAddress.name || a.emailAddress.address)
.join(', ')}
{event.attendees.length > 2 && ` +${event.attendees.length - 2}`}
</div>
)}
</div>
))}
</div>
)}
</aside>
);
}
// ── Agenda-Listenansicht (eigene Ansicht, zeigt mehrere Tage) ─────────────────
function AgendaView({
currentDate,
events,
selectedDay,
onDayClick,
onEventClick,
}: {
currentDate: Date;
events: M365CalendarEvent[];
selectedDay: Date;
onDayClick: (d: Date) => void;
onEventClick: (event: M365CalendarEvent) => void;
}) {
const today = new Date();
// 14 Tage ab currentDate
const days = Array.from({ length: 14 }, (_, i) => addDays(currentDate, i));
return (
<div className={styles.agendaFullView}>
{days.map((day) => {
const dayEvents = getEventsForDay(events, day);
const isToday = isSameDay(day, today);
const isSelected = isSameDay(day, selectedDay);
return (
<div
key={day.toISOString()}
className={`${styles.agendaFullDay} ${isSelected ? styles.agendaFullDaySelected : ''}`}
onClick={() => onDayClick(day)}
>
<div className={`${styles.agendaFullDayHdr} ${isToday ? styles.agendaFullDayHdrToday : ''}`}>
<span className={styles.agendaFullWeekday}>
{day.toLocaleDateString('de-DE', { weekday: 'short' })}
</span>
<span className={`${styles.agendaFullNum} ${isToday ? styles.agendaFullNumToday : ''}`}>
{day.getDate()}
</span>
<span className={styles.agendaFullMonth}>
{day.toLocaleDateString('de-DE', { month: 'short' })}
</span>
</div>
<div className={styles.agendaFullEvents}>
{dayEvents.length === 0 ? (
<span className={styles.agendaFullEmpty}>Kein Termin</span>
) : (
dayEvents.map((e) => (
<div
key={e.id}
className={styles.agendaFullEvent}
style={{ borderLeftColor: eventColor(e.id), cursor: 'pointer' }}
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}>
{formatTime(e.start.dateTime, e.start.timeZone)}
</span>
<span className={styles.agendaFullEventSubj}>{e.subject}</span>
{e.isOnlineMeeting && (
<span className={styles.onlineBadge}>Online</span>
)}
</div>
))
)}
</div>
</div>
);
})}
</div>
);
}
// ── Monatsansicht ─────────────────────────────────────────────────────────────
const WEEKDAY_LABELS = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
function MonthView({
currentDate,
selectedDay,
events,
onDayClick,
}: {
currentDate: Date;
selectedDay: Date;
events: M365CalendarEvent[];
onDayClick: (d: Date) => void;
}) {
const today = new Date();
const cells = getMonthCells(currentDate);
const month = currentDate.getMonth();
return (
<div className={styles.monthGrid}>
{/* Wochentag-Header */}
{WEEKDAY_LABELS.map((d) => (
<div key={d} className={styles.monthWeekdayHdr}>
{d}
</div>
))}
{/* Tageszellen */}
{cells.map((day, i) => {
const inMonth = day.getMonth() === month;
const isToday = isSameDay(day, today);
const isSelected = isSameDay(day, selectedDay);
const dayEvents = getEventsForDay(events, day);
return (
<div
key={i}
className={[
styles.dayCell,
!inMonth ? styles.dayCellOther : '',
isSelected ? styles.dayCellSelected : '',
]
.filter(Boolean)
.join(' ')}
onClick={() => onDayClick(day)}
>
<span
className={`${styles.dayNum} ${isToday ? styles.dayNumToday : ''}`}
>
{day.getDate()}
</span>
<div className={styles.dayCellEvents}>
{dayEvents.slice(0, 2).map((e) => (
<div
key={e.id}
className={styles.eventChip}
style={{ background: eventColor(e.id) }}
title={`${formatTime(e.start.dateTime, e.start.timeZone)} ${e.subject}`}
>
{formatTime(e.start.dateTime, e.start.timeZone)} {e.subject}
</div>
))}
{dayEvents.length > 2 && (
<div className={styles.eventMore}>+{dayEvents.length - 2}</div>
)}
</div>
</div>
);
})}
</div>
);
}
// ── Wochenansicht (MoFr) ─────────────────────────────────────────────────────
function WeekView({
currentDate,
selectedDay,
events,
onDayClick,
onEventClick,
}: {
currentDate: Date;
selectedDay: Date;
events: M365CalendarEvent[];
onDayClick: (d: Date) => void;
onEventClick: (event: M365CalendarEvent) => void;
}) {
const today = new Date();
const weekDays = getWorkWeekDays(currentDate); // nur MoFr
return (
<div className={styles.weekGrid}>
{weekDays.map((day) => {
const isToday = isSameDay(day, today);
const isSelected = isSameDay(day, selectedDay);
const dayEvents = getEventsForDay(events, day);
return (
<div
key={day.toISOString()}
className={`${styles.weekCol} ${isSelected ? styles.weekColSelected : ''}`}
onClick={() => onDayClick(day)}
>
<div
className={`${styles.weekColHdr} ${isToday ? styles.weekColHdrToday : ''}`}
>
<span className={styles.weekColDay}>
{day.toLocaleDateString('de-DE', { weekday: 'short' })}
</span>
<span
className={`${styles.weekColNum} ${isToday ? styles.weekColNumToday : ''}`}
>
{day.getDate()}
</span>
</div>
<div className={styles.weekColEvents}>
{dayEvents.map((e) => (
<div
key={e.id}
className={styles.weekEvent}
style={{ borderLeftColor: eventColor(e.id) }}
title={e.subject}
onClick={(ev) => {
ev.stopPropagation();
onEventClick(e);
}}
>
<span className={styles.weekEventTime}>
{formatTime(e.start.dateTime, e.start.timeZone)}
</span>
<span className={styles.weekEventSubj}>{e.subject}</span>
</div>
))}
</div>
</div>
);
})}
</div>
);
}
// ── DashboardCalendarTab ──────────────────────────────────────────────────────
export function DashboardCalendarTab() {
const { data: integrationsData, isLoading: intLoading } = useIntegrations();
const isConnected =
integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
const [viewMode, setViewMode] = useState<CalendarViewMode>('month');
const [currentDate, setCurrentDate] = useState(() => new Date());
const [selectedDay, setSelectedDay] = useState(() => new Date());
const [selectedEvent, setSelectedEvent] = useState<M365CalendarEvent | null>(null);
// Datumsbereich berechnen
const rangeStart =
viewMode === 'month'
? startOfWeekMonday(
new Date(currentDate.getFullYear(), currentDate.getMonth(), 1),
)
: viewMode === 'week'
? startOfWeekMonday(currentDate)
: currentDate; // agenda: ab currentDate
const rangeEnd =
viewMode === 'month'
? addDays(rangeStart, 42)
: viewMode === 'week'
? addDays(startOfWeekMonday(currentDate), 5) // MoFr
: addDays(currentDate, 14); // 14 Tage Agenda
const { data: eventsData, isLoading, error } = useOffice365CalendarRange(
toISODate(rangeStart),
toISODate(rangeEnd),
);
const events: M365CalendarEvent[] = eventsData?.data ?? [];
const navigate = (delta: number) => {
const d = new Date(currentDate);
if (viewMode === 'month') {
d.setMonth(d.getMonth() + delta);
} else if (viewMode === 'week') {
d.setDate(d.getDate() + delta * 7);
} else {
d.setDate(d.getDate() + delta * 14);
}
setCurrentDate(d);
};
const goToday = () => {
const t = new Date();
setCurrentDate(t);
setSelectedDay(t);
};
// Anzeigebezeichnung
const rangeLabel = (() => {
if (viewMode === 'month') {
return currentDate.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
}
if (viewMode === 'week') {
const days = getWorkWeekDays(currentDate);
const f = days[0];
const l = days[4];
const sm = f.getMonth() === l.getMonth();
return sm
? `${f.getDate()}. ${l.getDate()}. ${l.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })}`
: `${f.getDate()}. ${f.toLocaleDateString('de-DE', { month: 'short' })} ${l.getDate()}. ${l.toLocaleDateString('de-DE', { month: 'short', year: 'numeric' })}`;
}
// agenda
const end = addDays(currentDate, 13);
const sm = currentDate.getMonth() === end.getMonth();
return sm
? `${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' })}`;
})();
if (intLoading) {
return <p className={styles.status}>Verbindung wird geprüft</p>;
}
if (!isConnected) {
return (
<div className={styles.notConnected}>
<span className={styles.notConnectedIcon}>📅</span>
<p className={styles.notConnectedTitle}>Microsoft 365 nicht verbunden</p>
<p className={styles.notConnectedSub}>
Verbinden Sie Ihr Konto unter{' '}
<a href="/crm/office365" className={styles.notConnectedLink}>
CRM Office 365
</a>
.
</p>
</div>
);
}
return (
<div className={styles.root}>
{/* Toolbar */}
<div className={styles.toolbar}>
<div className={styles.navGroup}>
<button
type="button"
className={styles.navBtn}
onClick={() => navigate(-1)}
aria-label="Zurück"
>
</button>
<button type="button" className={styles.todayBtn} onClick={goToday}>
Heute
</button>
<button
type="button"
className={styles.navBtn}
onClick={() => navigate(1)}
aria-label="Vor"
>
</button>
</div>
<h2 className={styles.rangeLabel}>{rangeLabel}</h2>
<div className={styles.viewToggle}>
<button
type="button"
className={`${styles.viewBtn} ${viewMode === 'month' ? styles.viewBtnActive : ''}`}
onClick={() => setViewMode('month')}
>
Monat
</button>
<button
type="button"
className={`${styles.viewBtn} ${viewMode === 'week' ? styles.viewBtnActive : ''}`}
onClick={() => setViewMode('week')}
>
Woche
</button>
<button
type="button"
className={`${styles.viewBtn} ${viewMode === 'agenda' ? styles.viewBtnActive : ''}`}
onClick={() => setViewMode('agenda')}
>
Agenda
</button>
</div>
</div>
{/* Status */}
{isLoading && (
<p className={styles.status}>Termine werden geladen</p>
)}
{error && (
<p className={styles.errorText}>
Kalendertermine konnten nicht geladen werden.
</p>
)}
{/* Hauptbereich */}
{!isLoading && (
<div className={styles.content}>
<div className={styles.calendarPanel}>
{viewMode === 'month' && (
<MonthView
currentDate={currentDate}
selectedDay={selectedDay}
events={events}
onDayClick={setSelectedDay}
/>
)}
{viewMode === 'week' && (
<WeekView
currentDate={currentDate}
selectedDay={selectedDay}
events={events}
onDayClick={setSelectedDay}
onEventClick={setSelectedEvent}
/>
)}
{viewMode === 'agenda' && (
<AgendaView
currentDate={currentDate}
events={events}
selectedDay={selectedDay}
onDayClick={setSelectedDay}
onEventClick={setSelectedEvent}
/>
)}
</div>
{/* Tages-Agenda nur bei Monat- und Wochenansicht */}
{(viewMode === 'month' || viewMode === 'week') && (
<DayAgenda
day={selectedDay}
events={events}
onEventClick={setSelectedEvent}
/>
)}
</div>
)}
{/* Event-Detail-Modal */}
{selectedEvent && (
<CalendarEventModal
event={selectedEvent}
onClose={() => setSelectedEvent(null)}
/>
)}
</div>
);
}