feat(dashboard): Kalender-Tab mit Monats-/Wochenansicht und Tages-Agenda

- 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 10:54:32 +01:00
parent b6b182a349
commit 76e8dff577
9 changed files with 1039 additions and 14 deletions

View file

@ -348,6 +348,41 @@ export class GraphService {
return lists; 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<M365CalendarEvent[]> {
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 ─────────────────────────────────────────────────────── // ── Mail-Ordner ───────────────────────────────────────────────────────
/** Alle Mail-Ordner des Benutzers (Inbox, Gesendet, Entwürfe, …) */ /** Alle Mail-Ordner des Benutzers (Inbox, Gesendet, Entwürfe, …) */
@ -363,7 +398,10 @@ export class GraphService {
'/me/mailFolders', '/me/mailFolders',
{ {
$top: '50', $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',
}, },
); );

View file

@ -34,6 +34,22 @@ export class Office365Controller {
return { success: true, data: emails, meta: { count: emails.length } }; 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') @Get('calendar')
async getCalendar(@Req() req: Request & { user: JwtUser }) { async getCalendar(@Req() req: Request & { user: JwtUser }) {
const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');

View file

@ -846,6 +846,14 @@ export const office365Api = {
) )
.then((r) => r.data), .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: () => getMailFolders: () =>
api api
.get<{ success: boolean; data: M365MailFolder[]; meta: { count: number } }>( .get<{ success: boolean; data: M365MailFolder[]; meta: { count: number } }>(

View file

@ -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() { export function useOffice365MailFolders() {
const { data: integrationsData } = useIntegrations(); const { data: integrationsData } = useIntegrations();
const isConnected = integrationsData?.data?.some( const isConnected = integrationsData?.data?.some(

View file

@ -1020,7 +1020,7 @@ export interface M365MailFolder {
totalItemCount: number; totalItemCount: number;
unreadItemCount: number; unreadItemCount: number;
childFolderCount: 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 */ /** Minimaler CRM-Kontakt für E-Mail-Lookup */

View file

@ -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;
}

View file

@ -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 (MoSo) */
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 (
<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>
);
}
// ── 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 ─────────────────────────────────────────────────────────────
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 (
<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<ViewMode>('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 <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>
</div>
</div>
{/* Status */}
{isLoading && (
<p className={styles.status}>Termine werden geladen</p>
)}
{error && (
<p className={styles.errorText}>
Kalendertermine konnten nicht geladen werden.
</p>
)}
{/* Hauptbereich: Kalender + Agenda */}
{!isLoading && (
<div className={styles.content}>
<div className={styles.calendarPanel}>
{viewMode === 'month' ? (
<MonthView
currentDate={currentDate}
selectedDay={selectedDay}
events={events}
onDayClick={setSelectedDay}
/>
) : (
<WeekView
currentDate={currentDate}
selectedDay={selectedDay}
events={events}
onDayClick={setSelectedDay}
/>
)}
</div>
<DayAgenda day={selectedDay} events={events} />
</div>
)}
</div>
);
}

View file

@ -19,19 +19,36 @@ const DAYS_OPTIONS = [
{ label: 'Alle', value: 0 }, { label: 'Alle', value: 0 },
] as const; ] as const;
const FOLDER_PRIORITY: Record<string, number> = { // Priorität anhand bekannter DE/EN Ordnernamen (wellKnownName nicht verlässlich)
inbox: 0, const FOLDER_NAME_PRIORITY: Record<string, number> = {
sentitems: 1, 'posteingang': 0,
drafts: 2, 'inbox': 0,
archive: 3, 'gesendete elemente': 1,
junkemail: 4, 'sent items': 1,
deleteditems: 5, '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[] { function sortFolders(folders: M365MailFolder[]): M365MailFolder[] {
return [...folders].sort((a, b) => { return [...folders].sort((a, b) => {
const pa = FOLDER_PRIORITY[a.wellKnownName ?? ''] ?? 99; const pa = FOLDER_NAME_PRIORITY[a.displayName.toLowerCase()] ?? 99;
const pb = FOLDER_PRIORITY[b.wellKnownName ?? ''] ?? 99; const pb = FOLDER_NAME_PRIORITY[b.displayName.toLowerCase()] ?? 99;
if (pa !== pb) return pa - pb; if (pa !== pb) return pa - pb;
return a.displayName.localeCompare(b.displayName, 'de'); 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, // Standardmäßig Posteingang — Graph API akzeptiert Well-Known-Namen direkt als ID,
// sodass E-Mails sofort geladen werden, bevor die Ordnerliste verfügbar ist. // sodass E-Mails sofort geladen werden, bevor die Ordnerliste verfügbar ist.
// Sobald Ordner geladen sind, wird die echte ID aus der Liste verwendet. // 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 activeFolderId = selectedFolderId ?? inboxFolder?.id ?? 'inbox';
const { data: emailsData, isLoading: emailsLoading, error: emailsError } = const { data: emailsData, isLoading: emailsLoading, error: emailsError } =
@ -312,7 +329,7 @@ export function DashboardEmailTab() {
type="button" type="button"
className={`${styles.folderItem} ${ className={`${styles.folderItem} ${
activeFolderId === folder.id || activeFolderId === folder.id ||
(activeFolderId === 'inbox' && folder.wellKnownName === 'inbox') (activeFolderId === 'inbox' && isInboxFolder(folder))
? styles.folderItemActive ? styles.folderItemActive
: '' : ''
}`} }`}

View file

@ -3,6 +3,7 @@ import { useAuth } from '../auth/AuthContext';
import { WeatherWidget } from '../components/WeatherWidget'; import { WeatherWidget } from '../components/WeatherWidget';
import { EventCountdownTiles } from '../components/EventCountdownTiles'; import { EventCountdownTiles } from '../components/EventCountdownTiles';
import { DashboardEmailTab } from './DashboardEmailTab'; import { DashboardEmailTab } from './DashboardEmailTab';
import { DashboardCalendarTab } from './DashboardCalendarTab';
import styles from './DashboardPage.module.css'; import styles from './DashboardPage.module.css';
type DashboardTab = 'home' | 'emails' | 'calendar' | 'tasks' | 'contacts'; type DashboardTab = 'home' | 'emails' | 'calendar' | 'tasks' | 'contacts';
@ -87,7 +88,7 @@ export function DashboardPage() {
/> />
)} )}
{activeTab === 'emails' && <DashboardEmailTab />} {activeTab === 'emails' && <DashboardEmailTab />}
{activeTab === 'calendar' && <ComingSoonTab label="Kalender" />} {activeTab === 'calendar' && <DashboardCalendarTab />}
{activeTab === 'tasks' && <ComingSoonTab label="Aufgaben" />} {activeTab === 'tasks' && <ComingSoonTab label="Aufgaben" />}
{activeTab === 'contacts' && <ComingSoonTab label="Kontakte" />} {activeTab === 'contacts' && <ComingSoonTab label="Kontakte" />}
</div> </div>