diff --git a/packages/frontend/src/shell/DashboardCalendarTab.module.css b/packages/frontend/src/shell/DashboardCalendarTab.module.css index 14322ad..ac30b7e 100644 --- a/packages/frontend/src/shell/DashboardCalendarTab.module.css +++ b/packages/frontend/src/shell/DashboardCalendarTab.module.css @@ -619,3 +619,306 @@ letter-spacing: 0.02em; 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); +} diff --git a/packages/frontend/src/shell/DashboardCalendarTab.tsx b/packages/frontend/src/shell/DashboardCalendarTab.tsx index 08bfcc5..d918cda 100644 --- a/packages/frontend/src/shell/DashboardCalendarTab.tsx +++ b/packages/frontend/src/shell/DashboardCalendarTab.tsx @@ -1,6 +1,6 @@ import { useState } from 'react'; -import { useIntegrations, useOffice365CalendarRange } from '../crm/hooks'; -import type { M365CalendarEvent } from '../crm/types'; +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'; @@ -46,8 +46,19 @@ function toISODate(date: Date): string { 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', minute: '2-digit', }); @@ -55,10 +66,11 @@ function formatTime(iso: string): string { function getEventsForDay(events: M365CalendarEvent[], day: Date): M365CalendarEvent[] { return events - .filter((e) => isSameDay(new Date(e.start.dateTime), day)) + .filter((e) => isSameDay(toDate(e.start.dateTime, e.start.timeZone), day)) .sort( (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]; } +// ── Calendar Event Detail Modal ──────────────────────────────────────────────── + +function CalendarEventModal({ + event, + onClose, +}: { + event: M365CalendarEvent; + onClose: () => void; +}) { + const createActivity = useCreateActivity(); + const [contactSearch, setContactSearch] = useState(''); + const [selectedContact, setSelectedContact] = useState(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 ( +
{ + if (e.target === e.currentTarget) onClose(); + }} + > +
+ {/* Schließen */} + + + {/* Titel */} +

{event.subject}

+
+ 📅 {dateLabel} + · + 🕐 {timeLabel} + {event.isOnlineMeeting && Online} +
+ {event.location?.displayName && ( +
📍 {event.location.displayName}
+ )} + + {/* Detailbox */} +
+ {event.organizer && ( +
+ Organisator + + {event.organizer.emailAddress.name || event.organizer.emailAddress.address} + +
+ )} + {event.attendees && event.attendees.length > 0 && ( +
+ Teilnehmer +
+ {event.attendees.map((a, i) => ( +
+ {a.emailAddress.name + ? `${a.emailAddress.name} <${a.emailAddress.address}>` + : a.emailAddress.address} +
+ ))} +
+
+ )} + {event.onlineMeetingUrl && ( +
+ Meeting + + Beitreten ↗ + +
+ )} + {!event.organizer && (!event.attendees || event.attendees.length === 0) && !event.onlineMeetingUrl && ( +

Keine weiteren Details verfügbar.

+ )} +
+ + {/* Zu Kontakt hinzufügen */} + {actStatus !== 'success' ? ( +
+
Als Aktivität bei Kontakt speichern
+
+ { + setContactSearch(e.target.value); + setSelectedContact(null); + setActStatus('idle'); + }} + className={styles.contactSearchInput} + /> +
+ {(contacts.length > 0 || contactsQuery.isLoading) && ( +
+ {contactsQuery.isLoading ? ( +
Suche…
+ ) : ( + contacts.map((c) => ( +
setSelectedContact(c)} + > + {contactDisplayName(c)} + {c.company?.name && ( + {c.company.name} + )} +
+ )) + )} +
+ )} + {contacts.length === 0 && !contactsQuery.isLoading && contactSearch.length > 0 && ( +
Keine Kontakte gefunden
+ )} + {selectedContact && ( + + )} +
+ ) : ( +
+ ✓ Meeting-Aktivität wurde erfolgreich gespeichert +
+ )} + + {/* Footer */} +
+ + Im Outlook öffnen ↗ + +
+
+
+ ); +} + // ── 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); @@ -99,18 +317,21 @@ export function DayAgenda({ ) : (
{dayEvents.map((event) => ( - onEventClick?.(event)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') onEventClick?.(event); + }} >
- {formatTime(event.start.dateTime)} + {formatTime(event.start.dateTime, event.start.timeZone)} {' – '} - {formatTime(event.end.dateTime)} + {formatTime(event.end.dateTime, event.end.timeZone)} {event.isOnlineMeeting && ( Online )} @@ -131,7 +352,7 @@ export function DayAgenda({ {event.attendees.length > 2 && ` +${event.attendees.length - 2}`}
)} -
+
))} )} @@ -146,11 +367,13 @@ function AgendaView({ 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 @@ -160,7 +383,7 @@ function AgendaView({
{days.map((day) => { const dayEvents = getEventsForDay(events, day); - const isToday = isSameDay(day, today); + const isToday = isSameDay(day, today); const isSelected = isSameDay(day, selectedDay); return (
Kein Termin ) : ( dayEvents.map((e) => ( - ev.stopPropagation()} + 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); + }} > - {formatTime(e.start.dateTime)} + {formatTime(e.start.dateTime, e.start.timeZone)} {e.subject} {e.isOnlineMeeting && ( Online )} - +
)) )}
@@ -226,9 +455,9 @@ function MonthView({ events: M365CalendarEvent[]; onDayClick: (d: Date) => void; }) { - const today = new Date(); - const cells = getMonthCells(currentDate); - const month = currentDate.getMonth(); + const today = new Date(); + const cells = getMonthCells(currentDate); + const month = currentDate.getMonth(); return (
@@ -241,17 +470,17 @@ function MonthView({ {/* Tageszellen */} {cells.map((day, i) => { - const inMonth = day.getMonth() === month; - const isToday = isSameDay(day, today); + const inMonth = day.getMonth() === month; + const isToday = isSameDay(day, today); const isSelected = isSameDay(day, selectedDay); - const dayEvents = getEventsForDay(events, day); + const dayEvents = getEventsForDay(events, day); return (
- {formatTime(e.start.dateTime)} {e.subject} + {formatTime(e.start.dateTime, e.start.timeZone)} {e.subject}
))} {dayEvents.length > 2 && ( @@ -292,21 +521,23 @@ function WeekView({ selectedDay, events, onDayClick, + onEventClick, }: { currentDate: Date; selectedDay: Date; events: M365CalendarEvent[]; onDayClick: (d: Date) => void; + onEventClick: (event: M365CalendarEvent) => void; }) { - const today = new Date(); + const today = new Date(); const weekDays = getWorkWeekDays(currentDate); // nur Mo–Fr return (
{weekDays.map((day) => { - const isToday = isSameDay(day, today); + const isToday = isSameDay(day, today); const isSelected = isSameDay(day, selectedDay); - const dayEvents = getEventsForDay(events, day); + const dayEvents = getEventsForDay(events, day); return (
{ ev.stopPropagation(); - window.open(e.webLink, '_blank', 'noopener,noreferrer'); + onEventClick(e); }} > - {formatTime(e.start.dateTime)} + {formatTime(e.start.dateTime, e.start.timeZone)} {e.subject}
@@ -361,9 +592,10 @@ export function DashboardCalendarTab() { (i) => i.provider === 'MICROSOFT_365' && i.connected, ) ?? false; - const [viewMode, setViewMode] = useState('month'); - const [currentDate, setCurrentDate] = useState(() => new Date()); - const [selectedDay, setSelectedDay] = useState(() => new Date()); + const [viewMode, setViewMode] = useState('month'); + const [currentDate, setCurrentDate] = useState(() => new Date()); + const [selectedDay, setSelectedDay] = useState(() => new Date()); + const [selectedEvent, setSelectedEvent] = useState(null); // Datumsbereich berechnen const rangeStart = @@ -422,7 +654,7 @@ export function DashboardCalendarTab() { } // agenda const end = addDays(currentDate, 13); - const sm = currentDate.getMonth() === end.getMonth(); + 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' })}`; @@ -529,6 +761,7 @@ export function DashboardCalendarTab() { selectedDay={selectedDay} events={events} onDayClick={setSelectedDay} + onEventClick={setSelectedEvent} /> )} {viewMode === 'agenda' && ( @@ -537,16 +770,29 @@ export function DashboardCalendarTab() { events={events} selectedDay={selectedDay} onDayClick={setSelectedDay} + onEventClick={setSelectedEvent} /> )}
{/* Tages-Agenda nur bei Monat- und Wochenansicht */} {(viewMode === 'month' || viewMode === 'week') && ( - + )}
)} + + {/* Event-Detail-Modal */} + {selectedEvent && ( + setSelectedEvent(null)} + /> + )} ); }