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 (
+
+ );
+ }
+
+ 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 (
+
+ );
+}
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 (
+
+ );
+ }
+
+ 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 (
+
+ );
+ }
+
+ 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')}
+
+ )}
+
+
+ ) : (
+
+ )}
+
+ >
+ )}
);
}