From 76e8dff57742e9b761cb5df5f95644e7ea011709 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Fri, 13 Mar 2026 10:54:32 +0100 Subject: [PATCH] feat(dashboard): Kalender-Tab mit Monats-/Wochenansicht und Tages-Agenda MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Graph API: getCalendarEventsForRange() für beliebigen Datumsbereich, GET /crm/office365/calendar/range?startDate=&endDate= Endpoint (vor bestehenden calendar-Route definiert um Routing-Konflikt zu vermeiden) - Graph API: wellKnownName aus mailFolders $select entfernt (400-Fehler auf Exchange-Tenants die das OData-Property nicht unterstützen) - Frontend: DashboardCalendarTab mit MonthView (6×7 Grid), WeekView (7 Spalten) und DayAgenda (rechts 1/3), Navigation vor/zurück + Heute-Button, deterministisches Event-Coloring, Klick öffnet Termin in Outlook Online - Frontend: DashboardEmailTab Ordner-Sortierung auf Display-Name-Basis (wellKnownName optional, isInboxFolder() erkennt Posteingang/Inbox) - Frontend: M365MailFolder.wellKnownName als optional markiert Co-Authored-By: Claude Sonnet 4.6 --- .../crm-service/src/graph/graph.service.ts | 40 +- .../src/graph/office365.controller.ts | 16 + packages/frontend/src/crm/api.ts | 8 + packages/frontend/src/crm/hooks.ts | 14 + packages/frontend/src/crm/types.ts | 2 +- .../src/shell/DashboardCalendarTab.module.css | 486 ++++++++++++++++++ .../src/shell/DashboardCalendarTab.tsx | 445 ++++++++++++++++ .../frontend/src/shell/DashboardEmailTab.tsx | 39 +- packages/frontend/src/shell/DashboardPage.tsx | 3 +- 9 files changed, 1039 insertions(+), 14 deletions(-) create mode 100644 packages/frontend/src/shell/DashboardCalendarTab.module.css create mode 100644 packages/frontend/src/shell/DashboardCalendarTab.tsx diff --git a/packages/crm-service/src/graph/graph.service.ts b/packages/crm-service/src/graph/graph.service.ts index 7dbc585..78e3653 100644 --- a/packages/crm-service/src/graph/graph.service.ts +++ b/packages/crm-service/src/graph/graph.service.ts @@ -348,6 +348,41 @@ export class GraphService { return lists; } + // ── Kalender: beliebiger Datumsbereich ─────────────────────────────── + + /** Kalender-Ereignisse für einen bestimmten Zeitraum (für Monats-/Wochenansicht) */ + async getCalendarEventsForRange( + userJwt: string, + userId: string, + startDate: string, // YYYY-MM-DD (inklusiv) + endDate: string, // YYYY-MM-DD (exklusiv) + ): Promise { + const cacheKey = `graph:calendar-range:${userId}:${startDate}:${endDate}`; + const cached = await this.redis.get(cacheKey); + if (cached) return JSON.parse(cached) as M365CalendarEvent[]; + + const accessToken = await this.getM365Token(userJwt); + + const data = await this.graphGet<{ value: M365CalendarEvent[] }>( + accessToken, + '/me/calendarView', + { + startDateTime: `${startDate}T00:00:00Z`, + endDateTime: `${endDate}T00:00:00Z`, + $top: '200', + $select: 'id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeetingUrl,webLink', + $orderby: 'start/dateTime asc', + }, + ); + + const events = data.value ?? []; + await this.redis.set(cacheKey, JSON.stringify(events), CACHE_TTL); + this.logger.debug( + `Graph: ${events.length} Kalender-Ereignisse (${startDate} – ${endDate}) geladen`, + ); + return events; + } + // ── Mail-Ordner ─────────────────────────────────────────────────────── /** Alle Mail-Ordner des Benutzers (Inbox, Gesendet, Entwürfe, …) */ @@ -363,7 +398,10 @@ export class GraphService { '/me/mailFolders', { $top: '50', - $select: 'id,displayName,totalItemCount,unreadItemCount,childFolderCount,wellKnownName', + // wellKnownName wird NICHT in $select aufgenommen — wird von vielen Exchange-Tenants + // nicht als selektierbares OData-Property unterstützt (400 Bad Request). + // Ordner-Identifikation erfolgt stattdessen über den displayName. + $select: 'id,displayName,totalItemCount,unreadItemCount,childFolderCount', }, ); diff --git a/packages/crm-service/src/graph/office365.controller.ts b/packages/crm-service/src/graph/office365.controller.ts index 7d69737..3c619c0 100644 --- a/packages/crm-service/src/graph/office365.controller.ts +++ b/packages/crm-service/src/graph/office365.controller.ts @@ -34,6 +34,22 @@ export class Office365Controller { return { success: true, data: emails, meta: { count: emails.length } }; } + @Get('calendar/range') + async getCalendarRange( + @Req() req: Request & { user: JwtUser }, + @Query('startDate') startDate: string, + @Query('endDate') endDate: string, + ) { + const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); + const events = await this.graphService.getCalendarEventsForRange( + jwt, + req.user.sub, + startDate, + endDate, + ); + return { success: true, data: events, meta: { count: events.length } }; + } + @Get('calendar') async getCalendar(@Req() req: Request & { user: JwtUser }) { const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index 1620325..e164c0c 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -846,6 +846,14 @@ export const office365Api = { ) .then((r) => r.data), + getCalendarRange: (startDate: string, endDate: string) => + api + .get<{ success: boolean; data: M365CalendarEvent[]; meta: { count: number } }>( + '/crm/office365/calendar/range', + { params: { startDate, endDate } }, + ) + .then((r) => r.data), + getMailFolders: () => api .get<{ success: boolean; data: M365MailFolder[]; meta: { count: number } }>( diff --git a/packages/frontend/src/crm/hooks.ts b/packages/frontend/src/crm/hooks.ts index 41e2f70..9fc632a 100644 --- a/packages/frontend/src/crm/hooks.ts +++ b/packages/frontend/src/crm/hooks.ts @@ -1395,6 +1395,20 @@ export function useOffice365Tasks() { }); } +export function useOffice365CalendarRange(startDate: string, endDate: string) { + const { data: integrationsData } = useIntegrations(); + const isConnected = integrationsData?.data?.some( + (i) => i.provider === 'MICROSOFT_365' && i.connected, + ) ?? false; + + return useQuery({ + queryKey: ['office365', 'calendar-range', startDate, endDate], + queryFn: () => office365Api.getCalendarRange(startDate, endDate), + enabled: isConnected && !!startDate && !!endDate, + staleTime: 5 * 60 * 1000, + }); +} + export function useOffice365MailFolders() { const { data: integrationsData } = useIntegrations(); const isConnected = integrationsData?.data?.some( diff --git a/packages/frontend/src/crm/types.ts b/packages/frontend/src/crm/types.ts index 59b2503..b600513 100644 --- a/packages/frontend/src/crm/types.ts +++ b/packages/frontend/src/crm/types.ts @@ -1020,7 +1020,7 @@ export interface M365MailFolder { totalItemCount: number; unreadItemCount: number; childFolderCount: number; - wellKnownName: string | null; + wellKnownName?: string | null; // optional — nicht von allen Exchange-Tenants unterstützt } /** Minimaler CRM-Kontakt für E-Mail-Lookup */ diff --git a/packages/frontend/src/shell/DashboardCalendarTab.module.css b/packages/frontend/src/shell/DashboardCalendarTab.module.css new file mode 100644 index 0000000..49582e6 --- /dev/null +++ b/packages/frontend/src/shell/DashboardCalendarTab.module.css @@ -0,0 +1,486 @@ +/* ============================================================ + DashboardCalendarTab — Outlook-Kalender im Dashboard + ============================================================ */ + +.root { + display: flex; + flex-direction: column; + gap: 0.875rem; +} + +/* ── Status / Leer-Zustände ── */ + +.status { + color: var(--color-text-muted); + font-size: 0.9375rem; + padding: 1rem 0; +} + +.errorText { + color: #ef4444; + font-size: 0.9375rem; + padding: 0.5rem 0; +} + +/* ── Nicht verbunden ── */ + +.notConnected { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + gap: 0.5rem; +} + +.notConnectedIcon { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.notConnectedTitle { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); + margin: 0; +} + +.notConnectedSub { + font-size: 0.9375rem; + color: var(--color-text-muted); + margin: 0; +} + +.notConnectedLink { + color: var(--color-primary); + text-decoration: none; +} + +.notConnectedLink:hover { + text-decoration: underline; +} + +/* ── Toolbar ── */ + +.toolbar { + display: flex; + align-items: center; + gap: 1rem; + flex-wrap: wrap; +} + +.navGroup { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.navBtn { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + color: var(--color-text-muted); + font-size: 1.125rem; + cursor: pointer; + transition: all 0.15s; + line-height: 1; +} + +.navBtn:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.todayBtn { + padding: 0.3125rem 0.875rem; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-muted); + cursor: pointer; + transition: all 0.15s; +} + +.todayBtn:hover { + border-color: var(--color-primary); + color: var(--color-primary); +} + +.rangeLabel { + flex: 1; + font-size: 1rem; + font-weight: 600; + color: var(--color-text); + margin: 0; + white-space: nowrap; +} + +.viewToggle { + display: flex; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.viewBtn { + padding: 0.3125rem 0.875rem; + background: var(--color-bg-card); + border: none; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-text-muted); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.viewBtn + .viewBtn { + border-left: 1px solid var(--color-border); +} + +.viewBtn:hover { + color: var(--color-text); + background: var(--color-bg); +} + +.viewBtnActive { + background: var(--color-primary); + color: #fff; +} + +.viewBtnActive:hover { + color: #fff; + opacity: 0.9; +} + +/* ── Hauptbereich: Kalender + Agenda ── */ + +.content { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.calendarPanel { + flex: 1; + min-width: 0; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +/* ── Monatsansicht ── */ + +.monthGrid { + display: grid; + grid-template-columns: repeat(7, 1fr); +} + +.monthWeekdayHdr { + padding: 0.5rem 0; + text-align: center; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); + border-bottom: 1px solid var(--color-border); + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.dayCell { + min-height: 88px; + padding: 0.375rem 0.5rem; + border-right: 1px solid var(--color-border); + border-bottom: 1px solid var(--color-border); + cursor: pointer; + transition: background 0.12s; + overflow: hidden; +} + +.dayCell:nth-child(7n) { + border-right: none; +} + +.dayCell:nth-last-child(-n+7) { + border-bottom: none; +} + +.dayCell:hover { + background: rgba(59, 130, 246, 0.04); +} + +.dayCellOther { + background: var(--color-bg); + opacity: 0.55; +} + +.dayCellSelected { + background: rgba(59, 130, 246, 0.07) !important; + outline: 1px solid var(--color-primary); + outline-offset: -1px; +} + +.dayNum { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.625rem; + height: 1.625rem; + font-size: 0.875rem; + font-weight: 400; + color: var(--color-text); + border-radius: 50%; + margin-bottom: 0.25rem; +} + +.dayNumToday { + background: var(--color-primary); + color: #fff; + font-weight: 700; +} + +.dayCellEvents { + display: flex; + flex-direction: column; + gap: 2px; +} + +.eventChip { + font-size: 0.6875rem; + font-weight: 500; + color: #fff; + border-radius: 2px; + padding: 1px 4px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + cursor: pointer; +} + +.eventMore { + font-size: 0.6875rem; + color: var(--color-text-muted); + padding-left: 4px; +} + +/* ── Wochenansicht ── */ + +.weekGrid { + display: grid; + grid-template-columns: repeat(7, 1fr); +} + +.weekCol { + padding: 0.5rem 0.375rem; + border-right: 1px solid var(--color-border); + cursor: pointer; + transition: background 0.12s; + min-height: 320px; +} + +.weekCol:last-child { + border-right: none; +} + +.weekCol:hover { + background: rgba(59, 130, 246, 0.04); +} + +.weekColSelected { + background: rgba(59, 130, 246, 0.06) !important; + outline: 1px solid var(--color-primary); + outline-offset: -1px; +} + +.weekColHdr { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.1875rem; + margin-bottom: 0.5rem; + padding-bottom: 0.375rem; + border-bottom: 1px solid var(--color-border); +} + +.weekColHdrToday .weekColNum { + /* handled by weekColNumToday */ +} + +.weekColDay { + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.weekColNum { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.75rem; + height: 1.75rem; + font-size: 0.9375rem; + font-weight: 500; + color: var(--color-text); + border-radius: 50%; +} + +.weekColNumToday { + background: var(--color-primary); + color: #fff; + font-weight: 700; +} + +.weekColEvents { + display: flex; + flex-direction: column; + gap: 3px; +} + +.weekEvent { + padding: 0.25rem 0.375rem; + background: var(--color-bg); + border-left: 3px solid transparent; + border-radius: 2px; + cursor: pointer; + transition: background 0.12s; + overflow: hidden; +} + +.weekEvent:hover { + background: rgba(59, 130, 246, 0.07); +} + +.weekEventTime { + display: block; + font-size: 0.6875rem; + font-weight: 600; + color: var(--color-text-muted); + margin-bottom: 1px; +} + +.weekEventSubj { + display: block; + font-size: 0.75rem; + color: var(--color-text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* ── Tages-Agenda (rechte Spalte) ── */ + +.agenda { + width: 260px; + flex-shrink: 0; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + overflow: hidden; +} + +.agendaHeader { + display: flex; + flex-direction: column; + padding: 0.875rem 1rem 0.75rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); +} + +.agendaWeekday { + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.agendaDateNum { + font-size: 1.0625rem; + font-weight: 600; + color: var(--color-text); + margin-top: 0.125rem; +} + +.agendaEmpty { + padding: 1.5rem 1rem; + font-size: 0.875rem; + color: var(--color-text-muted); + margin: 0; + text-align: center; +} + +.agendaList { + display: flex; + flex-direction: column; + overflow-y: auto; + max-height: calc(100vh - 300px); +} + +.agendaItem { + display: block; + padding: 0.75rem 1rem; + text-decoration: none; + color: inherit; + border-bottom: 1px solid var(--color-border); + border-left: 3px solid transparent; + transition: background 0.12s; +} + +.agendaItem:last-child { + border-bottom: none; +} + +.agendaItem:hover { + background: rgba(59, 130, 246, 0.05); +} + +.agendaTime { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text-muted); + margin-bottom: 0.25rem; +} + +.agendaSubject { + font-size: 0.875rem; + font-weight: 500; + color: var(--color-text); + line-height: 1.35; + margin-bottom: 0.25rem; +} + +.agendaMeta { + font-size: 0.75rem; + color: var(--color-text-muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 0.125rem; +} + +.onlineBadge { + display: inline-flex; + align-items: center; + font-size: 0.625rem; + font-weight: 700; + background: rgba(59, 130, 246, 0.12); + color: var(--color-primary); + border: 1px solid rgba(59, 130, 246, 0.25); + border-radius: 999px; + padding: 0.0625rem 0.375rem; + letter-spacing: 0.02em; + text-transform: uppercase; +} diff --git a/packages/frontend/src/shell/DashboardCalendarTab.tsx b/packages/frontend/src/shell/DashboardCalendarTab.tsx new file mode 100644 index 0000000..14c243b --- /dev/null +++ b/packages/frontend/src/shell/DashboardCalendarTab.tsx @@ -0,0 +1,445 @@ +import { useState } from 'react'; +import { useIntegrations, useOffice365CalendarRange } from '../crm/hooks'; +import type { M365CalendarEvent } from '../crm/types'; +import styles from './DashboardCalendarTab.module.css'; + +type ViewMode = 'month' | 'week'; + +// ── 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)); +} + +/** 7 Tage der Woche (Mo–So) */ +function getWeekDays(date: Date): Date[] { + const monday = startOfWeekMonday(date); + return Array.from({ length: 7 }, (_, 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 (rechte Spalte) ───────────────────────────────────────────────── + +function DayAgenda({ + day, + events, +}: { + day: Date; + events: M365CalendarEvent[]; +}) { + const dayEvents = getEventsForDay(events, day); + + return ( + + ); +} + +// ── 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 ( +
+ {/* Wochentag-Header */} + {WEEKDAY_LABELS.map((d) => ( +
+ {d} +
+ ))} + + {/* 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 ( +
onDayClick(day)} + > + + {day.getDate()} + +
+ {dayEvents.slice(0, 2).map((e) => ( +
+ {formatTime(e.start.dateTime)} {e.subject} +
+ ))} + {dayEvents.length > 2 && ( +
+{dayEvents.length - 2}
+ )} +
+
+ ); + })} +
+ ); +} + +// ── Wochenansicht ───────────────────────────────────────────────────────────── + +function WeekView({ + currentDate, + selectedDay, + events, + onDayClick, +}: { + currentDate: Date; + selectedDay: Date; + events: M365CalendarEvent[]; + onDayClick: (d: Date) => void; +}) { + const today = new Date(); + const weekDays = getWeekDays(currentDate); + + return ( +
+ {weekDays.map((day) => { + const isToday = isSameDay(day, today); + const isSelected = isSameDay(day, selectedDay); + const dayEvents = getEventsForDay(events, day); + + return ( +
onDayClick(day)} + > +
+ + {day.toLocaleDateString('de-DE', { weekday: 'short' })} + + + {day.getDate()} + +
+
+ {dayEvents.map((e) => ( +
{ + ev.stopPropagation(); + window.open(e.webLink, '_blank', 'noopener,noreferrer'); + }} + > + + {formatTime(e.start.dateTime)} + + {e.subject} +
+ ))} +
+
+ ); + })} +
+ ); +} + +// ── 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('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), + ) + : startOfWeekMonday(currentDate); + + const rangeEnd = + viewMode === 'month' + ? addDays(rangeStart, 42) // 6 Wochen + : addDays(startOfWeekMonday(currentDate), 7); + + 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 { + d.setDate(d.getDate() + delta * 7); + } + setCurrentDate(d); + }; + + const goToday = () => { + const t = new Date(); + setCurrentDate(t); + setSelectedDay(t); + }; + + // Anzeigebezeichnung + const rangeLabel = + viewMode === 'month' + ? currentDate.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }) + : (() => { + const days = getWeekDays(currentDate); + const f = days[0]; + const l = days[6]; + 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' })}`; + })(); + + if (intLoading) { + return

Verbindung wird geprüft…

; + } + + if (!isConnected) { + return ( +
+ 📅 +

Microsoft 365 nicht verbunden

+

+ Verbinden Sie Ihr Konto unter{' '} + + CRM → Office 365 + + . +

+
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ + + +
+ +

{rangeLabel}

+ +
+ + +
+
+ + {/* Status */} + {isLoading && ( +

Termine werden geladen…

+ )} + {error && ( +

+ Kalendertermine konnten nicht geladen werden. +

+ )} + + {/* Hauptbereich: Kalender + Agenda */} + {!isLoading && ( +
+
+ {viewMode === 'month' ? ( + + ) : ( + + )} +
+ + +
+ )} +
+ ); +} diff --git a/packages/frontend/src/shell/DashboardEmailTab.tsx b/packages/frontend/src/shell/DashboardEmailTab.tsx index 1b5a864..dc4a550 100644 --- a/packages/frontend/src/shell/DashboardEmailTab.tsx +++ b/packages/frontend/src/shell/DashboardEmailTab.tsx @@ -19,19 +19,36 @@ const DAYS_OPTIONS = [ { label: 'Alle', value: 0 }, ] as const; -const FOLDER_PRIORITY: Record = { - inbox: 0, - sentitems: 1, - drafts: 2, - archive: 3, - junkemail: 4, - deleteditems: 5, +// Priorität anhand bekannter DE/EN Ordnernamen (wellKnownName nicht verlässlich) +const FOLDER_NAME_PRIORITY: Record = { + 'posteingang': 0, + 'inbox': 0, + 'gesendete elemente': 1, + 'sent items': 1, + 'gesendet': 1, + 'entwürfe': 2, + 'drafts': 2, + 'archiv': 3, + 'archive': 3, + 'junk-e-mail': 4, + 'junk email': 4, + 'spam': 4, + 'gelöschte elemente': 5, + 'deleted items': 5, + 'papierkorb': 5, + 'outbox': 6, + 'ausgang': 6, }; +function isInboxFolder(f: M365MailFolder): boolean { + const name = f.displayName.toLowerCase(); + return name === 'posteingang' || name === 'inbox' || f.wellKnownName === 'inbox'; +} + function sortFolders(folders: M365MailFolder[]): M365MailFolder[] { return [...folders].sort((a, b) => { - const pa = FOLDER_PRIORITY[a.wellKnownName ?? ''] ?? 99; - const pb = FOLDER_PRIORITY[b.wellKnownName ?? ''] ?? 99; + const pa = FOLDER_NAME_PRIORITY[a.displayName.toLowerCase()] ?? 99; + const pb = FOLDER_NAME_PRIORITY[b.displayName.toLowerCase()] ?? 99; if (pa !== pb) return pa - pb; return a.displayName.localeCompare(b.displayName, 'de'); }); @@ -236,7 +253,7 @@ export function DashboardEmailTab() { // Standardmäßig Posteingang — Graph API akzeptiert Well-Known-Namen direkt als ID, // sodass E-Mails sofort geladen werden, bevor die Ordnerliste verfügbar ist. // Sobald Ordner geladen sind, wird die echte ID aus der Liste verwendet. - const inboxFolder = sortedFolders.find((f) => f.wellKnownName === 'inbox'); + const inboxFolder = sortedFolders.find(isInboxFolder); const activeFolderId = selectedFolderId ?? inboxFolder?.id ?? 'inbox'; const { data: emailsData, isLoading: emailsLoading, error: emailsError } = @@ -312,7 +329,7 @@ export function DashboardEmailTab() { type="button" className={`${styles.folderItem} ${ activeFolderId === folder.id || - (activeFolderId === 'inbox' && folder.wellKnownName === 'inbox') + (activeFolderId === 'inbox' && isInboxFolder(folder)) ? styles.folderItemActive : '' }`} diff --git a/packages/frontend/src/shell/DashboardPage.tsx b/packages/frontend/src/shell/DashboardPage.tsx index 1c112b5..4ca8941 100644 --- a/packages/frontend/src/shell/DashboardPage.tsx +++ b/packages/frontend/src/shell/DashboardPage.tsx @@ -3,6 +3,7 @@ import { useAuth } from '../auth/AuthContext'; import { WeatherWidget } from '../components/WeatherWidget'; import { EventCountdownTiles } from '../components/EventCountdownTiles'; import { DashboardEmailTab } from './DashboardEmailTab'; +import { DashboardCalendarTab } from './DashboardCalendarTab'; import styles from './DashboardPage.module.css'; type DashboardTab = 'home' | 'emails' | 'calendar' | 'tasks' | 'contacts'; @@ -87,7 +88,7 @@ export function DashboardPage() { /> )} {activeTab === 'emails' && } - {activeTab === 'calendar' && } + {activeTab === 'calendar' && } {activeTab === 'tasks' && } {activeTab === 'contacts' && }