mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 23:56:40 +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;
|
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',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 ', '');
|
||||||
|
|
|
||||||
|
|
@ -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 } }>(
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
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 },
|
{ 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
|
||||||
: ''
|
: ''
|
||||||
}`}
|
}`}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue