mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
b6b182a349
commit
76e8dff577
9 changed files with 1039 additions and 14 deletions
|
|
@ -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<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 ───────────────────────────────────────────────────────
|
||||
|
||||
/** 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',
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ', '');
|
||||
|
|
|
|||
|
|
@ -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 } }>(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
486
packages/frontend/src/shell/DashboardCalendarTab.module.css
Normal file
486
packages/frontend/src/shell/DashboardCalendarTab.module.css
Normal 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;
|
||||
}
|
||||
445
packages/frontend/src/shell/DashboardCalendarTab.tsx
Normal file
445
packages/frontend/src/shell/DashboardCalendarTab.tsx
Normal 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 (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 (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,19 +19,36 @@ const DAYS_OPTIONS = [
|
|||
{ label: 'Alle', value: 0 },
|
||||
] as const;
|
||||
|
||||
const FOLDER_PRIORITY: Record<string, number> = {
|
||||
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<string, number> = {
|
||||
'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
|
||||
: ''
|
||||
}`}
|
||||
|
|
|
|||
|
|
@ -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' && <DashboardEmailTab />}
|
||||
{activeTab === 'calendar' && <ComingSoonTab label="Kalender" />}
|
||||
{activeTab === 'calendar' && <DashboardCalendarTab />}
|
||||
{activeTab === 'tasks' && <ComingSoonTab label="Aufgaben" />}
|
||||
{activeTab === 'contacts' && <ComingSoonTab label="Kontakte" />}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue