feat(crm): Office365-Übersichtsseite + Graph API Bugfixes

- Neuer Office365-Menüpunkt mit 4 Tabs (E-Mails, Kalender, Kontakte, Aufgaben)
- CRM-Service: Office365Controller mit globalen Graph-Endpoints
- Fix: $search + $orderby Kombination in Graph API nicht erlaubt
- M365Contact Interface + attendees/hasAttachments Typen ergänzt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-13 06:59:52 +01:00
parent 82e6a03bb9
commit ad9c48bcb6
11 changed files with 1059 additions and 46 deletions

View file

@ -1,11 +1,32 @@
# INSIGHT MVP - Aenderungsprotokoll # INSIGHT MVP - Aenderungsprotokoll
## Stand: 2026-03-12 ## Stand: 2026-03-13
### Aktueller Sprint: CRM Phase 3 — Kanban-Board + Microsoft 365 OAuth-Integration (Feature-Branch: feature/crm-service) ### Aktueller Sprint: CRM Phase 3 — Kanban-Board + Microsoft 365 OAuth-Integration (Feature-Branch: feature/crm-service)
--- ---
### Aenderungen 2026-03-13: Office365-Seite + Graph API Bugfixes
#### Backend: CRM-Service — GraphModule erweitert
- `graph/graph.service.ts` — Fix: `$search` + `$orderby` koennen nicht kombiniert werden (Graph API Limitation) → `$orderby` aus Kontakt-E-Mail-Suche entfernt; neue globale Methoden: `getAllEmails`, `getAllCalendarEvents`, `getAllOutlookContacts`, `getTasks` (war schon vorhanden); `attendees` zu Kalender-Abfragen ergaenzt; Fehler werden jetzt geloggt
- `graph/office365.controller.ts` — Neuer Controller mit globalen Office365-Endpoints: `GET /crm/office365/emails`, `GET /crm/office365/calendar`, `GET /crm/office365/contacts`, `GET /crm/office365/tasks`
- `graph/graph.module.ts``Office365Controller` registriert
#### Frontend: Office365-Uebersichtsseite
- `crm/office365/Office365Page.tsx` — Neue Seite mit 4 Tabs: E-Mails, Kalender, Outlook-Kontakte, Aufgaben; zeigt alle M365-Daten des eingeloggten Users; Suchfilter fuer Kontakte; "CRM"-Button zum Navigieren in CRM-Kontakte
- `crm/office365/Office365Page.module.css` — Vollstaendiges Styling (Cards, Tabs, Grid, Badges)
- `crm/types.ts``M365Email.hasAttachments` und `M365CalendarEvent.attendees` ergaenzt; neues Interface `M365Contact`
- `crm/api.ts``office365Api` (getEmails, getCalendar, getContacts, getTasks)
- `crm/hooks.ts``useOffice365Emails`, `useOffice365Calendar`, `useOffice365Contacts`, `useOffice365Tasks`
- `shell/App.tsx` — Route `/crm/office365` hinzugefuegt
- `shell/AppLayout.tsx` — NavLink "Office 365" (Grid-Icon) nach Kanban ergaenzt
#### Datenbankfix
- `user_integrations`-Tabelle: Tenant-Membership fuer `t.reitz@xinion.de` in "Xinion GmbH" manuell angelegt (fehlende Zuordnung verursachte 403 auf allen CRM-Endpoints)
---
### Aenderungen 2026-03-12: Microsoft 365 OAuth-Integration — Frontend ### Aenderungen 2026-03-12: Microsoft 365 OAuth-Integration — Frontend
#### Frontend: MS365 Integration-Tab + Kontakt-Tabs #### Frontend: MS365 Integration-Tab + Kontakt-Tabs

View file

@ -1,12 +1,13 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { GraphController } from './graph.controller'; import { GraphController } from './graph.controller';
import { Office365Controller } from './office365.controller';
import { GraphService } from './graph.service'; import { GraphService } from './graph.service';
import { CrmPrismaModule } from '../prisma/crm-prisma.module'; import { CrmPrismaModule } from '../prisma/crm-prisma.module';
import { RedisModule } from '../redis/redis.module'; import { RedisModule } from '../redis/redis.module';
@Module({ @Module({
imports: [CrmPrismaModule, RedisModule], imports: [CrmPrismaModule, RedisModule],
controllers: [GraphController], controllers: [GraphController, Office365Controller],
providers: [GraphService], providers: [GraphService],
}) })
export class GraphModule {} export class GraphModule {}

View file

@ -25,6 +25,7 @@ export interface M365CalendarEvent {
end: { dateTime: string; timeZone: string }; end: { dateTime: string; timeZone: string };
location: { displayName: string }; location: { displayName: string };
organizer: { emailAddress: { name: string; address: string } }; organizer: { emailAddress: { name: string; address: string } };
attendees: Array<{ emailAddress: { name: string; address: string }; type: string }>;
isOnlineMeeting: boolean; isOnlineMeeting: boolean;
onlineMeetingUrl: string | null; onlineMeetingUrl: string | null;
webLink: string; webLink: string;
@ -46,6 +47,16 @@ export interface M365TaskList {
tasks: M365Task[]; tasks: M365Task[];
} }
export interface M365Contact {
id: string;
displayName: string;
emailAddresses: Array<{ name: string; address: string }>;
mobilePhone: string | null;
businessPhones: string[];
jobTitle: string | null;
companyName: string | null;
}
const GRAPH_BASE = 'https://graph.microsoft.com/v1.0'; const GRAPH_BASE = 'https://graph.microsoft.com/v1.0';
const CACHE_TTL = 300; // 5 Minuten const CACHE_TTL = 300; // 5 Minuten
@ -64,10 +75,6 @@ export class GraphService {
// ── Token vom Core-Service holen ────────────────────────────────────── // ── Token vom Core-Service holen ──────────────────────────────────────
/**
* M365-Access-Token vom Core-Service abrufen.
* Leitet den User-JWT an Core weiter Core gibt den M365-Token zurueck.
*/
async getM365Token(userJwt: string): Promise<string> { async getM365Token(userJwt: string): Promise<string> {
const url = `${this.coreServiceUrl}/api/v1/users/me/integrations/microsoft-365/token`; const url = `${this.coreServiceUrl}/api/v1/users/me/integrations/microsoft-365/token`;
@ -133,6 +140,9 @@ export class GraphService {
const errBody = (await resp.json().catch(() => ({}))) as { const errBody = (await resp.json().catch(() => ({}))) as {
error?: { message?: string }; error?: { message?: string };
}; };
this.logger.error(
`Graph API Fehler ${resp.status} auf ${path}: ${errBody.error?.message ?? resp.statusText}`,
);
throw new ServiceUnavailableException( throw new ServiceUnavailableException(
`Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`, `Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`,
); );
@ -141,12 +151,8 @@ export class GraphService {
return resp.json() as Promise<T>; return resp.json() as Promise<T>;
} }
// ── E-Mails ─────────────────────────────────────────────────────────── // ── Kontakt-spezifische Abfragen ──────────────────────────────────────
/**
* E-Mails zum Kontakt aus MS Graph laden.
* Sucht in gesendeten + empfangenen Nachrichten nach der Kontakt-E-Mail.
*/
async getContactEmails( async getContactEmails(
userJwt: string, userJwt: string,
userId: string, userId: string,
@ -154,19 +160,17 @@ export class GraphService {
): Promise<M365Email[]> { ): Promise<M365Email[]> {
const cacheKey = `graph:emails:${userId}:${contactEmail}`; const cacheKey = `graph:emails:${userId}:${contactEmail}`;
const cached = await this.redis.get(cacheKey); const cached = await this.redis.get(cacheKey);
if (cached) { if (cached) return JSON.parse(cached) as M365Email[];
return JSON.parse(cached) as M365Email[];
}
const accessToken = await this.getM365Token(userJwt); const accessToken = await this.getM365Token(userJwt);
// Hinweis: $search und $orderby können NICHT kombiniert werden (Graph API Limitation)
const data = await this.graphGet<{ value: M365Email[] }>( const data = await this.graphGet<{ value: M365Email[] }>(
accessToken, accessToken,
'/me/messages', '/me/messages',
{ {
$search: `"${contactEmail}"`, $search: `"${contactEmail}"`,
$top: '25', $top: '25',
$orderby: 'receivedDateTime desc',
$select: $select:
'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink', 'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink',
}, },
@ -174,18 +178,10 @@ export class GraphService {
const emails = data.value ?? []; const emails = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL); await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL);
this.logger.debug( this.logger.debug(`Graph: ${emails.length} E-Mails fuer ${contactEmail} geladen`);
`Graph: ${emails.length} E-Mails fuer ${contactEmail} geladen`,
);
return emails; return emails;
} }
// ── Kalender-Ereignisse ───────────────────────────────────────────────
/**
* Kalender-Ereignisse mit dem Kontakt aus MS Graph laden.
* Filtert Ereignisse in denen die Kontakt-E-Mail als Teilnehmer vorkommt.
*/
async getContactCalendar( async getContactCalendar(
userJwt: string, userJwt: string,
userId: string, userId: string,
@ -193,17 +189,12 @@ export class GraphService {
): Promise<M365CalendarEvent[]> { ): Promise<M365CalendarEvent[]> {
const cacheKey = `graph:calendar:${userId}:${contactEmail}`; const cacheKey = `graph:calendar:${userId}:${contactEmail}`;
const cached = await this.redis.get(cacheKey); const cached = await this.redis.get(cacheKey);
if (cached) { if (cached) return JSON.parse(cached) as M365CalendarEvent[];
return JSON.parse(cached) as M365CalendarEvent[];
}
const accessToken = await this.getM365Token(userJwt); const accessToken = await this.getM365Token(userJwt);
// Naechste 3 Monate
const now = new Date().toISOString(); const now = new Date().toISOString();
const future = new Date( const future = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
Date.now() + 90 * 24 * 60 * 60 * 1000,
).toISOString();
const data = await this.graphGet<{ value: M365CalendarEvent[] }>( const data = await this.graphGet<{ value: M365CalendarEvent[] }>(
accessToken, accessToken,
@ -214,7 +205,7 @@ export class GraphService {
$top: '20', $top: '20',
$filter: `attendees/any(a:a/emailAddress/address eq '${contactEmail}')`, $filter: `attendees/any(a:a/emailAddress/address eq '${contactEmail}')`,
$select: $select:
'id,subject,start,end,location,organizer,isOnlineMeeting,onlineMeetingUrl,webLink', 'id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeetingUrl,webLink',
$orderby: 'start/dateTime asc', $orderby: 'start/dateTime asc',
}, },
); );
@ -224,25 +215,102 @@ export class GraphService {
return events; return events;
} }
// ── Aufgaben (Tasks) ────────────────────────────────────────────────── // ── Globale Office365-Übersicht ───────────────────────────────────────
/** /** Alle aktuellen E-Mails (Posteingang) */
* Microsoft To Do Aufgaben laden. async getAllEmails(userJwt: string, userId: string): Promise<M365Email[]> {
* Gibt alle Task-Listen mit ihren Aufgaben zurueck. const cacheKey = `graph:all-emails:${userId}`;
*/
async getTasks(
userJwt: string,
userId: string,
): Promise<M365TaskList[]> {
const cacheKey = `graph:tasks:${userId}`;
const cached = await this.redis.get(cacheKey); const cached = await this.redis.get(cacheKey);
if (cached) { if (cached) return JSON.parse(cached) as M365Email[];
return JSON.parse(cached) as M365TaskList[];
} const accessToken = await this.getM365Token(userJwt);
const data = await this.graphGet<{ value: M365Email[] }>(
accessToken,
'/me/messages',
{
$top: '50',
$orderby: 'receivedDateTime desc',
$select:
'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink',
},
);
const emails = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL);
this.logger.debug(`Graph: ${emails.length} E-Mails (global) geladen`);
return emails;
}
/** Alle Kalender-Ereignisse (nächste 30 Tage) */
async getAllCalendarEvents(
userJwt: string,
userId: string,
): Promise<M365CalendarEvent[]> {
const cacheKey = `graph:all-calendar:${userId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365CalendarEvent[];
const accessToken = await this.getM365Token(userJwt);
const now = new Date().toISOString();
const future = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString();
const data = await this.graphGet<{ value: M365CalendarEvent[] }>(
accessToken,
'/me/calendarView',
{
startDateTime: now,
endDateTime: future,
$top: '50',
$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 (global) geladen`);
return events;
}
/** Outlook-Kontakte des Benutzers */
async getAllOutlookContacts(
userJwt: string,
userId: string,
): Promise<M365Contact[]> {
const cacheKey = `graph:all-contacts:${userId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365Contact[];
const accessToken = await this.getM365Token(userJwt);
const data = await this.graphGet<{ value: M365Contact[] }>(
accessToken,
'/me/contacts',
{
$top: '100',
$orderby: 'displayName asc',
$select:
'id,displayName,emailAddresses,mobilePhone,businessPhones,jobTitle,companyName',
},
);
const contacts = data.value ?? [];
await this.redis.set(cacheKey, JSON.stringify(contacts), CACHE_TTL);
this.logger.debug(`Graph: ${contacts.length} Outlook-Kontakte geladen`);
return contacts;
}
/** Alle Task-Listen mit Aufgaben */
async getTasks(userJwt: string, userId: string): Promise<M365TaskList[]> {
const cacheKey = `graph:tasks:${userId}`;
const cached = await this.redis.get(cacheKey);
if (cached) return JSON.parse(cached) as M365TaskList[];
const accessToken = await this.getM365Token(userJwt); const accessToken = await this.getM365Token(userJwt);
// Listen laden
const listsData = await this.graphGet<{ const listsData = await this.graphGet<{
value: Array<{ id: string; displayName: string }>; value: Array<{ id: string; displayName: string }>;
}>(accessToken, '/me/todo/lists', { $top: '20' }); }>(accessToken, '/me/todo/lists', { $top: '20' });

View file

@ -0,0 +1,60 @@
import { Controller, Get, Req, Logger } from '@nestjs/common';
import { Request } from 'express';
import { GraphService } from './graph.service';
interface JwtUser {
sub: string;
email: string;
tenantId: string;
}
/**
* Office365Controller Globale Microsoft 365 Übersicht
*
* Zeigt alle M365-Daten des eingeloggten Users (ohne Kontakt-Filter).
*
* Routen:
* GET /crm/office365/emails Alle E-Mails (Posteingang)
* GET /crm/office365/calendar Alle Kalendertermine (nächste 30 Tage)
* GET /crm/office365/contacts Alle Outlook-Kontakte
* GET /crm/office365/tasks Alle Aufgaben (Microsoft To Do)
*/
@Controller('office365')
export class Office365Controller {
private readonly logger = new Logger(Office365Controller.name);
constructor(private readonly graphService: GraphService) {}
@Get('emails')
async getEmails(@Req() req: Request & { user: JwtUser }) {
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
const emails = await this.graphService.getAllEmails(jwt, req.user.sub);
return { success: true, data: emails, meta: { count: emails.length } };
}
@Get('calendar')
async getCalendar(@Req() req: Request & { user: JwtUser }) {
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
const events = await this.graphService.getAllCalendarEvents(jwt, req.user.sub);
return { success: true, data: events, meta: { count: events.length } };
}
@Get('contacts')
async getContacts(@Req() req: Request & { user: JwtUser }) {
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
const contacts = await this.graphService.getAllOutlookContacts(jwt, req.user.sub);
return { success: true, data: contacts, meta: { count: contacts.length } };
}
@Get('tasks')
async getTasks(@Req() req: Request & { user: JwtUser }) {
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
const taskLists = await this.graphService.getTasks(jwt, req.user.sub);
const totalTasks = taskLists.reduce((sum, l) => sum + l.tasks.length, 0);
return {
success: true,
data: taskLists,
meta: { listCount: taskLists.length, taskCount: totalTasks },
};
}
}

View file

@ -73,6 +73,7 @@ import type {
M365Email, M365Email,
M365CalendarEvent, M365CalendarEvent,
M365TaskList, M365TaskList,
M365Contact,
} from './types'; } from './types';
// --- Contacts --- // --- Contacts ---
@ -806,3 +807,35 @@ export const graphApi = {
) )
.then((r) => r.data), .then((r) => r.data),
}; };
// --- Office365 Übersicht (globale Daten) ---
export const office365Api = {
getEmails: () =>
api
.get<{ success: boolean; data: M365Email[]; meta: { count: number } }>(
'/crm/office365/emails',
)
.then((r) => r.data),
getCalendar: () =>
api
.get<{ success: boolean; data: M365CalendarEvent[]; meta: { count: number } }>(
'/crm/office365/calendar',
)
.then((r) => r.data),
getContacts: () =>
api
.get<{ success: boolean; data: M365Contact[]; meta: { count: number } }>(
'/crm/office365/contacts',
)
.then((r) => r.data),
getTasks: () =>
api
.get<{ success: boolean; data: M365TaskList[]; meta: { listCount: number; taskCount: number } }>(
'/crm/office365/tasks',
)
.then((r) => r.data),
};

View file

@ -26,6 +26,7 @@ import {
contractFilesApi, contractFilesApi,
integrationsApi, integrationsApi,
graphApi, graphApi,
office365Api,
} from './api'; } from './api';
import type { import type {
ContactsQueryParams, ContactsQueryParams,
@ -1335,3 +1336,61 @@ export function useContactTasks(contactId: string) {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
// ── Office365 Übersicht ───────────────────────────────────────────────────────
export function useOffice365Emails() {
const { data: integrationsData } = useIntegrations();
const isConnected = integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
return useQuery({
queryKey: ['office365', 'emails'],
queryFn: () => office365Api.getEmails(),
enabled: isConnected,
staleTime: 5 * 60 * 1000,
});
}
export function useOffice365Calendar() {
const { data: integrationsData } = useIntegrations();
const isConnected = integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
return useQuery({
queryKey: ['office365', 'calendar'],
queryFn: () => office365Api.getCalendar(),
enabled: isConnected,
staleTime: 5 * 60 * 1000,
});
}
export function useOffice365Contacts() {
const { data: integrationsData } = useIntegrations();
const isConnected = integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
return useQuery({
queryKey: ['office365', 'contacts'],
queryFn: () => office365Api.getContacts(),
enabled: isConnected,
staleTime: 5 * 60 * 1000,
});
}
export function useOffice365Tasks() {
const { data: integrationsData } = useIntegrations();
const isConnected = integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
return useQuery({
queryKey: ['office365', 'tasks'],
queryFn: () => office365Api.getTasks(),
enabled: isConnected,
staleTime: 5 * 60 * 1000,
});
}

View file

@ -0,0 +1,411 @@
/* ============================================================
Office365Page Microsoft 365 Übersicht
============================================================ */
.pageHeader {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 1.5rem;
}
.pageTitle {
font-size: 1.5rem;
font-weight: 600;
margin: 0;
}
.connectedBadge {
font-size: 0.8125rem;
font-weight: 500;
color: #22c55e;
}
.disconnectedBadge {
font-size: 0.8125rem;
font-weight: 500;
color: var(--color-text-muted);
}
/* ---- Connect Prompt ---- */
.connectPrompt {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 2rem;
text-align: center;
color: var(--color-text-muted);
}
.connectButton {
margin-top: 1rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1.25rem;
background: var(--color-primary);
color: white;
border: none;
border-radius: var(--radius-sm);
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
}
.connectButton:hover { opacity: 0.9; }
/* ---- Tabs ---- */
.tabBar {
display: flex;
gap: 0;
border-bottom: 2px solid var(--color-border);
margin-bottom: 1.5rem;
}
.tab {
padding: 0.625rem 1.25rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
margin-bottom: -2px;
font-size: 0.9375rem;
font-weight: 500;
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s;
}
.tab:hover { color: var(--color-text); }
.activeTab {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
}
.tabContent {
min-height: 200px;
}
/* ---- States ---- */
.loadingText, .emptyText {
color: var(--color-text-muted);
font-size: 0.9375rem;
padding: 1.5rem 0;
}
.errorText {
color: #ef4444;
font-size: 0.9375rem;
padding: 1.5rem 0;
}
/* ---- Shared list ---- */
.list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
/* ---- Email Cards ---- */
.emailCard {
display: block;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.875rem 1rem;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.emailCard:hover {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08);
}
.emailHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 0.25rem;
}
.emailSubject {
font-size: 0.9375rem;
font-weight: 400;
color: var(--color-text);
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.unread {
font-weight: 600;
}
.emailDate {
font-size: 0.75rem;
color: var(--color-text-muted);
flex-shrink: 0;
}
.emailMeta {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.emailFrom {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.attachmentBadge {
font-size: 0.75rem;
}
.emailPreview {
font-size: 0.8125rem;
color: var(--color-text-muted);
margin: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* ---- Calendar Cards ---- */
.calendarCard {
display: flex;
align-items: center;
gap: 1rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.875rem 1rem;
text-decoration: none;
color: inherit;
transition: border-color 0.15s, box-shadow 0.15s;
}
.calendarCard:hover {
border-color: var(--color-primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.08);
}
.calendarDate {
display: flex;
flex-direction: column;
align-items: center;
min-width: 80px;
text-align: center;
flex-shrink: 0;
}
.calendarDay {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-primary);
}
.calendarTime {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.calendarContent {
display: flex;
flex-direction: column;
gap: 0.125rem;
flex: 1;
min-width: 0;
}
.calendarSubject {
font-size: 0.9375rem;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.calendarLocation, .calendarAttendees {
font-size: 0.8125rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.onlineBadge {
font-size: 0.75rem;
font-weight: 500;
color: #22c55e;
background: rgba(34, 197, 94, 0.1);
padding: 0.125rem 0.5rem;
border-radius: 999px;
flex-shrink: 0;
}
/* ---- Contacts Grid ---- */
.searchInput {
width: 100%;
max-width: 320px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
font-size: 0.875rem;
background: var(--color-bg);
color: var(--color-text);
margin-bottom: 1rem;
}
.contactGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 0.75rem;
}
.contactCard {
display: flex;
align-items: flex-start;
gap: 0.75rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.875rem;
position: relative;
}
.contactAvatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--color-primary);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: 700;
flex-shrink: 0;
}
.contactInfo {
display: flex;
flex-direction: column;
gap: 0.125rem;
flex: 1;
min-width: 0;
}
.contactName {
font-size: 0.9375rem;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contactMeta {
font-size: 0.8125rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contactEmail {
font-size: 0.8125rem;
color: var(--color-primary);
text-decoration: none;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.contactEmail:hover { text-decoration: underline; }
.importButton {
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.25rem 0.5rem;
font-size: 0.6875rem;
font-weight: 600;
color: var(--color-text-muted);
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
align-self: flex-start;
}
.importButton:hover {
background: var(--color-primary);
color: white;
border-color: var(--color-primary);
}
/* ---- Task Cards ---- */
.taskCard {
display: flex;
align-items: flex-start;
gap: 0.75rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
padding: 0.75rem 1rem;
}
.taskImportance {
font-size: 1rem;
font-weight: 700;
min-width: 1rem;
text-align: center;
}
.taskContent {
display: flex;
flex-direction: column;
gap: 0.125rem;
flex: 1;
}
.taskTitle {
font-size: 0.9375rem;
font-weight: 500;
}
.taskMeta {
font-size: 0.8125rem;
color: var(--color-text-muted);
}
.taskStatus {
font-size: 0.75rem;
font-weight: 500;
padding: 0.125rem 0.5rem;
border-radius: 999px;
background: var(--color-bg);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
flex-shrink: 0;
white-space: nowrap;
}
.status_inProgress {
background: rgba(59, 130, 246, 0.1);
color: var(--color-primary);
border-color: var(--color-primary);
}

View file

@ -0,0 +1,322 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import {
useIntegrations,
useOffice365Emails,
useOffice365Calendar,
useOffice365Contacts,
useOffice365Tasks,
} from '../hooks';
import { integrationsApi } from '../api';
import type { M365Email, M365CalendarEvent, M365Contact, M365TaskList } from '../types';
import styles from './Office365Page.module.css';
// ── Helpers ──────────────────────────────────────────────────────────────────
function formatDate(iso: string) {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
function formatDateOnly(iso: string) {
return new Date(iso).toLocaleDateString('de-DE', {
weekday: 'short',
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
type Tab = 'emails' | 'calendar' | 'contacts' | 'tasks';
const TAB_LABELS: { id: Tab; label: string }[] = [
{ id: 'emails', label: 'E-Mails' },
{ id: 'calendar', label: 'Kalender' },
{ id: 'contacts', label: 'Kontakte' },
{ id: 'tasks', label: 'Aufgaben' },
];
// ── E-Mails ───────────────────────────────────────────────────────────────────
function EmailsList() {
const { data, isLoading, error } = useOffice365Emails();
const emails: M365Email[] = data?.data ?? [];
if (isLoading) return <p className={styles.loadingText}>E-Mails werden geladen</p>;
if (error) return <p className={styles.errorText}>E-Mails konnten nicht geladen werden.</p>;
if (!emails.length) return <p className={styles.emptyText}>Keine E-Mails gefunden.</p>;
return (
<div className={styles.list}>
{emails.map((email) => (
<a
key={email.id}
href={email.webLink}
target="_blank"
rel="noopener noreferrer"
className={styles.emailCard}
>
<div className={styles.emailHeader}>
<span className={`${styles.emailSubject} ${!email.isRead ? styles.unread : ''}`}>
{email.subject || '(Kein Betreff)'}
</span>
<span className={styles.emailDate}>
{formatDate(email.receivedDateTime)}
</span>
</div>
<div className={styles.emailMeta}>
<span className={styles.emailFrom}>
{email.from?.emailAddress?.name || email.from?.emailAddress?.address || '—'}
</span>
{email.hasAttachments && (
<span className={styles.attachmentBadge}>📎</span>
)}
</div>
{email.bodyPreview && (
<p className={styles.emailPreview}>{email.bodyPreview}</p>
)}
</a>
))}
</div>
);
}
// ── Kalender ──────────────────────────────────────────────────────────────────
function CalendarList() {
const { data, isLoading, error } = useOffice365Calendar();
const events: M365CalendarEvent[] = data?.data ?? [];
if (isLoading) return <p className={styles.loadingText}>Termine werden geladen</p>;
if (error) return <p className={styles.errorText}>Kalendertermine konnten nicht geladen werden.</p>;
if (!events.length) return <p className={styles.emptyText}>Keine Termine in den nächsten 30 Tagen.</p>;
return (
<div className={styles.list}>
{events.map((event) => (
<a
key={event.id}
href={event.webLink}
target="_blank"
rel="noopener noreferrer"
className={styles.calendarCard}
>
<div className={styles.calendarDate}>
<span className={styles.calendarDay}>
{new Date(event.start.dateTime).toLocaleDateString('de-DE', { weekday: 'short', day: '2-digit', month: 'short' })}
</span>
<span className={styles.calendarTime}>
{new Date(event.start.dateTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
{' '}
{new Date(event.end.dateTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
<div className={styles.calendarContent}>
<span className={styles.calendarSubject}>{event.subject}</span>
{event.location?.displayName && (
<span className={styles.calendarLocation}>📍 {event.location.displayName}</span>
)}
{event.attendees && event.attendees.length > 0 && (
<span className={styles.calendarAttendees}>
{event.attendees.slice(0, 3).map((a) => a.emailAddress.name || a.emailAddress.address).join(', ')}
{event.attendees.length > 3 && ` +${event.attendees.length - 3}`}
</span>
)}
</div>
{event.isOnlineMeeting && (
<span className={styles.onlineBadge}>Online</span>
)}
</a>
))}
</div>
);
}
// ── Outlook-Kontakte ──────────────────────────────────────────────────────────
function ContactsList() {
const navigate = useNavigate();
const { data, isLoading, error } = useOffice365Contacts();
const contacts: M365Contact[] = data?.data ?? [];
const [search, setSearch] = useState('');
if (isLoading) return <p className={styles.loadingText}>Kontakte werden geladen</p>;
if (error) return <p className={styles.errorText}>Kontakte konnten nicht geladen werden.</p>;
const filtered = contacts.filter((c) => {
const q = search.toLowerCase();
return (
c.displayName.toLowerCase().includes(q) ||
c.emailAddresses.some((e) => e.address?.toLowerCase().includes(q)) ||
c.companyName?.toLowerCase().includes(q)
);
});
return (
<div>
<input
type="search"
placeholder="Kontakte durchsuchen…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className={styles.searchInput}
/>
{!filtered.length ? (
<p className={styles.emptyText}>Keine Kontakte gefunden.</p>
) : (
<div className={styles.contactGrid}>
{filtered.map((contact) => {
const primaryEmail = contact.emailAddresses[0]?.address;
return (
<div key={contact.id} className={styles.contactCard}>
<div className={styles.contactAvatar}>
{contact.displayName.charAt(0).toUpperCase()}
</div>
<div className={styles.contactInfo}>
<span className={styles.contactName}>{contact.displayName}</span>
{contact.jobTitle && (
<span className={styles.contactMeta}>{contact.jobTitle}</span>
)}
{contact.companyName && (
<span className={styles.contactMeta}>{contact.companyName}</span>
)}
{primaryEmail && (
<a href={`mailto:${primaryEmail}`} className={styles.contactEmail}>
{primaryEmail}
</a>
)}
{contact.mobilePhone && (
<span className={styles.contactMeta}>{contact.mobilePhone}</span>
)}
</div>
{primaryEmail && (
<button
type="button"
className={styles.importButton}
title="In CRM-Kontakten suchen"
onClick={() => navigate(`/crm/contacts?search=${encodeURIComponent(primaryEmail)}`)}
>
CRM
</button>
)}
</div>
);
})}
</div>
)}
</div>
);
}
// ── Aufgaben ─────────────────────────────────────────────────────────────────
const importanceColors = { high: '#ef4444', normal: 'var(--color-text-muted)', low: '#9ca3af' };
const importanceLabel = { high: '!', normal: '', low: '' };
function TasksList() {
const { data, isLoading, error } = useOffice365Tasks();
const taskLists: M365TaskList[] = data?.data ?? [];
if (isLoading) return <p className={styles.loadingText}>Aufgaben werden geladen</p>;
if (error) return <p className={styles.errorText}>Aufgaben konnten nicht geladen werden.</p>;
const allTasks = taskLists.flatMap((l) =>
l.tasks.map((t) => ({ ...t, listName: l.displayName })),
);
if (!allTasks.length) return <p className={styles.emptyText}>Keine offenen Aufgaben.</p>;
return (
<div className={styles.list}>
{allTasks.map((task) => (
<div key={task.id} className={styles.taskCard}>
<div
className={styles.taskImportance}
style={{ color: importanceColors[task.importance as keyof typeof importanceColors] ?? 'var(--color-text-muted)' }}
>
{importanceLabel[task.importance as keyof typeof importanceLabel] ?? ''}
</div>
<div className={styles.taskContent}>
<span className={styles.taskTitle}>{task.title}</span>
<span className={styles.taskMeta}>
{task.listName}
{task.dueDateTime && (
<> · Fällig {formatDateOnly(task.dueDateTime.dateTime)}</>
)}
</span>
</div>
<span className={`${styles.taskStatus} ${styles[`status_${task.status}`] ?? ''}`}>
{task.status === 'notStarted' ? 'Offen' : task.status === 'inProgress' ? 'In Arbeit' : task.status}
</span>
</div>
))}
</div>
);
}
// ── Main Page ─────────────────────────────────────────────────────────────────
export function Office365Page() {
const [activeTab, setActiveTab] = useState<Tab>('emails');
const { data: integrationsData, isLoading: integrationsLoading } = useIntegrations();
const isConnected = integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
return (
<div>
<div className={styles.pageHeader}>
<h1 className={styles.pageTitle}>Microsoft Office 365</h1>
{!integrationsLoading && (
<span className={isConnected ? styles.connectedBadge : styles.disconnectedBadge}>
{isConnected ? '● Verbunden' : '○ Nicht verbunden'}
</span>
)}
</div>
{!integrationsLoading && !isConnected && (
<div className={styles.connectPrompt}>
<p>Verbinden Sie Ihr Microsoft 365-Konto, um E-Mails, Kalender, Kontakte und Aufgaben zu sehen.</p>
<button
type="button"
className={styles.connectButton}
onClick={() => integrationsApi.connectM365()}
>
Microsoft 365 verbinden
</button>
</div>
)}
{isConnected && (
<>
{/* Tab-Navigation */}
<div className={styles.tabBar}>
{TAB_LABELS.map((tab) => (
<button
key={tab.id}
type="button"
className={`${styles.tab} ${activeTab === tab.id ? styles.activeTab : ''}`}
onClick={() => setActiveTab(tab.id)}
>
{tab.label}
</button>
))}
</div>
{/* Tab-Inhalt */}
<div className={styles.tabContent}>
{activeTab === 'emails' && <EmailsList />}
{activeTab === 'calendar' && <CalendarList />}
{activeTab === 'contacts' && <ContactsList />}
{activeTab === 'tasks' && <TasksList />}
</div>
</>
)}
</div>
);
}

View file

@ -970,6 +970,7 @@ export interface M365Email {
receivedDateTime: string; receivedDateTime: string;
from: { emailAddress: M365EmailAddress } | null; from: { emailAddress: M365EmailAddress } | null;
toRecipients: Array<{ emailAddress: M365EmailAddress }>; toRecipients: Array<{ emailAddress: M365EmailAddress }>;
hasAttachments: boolean;
isRead: boolean; isRead: boolean;
webLink: string; webLink: string;
} }
@ -981,6 +982,7 @@ export interface M365CalendarEvent {
end: { dateTime: string; timeZone: string }; end: { dateTime: string; timeZone: string };
location?: { displayName?: string }; location?: { displayName?: string };
organizer?: { emailAddress: M365EmailAddress }; organizer?: { emailAddress: M365EmailAddress };
attendees?: Array<{ emailAddress: { name: string; address: string }; type: string }>;
isOnlineMeeting: boolean; isOnlineMeeting: boolean;
onlineMeetingUrl?: string; onlineMeetingUrl?: string;
webLink: string; webLink: string;
@ -1001,3 +1003,13 @@ export interface M365TaskList {
displayName: string; displayName: string;
tasks: M365Task[]; tasks: M365Task[];
} }
export interface M365Contact {
id: string;
displayName: string;
emailAddresses: Array<{ name?: string; address?: string }>;
mobilePhone: string | null;
businessPhones: string[];
jobTitle: string | null;
companyName: string | null;
}

View file

@ -25,6 +25,7 @@ import { CrmSettingsPage } from '../crm/settings/CrmSettingsPage';
import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage'; import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage';
import { ForecastPage } from '../crm/forecast/ForecastPage'; import { ForecastPage } from '../crm/forecast/ForecastPage';
import { KanbanPage } from '../crm/deals/KanbanPage'; import { KanbanPage } from '../crm/deals/KanbanPage';
import { Office365Page } from '../crm/office365/Office365Page';
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isLoading } = useAuth(); const { isAuthenticated, isLoading } = useAuth();
@ -74,6 +75,7 @@ export function App() {
<Route path="crm/pipelines" element={<CrmModuleGuard module="pipelines"><PipelinesPage /></CrmModuleGuard>} /> <Route path="crm/pipelines" element={<CrmModuleGuard module="pipelines"><PipelinesPage /></CrmModuleGuard>} />
<Route path="crm/forecast" element={<CrmModuleGuard module="deals"><ForecastPage /></CrmModuleGuard>} /> <Route path="crm/forecast" element={<CrmModuleGuard module="deals"><ForecastPage /></CrmModuleGuard>} />
<Route path="crm/kanban" element={<CrmModuleGuard module="deals"><KanbanPage /></CrmModuleGuard>} /> <Route path="crm/kanban" element={<CrmModuleGuard module="deals"><KanbanPage /></CrmModuleGuard>} />
<Route path="crm/office365" element={<Office365Page />} />
<Route path="crm/import" element={<Navigate to="/crm/settings" replace />} /> <Route path="crm/import" element={<Navigate to="/crm/settings" replace />} />
<Route path="crm/settings" element={<CrmSettingsPage />} /> <Route path="crm/settings" element={<CrmSettingsPage />} />
<Route path="crm/lexware-sync" element={<LexwareSyncPage />} /> <Route path="crm/lexware-sync" element={<LexwareSyncPage />} />

View file

@ -416,6 +416,30 @@ export function AppLayout() {
{!collapsed && 'Kanban'} {!collapsed && 'Kanban'}
</NavLink> </NavLink>
)} )}
<NavLink
to="/crm/office365"
className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}`
}
title="Office 365"
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="1" y="1" width="6.5" height="6.5" rx="1" />
<rect x="8.5" y="1" width="6.5" height="6.5" rx="1" />
<rect x="1" y="8.5" width="6.5" height="6.5" rx="1" />
<rect x="8.5" y="8.5" width="6.5" height="6.5" rx="1" />
</svg>
{!collapsed && 'Office 365'}
</NavLink>
{/* CRM Einstellungen (nur Admins) */} {/* CRM Einstellungen (nur Admins) */}
{isAdmin && ( {isAdmin && (
<NavLink <NavLink