diff --git a/Summarize.md b/Summarize.md index 0eebd09..5382e58 100644 --- a/Summarize.md +++ b/Summarize.md @@ -1,11 +1,32 @@ # 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) --- +### 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 #### Frontend: MS365 Integration-Tab + Kontakt-Tabs diff --git a/packages/crm-service/src/graph/graph.module.ts b/packages/crm-service/src/graph/graph.module.ts index a5767a1..6f0c47d 100644 --- a/packages/crm-service/src/graph/graph.module.ts +++ b/packages/crm-service/src/graph/graph.module.ts @@ -1,12 +1,13 @@ import { Module } from '@nestjs/common'; import { GraphController } from './graph.controller'; +import { Office365Controller } from './office365.controller'; import { GraphService } from './graph.service'; import { CrmPrismaModule } from '../prisma/crm-prisma.module'; import { RedisModule } from '../redis/redis.module'; @Module({ imports: [CrmPrismaModule, RedisModule], - controllers: [GraphController], + controllers: [GraphController, Office365Controller], providers: [GraphService], }) export class GraphModule {} diff --git a/packages/crm-service/src/graph/graph.service.ts b/packages/crm-service/src/graph/graph.service.ts index 006b639..14efa62 100644 --- a/packages/crm-service/src/graph/graph.service.ts +++ b/packages/crm-service/src/graph/graph.service.ts @@ -25,6 +25,7 @@ export interface M365CalendarEvent { end: { dateTime: string; timeZone: string }; location: { displayName: string }; organizer: { emailAddress: { name: string; address: string } }; + attendees: Array<{ emailAddress: { name: string; address: string }; type: string }>; isOnlineMeeting: boolean; onlineMeetingUrl: string | null; webLink: string; @@ -46,6 +47,16 @@ export interface M365TaskList { 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 CACHE_TTL = 300; // 5 Minuten @@ -64,10 +75,6 @@ export class GraphService { // ── 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 { 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 { error?: { message?: string }; }; + this.logger.error( + `Graph API Fehler ${resp.status} auf ${path}: ${errBody.error?.message ?? resp.statusText}`, + ); throw new ServiceUnavailableException( `Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`, ); @@ -141,12 +151,8 @@ export class GraphService { return resp.json() as Promise; } - // ── 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( userJwt: string, userId: string, @@ -154,19 +160,17 @@ export class GraphService { ): Promise { const cacheKey = `graph:emails:${userId}:${contactEmail}`; const cached = await this.redis.get(cacheKey); - if (cached) { - return JSON.parse(cached) as M365Email[]; - } + if (cached) return JSON.parse(cached) as M365Email[]; 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[] }>( accessToken, '/me/messages', { $search: `"${contactEmail}"`, $top: '25', - $orderby: 'receivedDateTime desc', $select: 'id,subject,bodyPreview,receivedDateTime,from,hasAttachments,isRead,webLink', }, @@ -174,18 +178,10 @@ export class GraphService { const emails = data.value ?? []; await this.redis.set(cacheKey, JSON.stringify(emails), CACHE_TTL); - this.logger.debug( - `Graph: ${emails.length} E-Mails fuer ${contactEmail} geladen`, - ); + this.logger.debug(`Graph: ${emails.length} E-Mails fuer ${contactEmail} geladen`); 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( userJwt: string, userId: string, @@ -193,17 +189,12 @@ export class GraphService { ): Promise { const cacheKey = `graph:calendar:${userId}:${contactEmail}`; const cached = await this.redis.get(cacheKey); - if (cached) { - return JSON.parse(cached) as M365CalendarEvent[]; - } + if (cached) return JSON.parse(cached) as M365CalendarEvent[]; const accessToken = await this.getM365Token(userJwt); - // Naechste 3 Monate const now = new Date().toISOString(); - const future = new Date( - Date.now() + 90 * 24 * 60 * 60 * 1000, - ).toISOString(); + const future = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(); const data = await this.graphGet<{ value: M365CalendarEvent[] }>( accessToken, @@ -214,7 +205,7 @@ export class GraphService { $top: '20', $filter: `attendees/any(a:a/emailAddress/address eq '${contactEmail}')`, $select: - 'id,subject,start,end,location,organizer,isOnlineMeeting,onlineMeetingUrl,webLink', + 'id,subject,start,end,location,organizer,attendees,isOnlineMeeting,onlineMeetingUrl,webLink', $orderby: 'start/dateTime asc', }, ); @@ -224,25 +215,102 @@ export class GraphService { return events; } - // ── Aufgaben (Tasks) ────────────────────────────────────────────────── + // ── Globale Office365-Übersicht ─────────────────────────────────────── - /** - * Microsoft To Do Aufgaben laden. - * Gibt alle Task-Listen mit ihren Aufgaben zurueck. - */ - async getTasks( - userJwt: string, - userId: string, - ): Promise { - const cacheKey = `graph:tasks:${userId}`; + /** Alle aktuellen E-Mails (Posteingang) */ + async getAllEmails(userJwt: string, userId: string): Promise { + const cacheKey = `graph:all-emails:${userId}`; const cached = await this.redis.get(cacheKey); - if (cached) { - return JSON.parse(cached) as M365TaskList[]; - } + if (cached) return JSON.parse(cached) as M365Email[]; + + 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 { + 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 { + 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 { + 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); - // Listen laden const listsData = await this.graphGet<{ value: Array<{ id: string; displayName: string }>; }>(accessToken, '/me/todo/lists', { $top: '20' }); diff --git a/packages/crm-service/src/graph/office365.controller.ts b/packages/crm-service/src/graph/office365.controller.ts new file mode 100644 index 0000000..ca47860 --- /dev/null +++ b/packages/crm-service/src/graph/office365.controller.ts @@ -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 }, + }; + } +} diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index 2e2feb7..ad426f5 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -73,6 +73,7 @@ import type { M365Email, M365CalendarEvent, M365TaskList, + M365Contact, } from './types'; // --- Contacts --- @@ -806,3 +807,35 @@ export const graphApi = { ) .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), +}; diff --git a/packages/frontend/src/crm/hooks.ts b/packages/frontend/src/crm/hooks.ts index 1417215..fea1577 100644 --- a/packages/frontend/src/crm/hooks.ts +++ b/packages/frontend/src/crm/hooks.ts @@ -26,6 +26,7 @@ import { contractFilesApi, integrationsApi, graphApi, + office365Api, } from './api'; import type { ContactsQueryParams, @@ -1335,3 +1336,61 @@ export function useContactTasks(contactId: string) { 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, + }); +} diff --git a/packages/frontend/src/crm/office365/Office365Page.module.css b/packages/frontend/src/crm/office365/Office365Page.module.css new file mode 100644 index 0000000..e9dd703 --- /dev/null +++ b/packages/frontend/src/crm/office365/Office365Page.module.css @@ -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); +} diff --git a/packages/frontend/src/crm/office365/Office365Page.tsx b/packages/frontend/src/crm/office365/Office365Page.tsx new file mode 100644 index 0000000..932b7db --- /dev/null +++ b/packages/frontend/src/crm/office365/Office365Page.tsx @@ -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

E-Mails werden geladen…

; + if (error) return

E-Mails konnten nicht geladen werden.

; + if (!emails.length) return

Keine E-Mails gefunden.

; + + return ( + + ); +} + +// ── Kalender ────────────────────────────────────────────────────────────────── + +function CalendarList() { + const { data, isLoading, error } = useOffice365Calendar(); + const events: M365CalendarEvent[] = data?.data ?? []; + + if (isLoading) return

Termine werden geladen…

; + if (error) return

Kalendertermine konnten nicht geladen werden.

; + if (!events.length) return

Keine Termine in den nächsten 30 Tagen.

; + + return ( + + ); +} + +// ── Outlook-Kontakte ────────────────────────────────────────────────────────── + +function ContactsList() { + const navigate = useNavigate(); + const { data, isLoading, error } = useOffice365Contacts(); + const contacts: M365Contact[] = data?.data ?? []; + const [search, setSearch] = useState(''); + + if (isLoading) return

Kontakte werden geladen…

; + if (error) return

Kontakte konnten nicht geladen werden.

; + + 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 ( +
+ setSearch(e.target.value)} + className={styles.searchInput} + /> + {!filtered.length ? ( +

Keine Kontakte gefunden.

+ ) : ( +
+ {filtered.map((contact) => { + const primaryEmail = contact.emailAddresses[0]?.address; + return ( +
+
+ {contact.displayName.charAt(0).toUpperCase()} +
+
+ {contact.displayName} + {contact.jobTitle && ( + {contact.jobTitle} + )} + {contact.companyName && ( + {contact.companyName} + )} + {primaryEmail && ( + + {primaryEmail} + + )} + {contact.mobilePhone && ( + {contact.mobilePhone} + )} +
+ {primaryEmail && ( + + )} +
+ ); + })} +
+ )} +
+ ); +} + +// ── 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

Aufgaben werden geladen…

; + if (error) return

Aufgaben konnten nicht geladen werden.

; + + const allTasks = taskLists.flatMap((l) => + l.tasks.map((t) => ({ ...t, listName: l.displayName })), + ); + + if (!allTasks.length) return

Keine offenen Aufgaben.

; + + return ( +
+ {allTasks.map((task) => ( +
+
+ {importanceLabel[task.importance as keyof typeof importanceLabel] ?? ''} +
+
+ {task.title} + + {task.listName} + {task.dueDateTime && ( + <> · Fällig {formatDateOnly(task.dueDateTime.dateTime)} + )} + +
+ + {task.status === 'notStarted' ? 'Offen' : task.status === 'inProgress' ? 'In Arbeit' : task.status} + +
+ ))} +
+ ); +} + +// ── Main Page ───────────────────────────────────────────────────────────────── + +export function Office365Page() { + const [activeTab, setActiveTab] = useState('emails'); + const { data: integrationsData, isLoading: integrationsLoading } = useIntegrations(); + const isConnected = integrationsData?.data?.some( + (i) => i.provider === 'MICROSOFT_365' && i.connected, + ) ?? false; + + return ( +
+
+

Microsoft Office 365

+ {!integrationsLoading && ( + + {isConnected ? '● Verbunden' : '○ Nicht verbunden'} + + )} +
+ + {!integrationsLoading && !isConnected && ( +
+

Verbinden Sie Ihr Microsoft 365-Konto, um E-Mails, Kalender, Kontakte und Aufgaben zu sehen.

+ +
+ )} + + {isConnected && ( + <> + {/* Tab-Navigation */} +
+ {TAB_LABELS.map((tab) => ( + + ))} +
+ + {/* Tab-Inhalt */} +
+ {activeTab === 'emails' && } + {activeTab === 'calendar' && } + {activeTab === 'contacts' && } + {activeTab === 'tasks' && } +
+ + )} +
+ ); +} diff --git a/packages/frontend/src/crm/types.ts b/packages/frontend/src/crm/types.ts index fb76af8..70f8f35 100644 --- a/packages/frontend/src/crm/types.ts +++ b/packages/frontend/src/crm/types.ts @@ -970,6 +970,7 @@ export interface M365Email { receivedDateTime: string; from: { emailAddress: M365EmailAddress } | null; toRecipients: Array<{ emailAddress: M365EmailAddress }>; + hasAttachments: boolean; isRead: boolean; webLink: string; } @@ -981,6 +982,7 @@ export interface M365CalendarEvent { end: { dateTime: string; timeZone: string }; location?: { displayName?: string }; organizer?: { emailAddress: M365EmailAddress }; + attendees?: Array<{ emailAddress: { name: string; address: string }; type: string }>; isOnlineMeeting: boolean; onlineMeetingUrl?: string; webLink: string; @@ -1001,3 +1003,13 @@ export interface M365TaskList { displayName: string; 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; +} diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx index 0199f58..7908d2b 100644 --- a/packages/frontend/src/shell/App.tsx +++ b/packages/frontend/src/shell/App.tsx @@ -25,6 +25,7 @@ import { CrmSettingsPage } from '../crm/settings/CrmSettingsPage'; import { LexwareSyncPage } from '../crm/lexware/LexwareSyncPage'; import { ForecastPage } from '../crm/forecast/ForecastPage'; import { KanbanPage } from '../crm/deals/KanbanPage'; +import { Office365Page } from '../crm/office365/Office365Page'; function PrivateRoute({ children }: { children: React.ReactNode }) { const { isAuthenticated, isLoading } = useAuth(); @@ -74,6 +75,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/packages/frontend/src/shell/AppLayout.tsx b/packages/frontend/src/shell/AppLayout.tsx index 1aa0fad..4880c52 100644 --- a/packages/frontend/src/shell/AppLayout.tsx +++ b/packages/frontend/src/shell/AppLayout.tsx @@ -416,6 +416,30 @@ export function AppLayout() { {!collapsed && 'Kanban'} )} + + `${styles.navLink} ${isActive ? styles.active : ''}` + } + title="Office 365" + > + + + + + + + {!collapsed && 'Office 365'} + {/* CRM Einstellungen (nur Admins) */} {isAdmin && (