mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 11:46:39 +02:00
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>
550 lines
18 KiB
TypeScript
550 lines
18 KiB
TypeScript
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 (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);
|
||
}
|
||
|
||
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 (Mo–Fr) ─────────────────────────────────────────────────────
|
||
|
||
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 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();
|
||
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) // 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}
|
||
/>
|
||
)}
|
||
{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>
|
||
);
|
||
}
|