mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
- 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>
798 lines
27 KiB
TypeScript
798 lines
27 KiB
TypeScript
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 (Mo–Fr) */
|
||
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 (Mo–Fr) ─────────────────────────────────────────────────────
|
||
|
||
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 Mo–Fr
|
||
|
||
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) // Mo–Fr
|
||
: 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>
|
||
);
|
||
}
|