INSIGHT-MVP/packages/frontend/src/shell/DashboardCalendarTab.tsx
Thomas Reitz fbf0b33a1f feat(dashboard): E-Mail Lesefenster + Kalender Umbau
E-Mail Tab:
- Klick auf E-Mail öffnet Detail-Modal (Lesefenster wie Outlook)
- Modal zeigt: Betreff, Absender, Datum, Anhang-Info, Body-Vorschau
- CRM-Bereich: gefundener Kontakt mit "Im CRM öffnen" + "Als Aktivität"
  speichern; kein Kontakt → "Kontakt anlegen" navigiert zu /crm/contacts
- "In Outlook öffnen" Link im Footer des Modals

Kalender Tab:
- WeekView: nur Arbeitstage Mo–Fr (5-Spalten-Grid statt 7)
- Neue Ansicht "Agenda": 14-Tage-Listenansicht (eigener Toggle-Button)
- Tages-Agenda nur bei Monat- und Wochenansicht sichtbar (nicht Agenda-View)
- Home-Tab: Tages-Agenda des heutigen Tages als Widget rechts
  (nur sichtbar wenn M365 verbunden)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 11:36:30 +01:00

550 lines
18 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 } from '../crm/hooks';
import type { M365CalendarEvent } 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);
}
function formatTime(iso: string): string {
return new Date(iso).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
}
function getEventsForDay(events: M365CalendarEvent[], day: Date): M365CalendarEvent[] {
return events
.filter((e) => isSameDay(new Date(e.start.dateTime), day))
.sort(
(a, b) =>
new Date(a.start.dateTime).getTime() - new Date(b.start.dateTime).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];
}
// ── Day Agenda (Tages-Detailansicht, auch für Home) ───────────────────────────
export function DayAgenda({
day,
events,
}: {
day: Date;
events: M365CalendarEvent[];
}) {
const dayEvents = getEventsForDay(events, day);
return (
<aside className={styles.agenda}>
<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) => (
<a
key={event.id}
href={event.webLink}
target="_blank"
rel="noopener noreferrer"
className={styles.agendaItem}
style={{ borderLeftColor: eventColor(event.id) }}
>
<div className={styles.agendaTime}>
{formatTime(event.start.dateTime)}
{' '}
{formatTime(event.end.dateTime)}
{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>
)}
</a>
))}
</div>
)}
</aside>
);
}
// ── Agenda-Listenansicht (eigene Ansicht, zeigt mehrere Tage) ─────────────────
function AgendaView({
currentDate,
events,
selectedDay,
onDayClick,
}: {
currentDate: Date;
events: M365CalendarEvent[];
selectedDay: Date;
onDayClick: (d: Date) => 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) => (
<a
key={e.id}
href={e.webLink}
target="_blank"
rel="noopener noreferrer"
className={styles.agendaFullEvent}
style={{ borderLeftColor: eventColor(e.id) }}
onClick={(ev) => ev.stopPropagation()}
>
<span className={styles.agendaFullEventTime}>
{formatTime(e.start.dateTime)}
</span>
<span className={styles.agendaFullEventSubj}>{e.subject}</span>
{e.isOnlineMeeting && (
<span className={styles.onlineBadge}>Online</span>
)}
</a>
))
)}
</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.subject}`}
>
{formatTime(e.start.dateTime)} {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,
}: {
currentDate: Date;
selectedDay: Date;
events: M365CalendarEvent[];
onDayClick: (d: Date) => 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();
window.open(e.webLink, '_blank', 'noopener,noreferrer');
}}
>
<span className={styles.weekEventTime}>
{formatTime(e.start.dateTime)}
</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());
// 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}
/>
)}
{viewMode === 'agenda' && (
<AgendaView
currentDate={currentDate}
events={events}
selectedDay={selectedDay}
onDayClick={setSelectedDay}
/>
)}
</div>
{/* Tages-Agenda nur bei Monat- und Wochenansicht */}
{(viewMode === 'month' || viewMode === 'week') && (
<DayAgenda day={selectedDay} events={events} />
)}
</div>
)}
</div>
);
}