From 30c4b208d9d8306274248533d429fff8d02562a2 Mon Sep 17 00:00:00 2001 From: Thomas Reitz Date: Thu, 12 Mar 2026 22:48:43 +0100 Subject: [PATCH] =?UTF-8?q?feat(crm):=20Phase=203=20MS365=20Frontend=20?= =?UTF-8?q?=E2=80=94=20Integrationen=20+=20Kontakt-Tabs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neuer "Integrationen"-Tab in ProfilePage mit "Microsoft 365 verbinden"- Button, OAuth-Callback-Handling (?integration=...&status=...), Trennen-Button - EmailsTab, CalendarTab, TasksTab fuer ContactDetailPage (via MS Graph Proxy) - useIntegrations, useDisconnectM365, useContactEmails/Calendar/Tasks Hooks - integrationsApi + graphApi in crm/api.ts - M365Email, M365CalendarEvent, M365Task, UserIntegration Types in crm/types.ts - Tabs nur sichtbar wenn Kontakt eine E-Mail-Adresse hat; ohne Verbindung Connect-Button Co-Authored-By: Claude Sonnet 4.6 --- Summarize.md | 65 +++++++ packages/frontend/src/crm/api.ts | 50 +++++ .../frontend/src/crm/contacts/CalendarTab.tsx | 116 ++++++++++++ .../src/crm/contacts/ContactDetailPage.tsx | 47 +++++ .../frontend/src/crm/contacts/EmailsTab.tsx | 120 ++++++++++++ .../frontend/src/crm/contacts/TasksTab.tsx | 178 ++++++++++++++++++ packages/frontend/src/crm/hooks.ts | 66 +++++++ packages/frontend/src/crm/types.ts | 56 ++++++ packages/frontend/src/profile/ProfilePage.tsx | 119 +++++++++++- 9 files changed, 815 insertions(+), 2 deletions(-) create mode 100644 packages/frontend/src/crm/contacts/CalendarTab.tsx create mode 100644 packages/frontend/src/crm/contacts/EmailsTab.tsx create mode 100644 packages/frontend/src/crm/contacts/TasksTab.tsx diff --git a/Summarize.md b/Summarize.md index 88db900..0eebd09 100644 --- a/Summarize.md +++ b/Summarize.md @@ -2,6 +2,71 @@ ## Stand: 2026-03-12 +### Aktueller Sprint: CRM Phase 3 — Kanban-Board + Microsoft 365 OAuth-Integration (Feature-Branch: feature/crm-service) + +--- + +### Aenderungen 2026-03-12: Microsoft 365 OAuth-Integration — Frontend + +#### Frontend: MS365 Integration-Tab + Kontakt-Tabs + +- `crm/types.ts` — Neue Interfaces: `UserIntegration`, `M365Email`, `M365EmailAddress`, `M365CalendarEvent`, `M365Task`, `M365TaskList` +- `crm/api.ts` — `integrationsApi` (list, disconnectM365, getM365ConnectUrl) + `graphApi` (getContactEmails, getContactCalendar, getContactTasks) +- `crm/hooks.ts` — `useIntegrations`, `useDisconnectM365`, `useContactEmails`, `useContactCalendar`, `useContactTasks` +- `profile/ProfilePage.tsx`: + - Neuer Tab "Integrationen" (Typ: `ProfileTab = 'personal' | 'expert' | 'password' | 'integrations'`) + - Oeffnet automatisch wenn `?integration=microsoft-365` URL-Param gesetzt ist + - Zeigt Erfolgs-/Fehlermeldung aus `?status=success|error` Param + - "Microsoft 365 verbinden" Button (Link zu `/api/v1/auth/integrations/microsoft-365`) + - Verbunden-Ansicht: Tenant-ID, Token-Ablauf, "Verbindung trennen" Button +- `crm/contacts/EmailsTab.tsx` — E-Mail-Liste aus MS Graph, ungelesen fett + blauer Rand, Link zu Outlook Web +- `crm/contacts/CalendarTab.tsx` — Kalendertermine (naechste 90 Tage), Online-Meeting-Badge, Link zu Outlook Web +- `crm/contacts/TasksTab.tsx` — Microsoft To Do Aufgaben, gruppiert nach Listen, Status-/Prioritaets-Badges +- `crm/contacts/ContactDetailPage.tsx`: + - Neuer "Microsoft 365" Abschnitt (Card) am Seitenende (nur wenn Kontakt E-Mail hat) + - Drei Tabs: E-Mails / Kalender / Aufgaben + - Ohne MS365-Verbindung: "Verbinden"-Button direkt im Tab +- TypeScript `npx tsc --noEmit`: 0 Fehler + +--- + +### Aenderungen 2026-03-12: Microsoft 365 OAuth-Integration — CRM-Service (GraphModule) + +- `crm-service/src/graph/graph.service.ts` — Token von Core-Service holen (JWT-Forwarding), Graph-Calls (Emails/Kalender/Tasks), Redis-Cache 5 Min +- `crm-service/src/graph/graph.controller.ts` — GET /crm/contacts/:id/emails|calendar|tasks +- `crm-service/src/graph/graph.module.ts` — Modul-Definition +- `crm-service/src/app.module.ts` — GraphModule registriert +- `crm-service/src/config/env.validation.ts` — `CORE_SERVICE_URL` ergaenzt +- `docker-compose.crm.yml` — `CORE_SERVICE_URL=http://core:3000` hinzugefuegt + +--- + +### Aenderungen 2026-03-12: Microsoft 365 OAuth-Integration — Core-Service + +- `core-service/prisma/core.schema.prisma` — `UserIntegration` Modell + Relation auf `User` +- `core-service/prisma/migrations/20260312_user_integrations/migration.sql` — Migration fuer `user_integrations` Tabelle +- `core-service/src/config/env.validation.ts` — `AZURE_INTEGRATION_REDIRECT_URI`, `INTEGRATION_ENCRYPTION_KEY` +- `core-service/src/core/auth/sso/entra-id.service.ts` — `getIntegrationAuthUrl`, `handleIntegrationCallback`, `refreshIntegrationToken` +- `core-service/src/core/integrations/integrations.service.ts` — AES-256-GCM Token-Verschluesselung, Token-CRUD, Auto-Refresh +- `core-service/src/core/integrations/integrations.controller.ts` — OAuth-Flow + Token-Endpoints +- `core-service/src/core/integrations/integrations.module.ts` +- `core-service/src/app.module.ts` — IntegrationsModule registriert + +--- + +### Aenderungen 2026-03-12: Kanban-Board (Frontend) + +- `frontend/src/crm/deals/KanbanPage.tsx` — Drag-&-Drop Kanban-Board (@dnd-kit) + - Pipeline-Selektor + Toggle "Abgeschlossene anzeigen" + - DealCard (useDraggable), KanbanColumn (useDroppable), DragOverlay + - Optimistisches Update via `localStageMap`; Rollback bei Fehler +- `frontend/src/crm/deals/KanbanPage.module.css` — Styles fuer Board, Spalten, Cards +- `frontend/src/shell/App.tsx` — Route `/crm/kanban` +- `frontend/src/shell/AppLayout.tsx` — NavLink "Kanban" im CRM-Bereich +- `frontend/package.json` — `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` + +--- + ### Aktueller Sprint: CRM Phase 2 / Vertraege-Modul (Feature-Branch: feature/crm-service) --- diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index 42f54e7..96e4ba1 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -69,6 +69,10 @@ import type { ContractsQueryParams, PaginatedResponse, SingleResponse, + UserIntegration, + M365Email, + M365CalendarEvent, + M365TaskList, } from './types'; // --- Contacts --- @@ -749,3 +753,49 @@ export const contractFilesApi = { ) .then((r) => r.data), }; + +// --- Microsoft 365 Integrations --- + +export const integrationsApi = { + list: () => + api + .get<{ success: boolean; data: UserIntegration[]; meta: { timestamp: string } }>( + '/users/me/integrations', + ) + .then((r) => r.data), + + disconnectM365: () => + api + .delete<{ success: boolean; meta: { timestamp: string } }>( + '/users/me/integrations/microsoft-365', + ) + .then((r) => r.data), + + /** Gibt die URL zurück, zu der der Browser weitergeleitet werden soll */ + getM365ConnectUrl: (): string => '/api/v1/auth/integrations/microsoft-365', +}; + +// --- Microsoft Graph Proxy (CRM) --- + +export const graphApi = { + getContactEmails: (contactId: string) => + api + .get<{ success: boolean; data: M365Email[]; meta: { timestamp: string } }>( + `/crm/contacts/${contactId}/emails`, + ) + .then((r) => r.data), + + getContactCalendar: (contactId: string) => + api + .get<{ success: boolean; data: M365CalendarEvent[]; meta: { timestamp: string } }>( + `/crm/contacts/${contactId}/calendar`, + ) + .then((r) => r.data), + + getContactTasks: (contactId: string) => + api + .get<{ success: boolean; data: M365TaskList[]; meta: { timestamp: string } }>( + `/crm/contacts/${contactId}/tasks`, + ) + .then((r) => r.data), +}; diff --git a/packages/frontend/src/crm/contacts/CalendarTab.tsx b/packages/frontend/src/crm/contacts/CalendarTab.tsx new file mode 100644 index 0000000..1a7e0d2 --- /dev/null +++ b/packages/frontend/src/crm/contacts/CalendarTab.tsx @@ -0,0 +1,116 @@ +import { useContactCalendar, useIntegrations } from '../hooks'; +import { integrationsApi } from '../api'; +import type { M365CalendarEvent } from '../types'; + +interface Props { + contactId: string; +} + +function formatEventDate(dt: string): string { + return new Date(dt).toLocaleString('de-DE', { + weekday: 'short', + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export function CalendarTab({ contactId }: Props) { + const { data: integrationsData } = useIntegrations(); + const isConnected = integrationsData?.data?.some( + (i) => i.provider === 'MICROSOFT_365' && i.connected, + ) ?? false; + + const { data, isLoading, error } = useContactCalendar(contactId); + const events: M365CalendarEvent[] = data?.data ?? []; + + if (!isConnected) { + return ( +
+

+ Verbinden Sie Microsoft 365, um Kalendertermine zu diesem Kontakt zu sehen. +

+ + Microsoft 365 verbinden + +
+ ); + } + + if (isLoading) { + return

Laden…

; + } + + if (error) { + return

Kalendertermine konnten nicht geladen werden.

; + } + + if (events.length === 0) { + return

Keine Kalendertermine in den nächsten 90 Tagen.

; + } + + return ( +
+ {events.map((event) => ( + +
+ + {event.subject} + + {event.isOnlineMeeting && ( + + Online + + )} +
+
+ {formatEventDate(event.start.dateTime)} – {new Date(event.end.dateTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })} +
+ {event.location?.displayName && ( +
+ 📍 {event.location.displayName} +
+ )} +
+ ))} +
+ ); +} diff --git a/packages/frontend/src/crm/contacts/ContactDetailPage.tsx b/packages/frontend/src/crm/contacts/ContactDetailPage.tsx index 2affd94..fff2df5 100644 --- a/packages/frontend/src/crm/contacts/ContactDetailPage.tsx +++ b/packages/frontend/src/crm/contacts/ContactDetailPage.tsx @@ -5,10 +5,15 @@ import { ContactFormModal } from './ContactFormModal'; import { ActivityFormModal } from '../activities/ActivityFormModal'; import { Modal } from '../../components/Modal'; import { CustomFieldsDisplay } from '../CustomFieldsDisplay'; +import { EmailsTab } from './EmailsTab'; +import { CalendarTab } from './CalendarTab'; +import { TasksTab } from './TasksTab'; import type { Contact, Activity, ActivityType } from '../types'; import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types'; import styles from './ContactDetailPage.module.css'; +type M365Tab = 'emails' | 'calendar' | 'tasks'; + const ACTIVITY_TYPE_LABELS: Record = { NOTE: 'Notiz', CALL: 'Anruf', @@ -112,6 +117,7 @@ export function ContactDetailPage() { const [isEditOpen, setEditOpen] = useState(false); const [isActivityOpen, setActivityOpen] = useState(false); const [isDeleteOpen, setDeleteOpen] = useState(false); + const [m365Tab, setM365Tab] = useState('emails'); if (isLoading) return

Laden…

; if (error || !data) @@ -546,6 +552,47 @@ export function ContactDetailPage() { )} + {/* ── Microsoft 365 ── */} + {contact.email && ( +
+
+

+ Microsoft 365 +

+
+ {(['emails', 'calendar', 'tasks'] as M365Tab[]).map((tab) => { + const labels: Record = { emails: 'E-Mails', calendar: 'Kalender', tasks: 'Aufgaben' }; + return ( + + ); + })} +
+
+ + {m365Tab === 'emails' && } + {m365Tab === 'calendar' && } + {m365Tab === 'tasks' && } +
+ )} + {/* ── Modals ── */} i.provider === 'MICROSOFT_365' && i.connected, + ) ?? false; + + const { data, isLoading, error } = useContactEmails(contactId); + const emails: M365Email[] = data?.data ?? []; + + if (!isConnected) { + return ( +
+

+ Verbinden Sie Microsoft 365, um E-Mails zu diesem Kontakt zu sehen. +

+ + Microsoft 365 verbinden + +
+ ); + } + + if (isLoading) { + return

Laden…

; + } + + if (error) { + return

E-Mails konnten nicht geladen werden.

; + } + + if (emails.length === 0) { + return

Keine E-Mails gefunden.

; + } + + return ( + + ); +} diff --git a/packages/frontend/src/crm/contacts/TasksTab.tsx b/packages/frontend/src/crm/contacts/TasksTab.tsx new file mode 100644 index 0000000..8af2302 --- /dev/null +++ b/packages/frontend/src/crm/contacts/TasksTab.tsx @@ -0,0 +1,178 @@ +import { useContactTasks, useIntegrations } from '../hooks'; +import { integrationsApi } from '../api'; +import type { M365Task, M365TaskList } from '../types'; + +interface Props { + contactId: string; +} + +const TASK_STATUS_LABELS: Record = { + notStarted: 'Offen', + inProgress: 'In Bearbeitung', + completed: 'Erledigt', + waitingOnOthers: 'Wartet', + deferred: 'Zurückgestellt', +}; + +const TASK_STATUS_COLORS: Record = { + notStarted: { bg: '#f1f5f9', color: '#475569' }, + inProgress: { bg: '#dbeafe', color: '#1e40af' }, + completed: { bg: '#d1fae5', color: '#065f46' }, + waitingOnOthers: { bg: '#fef9c3', color: '#854d0e' }, + deferred: { bg: '#f1f5f9', color: '#94a3b8' }, +}; + +const IMPORTANCE_COLORS: Record = { + low: 'var(--color-text-muted)', + normal: 'var(--color-text-secondary)', + high: 'var(--color-error, #ef4444)', +}; + +function formatDueDate(dt: string): string { + return new Date(dt).toLocaleDateString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + }); +} + +function TaskItem({ task }: { task: M365Task }) { + const status = TASK_STATUS_COLORS[task.status]; + const isCompleted = task.status === 'completed'; + + return ( +
+
+ {isCompleted && ( + + + + )} +
+
+
+ {task.title} +
+
+ + {TASK_STATUS_LABELS[task.status]} + + {task.importance !== 'normal' && ( + + {task.importance === 'high' ? '↑ Hoch' : '↓ Niedrig'} + + )} + {task.dueDateTime && ( + + Fällig: {formatDueDate(task.dueDateTime.dateTime)} + + )} +
+
+
+ ); +} + +export function TasksTab({ contactId }: Props) { + const { data: integrationsData } = useIntegrations(); + const isConnected = integrationsData?.data?.some( + (i) => i.provider === 'MICROSOFT_365' && i.connected, + ) ?? false; + + const { data, isLoading, error } = useContactTasks(contactId); + const taskLists: M365TaskList[] = data?.data ?? []; + + if (!isConnected) { + return ( +
+

+ Verbinden Sie Microsoft 365, um Aufgaben zu diesem Kontakt zu sehen. +

+ + Microsoft 365 verbinden + +
+ ); + } + + if (isLoading) { + return

Laden…

; + } + + if (error) { + return

Aufgaben konnten nicht geladen werden.

; + } + + const allTasks = taskLists.flatMap((list) => list.tasks); + + if (allTasks.length === 0) { + return

Keine Aufgaben gefunden.

; + } + + return ( +
+ {taskLists.map((list) => + list.tasks.length > 0 ? ( +
+
+ {list.displayName} +
+ {list.tasks.map((task) => ( + + ))} +
+ ) : null, + )} +
+ ); +} diff --git a/packages/frontend/src/crm/hooks.ts b/packages/frontend/src/crm/hooks.ts index b78bc91..1417215 100644 --- a/packages/frontend/src/crm/hooks.ts +++ b/packages/frontend/src/crm/hooks.ts @@ -24,6 +24,8 @@ import { enrichmentApi, contractsApi, contractFilesApi, + integrationsApi, + graphApi, } from './api'; import type { ContactsQueryParams, @@ -1269,3 +1271,67 @@ export function useDeleteContractFile(companyId: string, contractId: string) { }, }); } + +// ============================================================ +// Microsoft 365 Integrations (Phase 3) +// ============================================================ + +export function useIntegrations() { + return useQuery({ + queryKey: ['integrations'], + queryFn: () => integrationsApi.list(), + staleTime: 60 * 1000, + }); +} + +export function useDisconnectM365() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: () => integrationsApi.disconnectM365(), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['integrations'] }); + }, + }); +} + +export function useContactEmails(contactId: string) { + const { data: integrationsData } = useIntegrations(); + const isConnected = integrationsData?.data?.some( + (i) => i.provider === 'MICROSOFT_365' && i.connected, + ) ?? false; + + return useQuery({ + queryKey: ['graph', 'emails', contactId], + queryFn: () => graphApi.getContactEmails(contactId), + enabled: !!contactId && isConnected, + staleTime: 5 * 60 * 1000, + }); +} + +export function useContactCalendar(contactId: string) { + const { data: integrationsData } = useIntegrations(); + const isConnected = integrationsData?.data?.some( + (i) => i.provider === 'MICROSOFT_365' && i.connected, + ) ?? false; + + return useQuery({ + queryKey: ['graph', 'calendar', contactId], + queryFn: () => graphApi.getContactCalendar(contactId), + enabled: !!contactId && isConnected, + staleTime: 5 * 60 * 1000, + }); +} + +export function useContactTasks(contactId: string) { + const { data: integrationsData } = useIntegrations(); + const isConnected = integrationsData?.data?.some( + (i) => i.provider === 'MICROSOFT_365' && i.connected, + ) ?? false; + + return useQuery({ + queryKey: ['graph', 'tasks', contactId], + queryFn: () => graphApi.getContactTasks(contactId), + enabled: !!contactId && isConnected, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/packages/frontend/src/crm/types.ts b/packages/frontend/src/crm/types.ts index 9a71b89..fb76af8 100644 --- a/packages/frontend/src/crm/types.ts +++ b/packages/frontend/src/crm/types.ts @@ -945,3 +945,59 @@ export interface EnrichmentConfig { apiKey: string; configured: boolean; } + +// ============================================================ +// Microsoft 365 Integration (Phase 3) +// ============================================================ + +export interface UserIntegration { + provider: string; + connected: boolean; + scopes: string[]; + expiresAt: string | null; + msTenantId: string | null; +} + +export interface M365EmailAddress { + name?: string; + address?: string; +} + +export interface M365Email { + id: string; + subject: string | null; + bodyPreview: string; + receivedDateTime: string; + from: { emailAddress: M365EmailAddress } | null; + toRecipients: Array<{ emailAddress: M365EmailAddress }>; + isRead: boolean; + webLink: string; +} + +export interface M365CalendarEvent { + id: string; + subject: string; + start: { dateTime: string; timeZone: string }; + end: { dateTime: string; timeZone: string }; + location?: { displayName?: string }; + organizer?: { emailAddress: M365EmailAddress }; + isOnlineMeeting: boolean; + onlineMeetingUrl?: string; + webLink: string; +} + +export interface M365Task { + id: string; + title: string; + status: 'notStarted' | 'inProgress' | 'completed' | 'waitingOnOthers' | 'deferred'; + importance: 'low' | 'normal' | 'high'; + dueDateTime?: { dateTime: string; timeZone: string } | null; + completedDateTime?: { dateTime: string; timeZone: string } | null; + createdDateTime: string; +} + +export interface M365TaskList { + id: string; + displayName: string; + tasks: M365Task[]; +} diff --git a/packages/frontend/src/profile/ProfilePage.tsx b/packages/frontend/src/profile/ProfilePage.tsx index 7817743..18fc857 100644 --- a/packages/frontend/src/profile/ProfilePage.tsx +++ b/packages/frontend/src/profile/ProfilePage.tsx @@ -1,12 +1,15 @@ import { useState, useEffect, useRef, type FormEvent, type ChangeEvent } from 'react'; +import { useLocation } from 'react-router-dom'; import { useAuth } from '../auth/AuthContext'; import api from '../api/client'; import { UserAvatar } from '../components/UserAvatar'; import { resizeImageToBase64 } from '../utils/imageUtils'; import { ExpertProfileTab } from './ExpertProfileTab'; +import { useIntegrations, useDisconnectM365 } from '../crm/hooks'; +import { integrationsApi } from '../crm/api'; import styles from './ProfilePage.module.css'; -type ProfileTab = 'personal' | 'expert' | 'password'; +type ProfileTab = 'personal' | 'expert' | 'password' | 'integrations'; export function ProfilePage() { const { user, refreshUser } = useAuth(); @@ -283,7 +286,29 @@ export function ProfilePage() { }; // --- Tab-Navigation --- - const [activeTab, setActiveTab] = useState('personal'); + const location = useLocation(); + const searchParams = new URLSearchParams(location.search); + const integrationParam = searchParams.get('integration'); + const integrationStatus = searchParams.get('status'); + + const [activeTab, setActiveTab] = useState( + integrationParam === 'microsoft-365' ? 'integrations' : 'personal', + ); + + // --- Microsoft 365 Integration --- + const { data: integrationsData, isLoading: integrationsLoading } = useIntegrations(); + const disconnectM365 = useDisconnectM365(); + const m365Integration = integrationsData?.data?.find((i) => i.provider === 'MICROSOFT_365'); + const [m365Msg, setM365Msg] = useState( + integrationParam === 'microsoft-365' && integrationStatus === 'success' + ? 'Microsoft 365 wurde erfolgreich verbunden!' + : integrationParam === 'microsoft-365' && integrationStatus === 'error' + ? 'Verbindung mit Microsoft 365 fehlgeschlagen. Bitte versuchen Sie es erneut.' + : '', + ); + const [m365IsError, setM365IsError] = useState( + integrationParam === 'microsoft-365' && integrationStatus === 'error', + ); return (
@@ -337,6 +362,13 @@ export function ProfilePage() { > Passwort ändern +
{/* === Tab: Profil === */} @@ -754,6 +786,89 @@ export function ProfilePage() { )} + + {/* === Tab: Integrationen === */} + {activeTab === 'integrations' && ( + <> +
+

Microsoft 365

+ + {m365Msg && ( +
+ {m365Msg} +
+ )} + + {integrationsLoading ? ( +

Laden…

+ ) : m365Integration?.connected ? ( +
+

+ ● Verbunden + {m365Integration.msTenantId && ( + + Tenant: {m365Integration.msTenantId} + + )} +

+ {m365Integration.expiresAt && ( +

+ Token gültig bis: {new Date(m365Integration.expiresAt).toLocaleString('de-DE')} +

+ )} + +
+ ) : ( +
+

+ Verbinden Sie Ihr Microsoft 365 Konto, um E-Mails, Kalendertermine und Aufgaben + direkt in Kontaktprofilen zu sehen. +

+ + + + + Microsoft 365 verbinden + +
+ )} +
+ + )} ); }