feat(crm): Phase 3 MS365 Frontend — Integrationen + Kontakt-Tabs

- 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 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-12 22:48:43 +01:00
parent 47b1938605
commit 30c4b208d9
9 changed files with 815 additions and 2 deletions

View file

@ -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)
---

View file

@ -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),
};

View file

@ -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 (
<div style={{ padding: '1.5rem 0', textAlign: 'center' }}>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.9375rem', marginBottom: '1rem' }}>
Verbinden Sie Microsoft 365, um Kalendertermine zu diesem Kontakt zu sehen.
</p>
<a
href={integrationsApi.getM365ConnectUrl()}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4375rem 1rem',
background: 'var(--color-primary)',
color: 'white',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
textDecoration: 'none',
}}
>
Microsoft 365 verbinden
</a>
</div>
);
}
if (isLoading) {
return <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>Laden</p>;
}
if (error) {
return <p style={{ color: 'var(--color-error)', fontSize: '0.875rem', padding: '1rem 0' }}>Kalendertermine konnten nicht geladen werden.</p>;
}
if (events.length === 0) {
return <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>Keine Kalendertermine in den nächsten 90 Tagen.</p>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{events.map((event) => (
<a
key={event.id}
href={event.webLink}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
padding: '0.75rem 1rem',
background: 'var(--color-bg-card)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
borderLeft: '3px solid var(--color-primary)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: 'var(--color-text)', flex: 1 }}>
{event.subject}
</span>
{event.isOnlineMeeting && (
<span style={{
fontSize: '0.6875rem',
fontWeight: 600,
padding: '0.125rem 0.375rem',
background: '#dbeafe',
color: '#1e40af',
borderRadius: '999px',
flexShrink: 0,
}}>
Online
</span>
)}
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: '0.25rem' }}>
{formatEventDate(event.start.dateTime)} {new Date(event.end.dateTime).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })}
</div>
{event.location?.displayName && (
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginTop: '0.125rem' }}>
📍 {event.location.displayName}
</div>
)}
</a>
))}
</div>
);
}

View file

@ -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<ActivityType, string> = {
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<M365Tab>('emails');
if (isLoading) return <p>Laden</p>;
if (error || !data)
@ -546,6 +552,47 @@ export function ContactDetailPage() {
)}
</div>
{/* ── Microsoft 365 ── */}
{contact.email && (
<div className={styles.card} style={{ marginTop: '1.5rem' }}>
<div style={{ marginBottom: '1rem' }}>
<h2 className={styles.cardTitle} style={{ margin: '0 0 0.75rem' }}>
Microsoft 365
</h2>
<div style={{ display: 'flex', gap: '0.5rem', borderBottom: '1px solid var(--color-border)', paddingBottom: '0' }}>
{(['emails', 'calendar', 'tasks'] as M365Tab[]).map((tab) => {
const labels: Record<M365Tab, string> = { emails: 'E-Mails', calendar: 'Kalender', tasks: 'Aufgaben' };
return (
<button
key={tab}
type="button"
onClick={() => setM365Tab(tab)}
style={{
padding: '0.375rem 0.75rem',
background: 'transparent',
border: 'none',
borderBottom: m365Tab === tab ? '2px solid var(--color-primary)' : '2px solid transparent',
fontSize: '0.875rem',
fontWeight: m365Tab === tab ? 600 : 400,
color: m365Tab === tab ? 'var(--color-primary)' : 'var(--color-text-secondary)',
cursor: 'pointer',
marginBottom: '-1px',
transition: 'all 0.15s',
}}
>
{labels[tab]}
</button>
);
})}
</div>
</div>
{m365Tab === 'emails' && <EmailsTab contactId={contact.id} />}
{m365Tab === 'calendar' && <CalendarTab contactId={contact.id} />}
{m365Tab === 'tasks' && <TasksTab contactId={contact.id} />}
</div>
)}
{/* ── Modals ── */}
<ContactFormModal
isOpen={isEditOpen}

View file

@ -0,0 +1,120 @@
import { useContactEmails, useIntegrations } from '../hooks';
import { integrationsApi } from '../api';
import type { M365Email } from '../types';
interface Props {
contactId: string;
}
function formatEmailDate(iso: string): string {
return new Date(iso).toLocaleString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
});
}
export function EmailsTab({ contactId }: Props) {
const { data: integrationsData } = useIntegrations();
const isConnected = integrationsData?.data?.some(
(i) => i.provider === 'MICROSOFT_365' && i.connected,
) ?? false;
const { data, isLoading, error } = useContactEmails(contactId);
const emails: M365Email[] = data?.data ?? [];
if (!isConnected) {
return (
<div style={{ padding: '1.5rem 0', textAlign: 'center' }}>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.9375rem', marginBottom: '1rem' }}>
Verbinden Sie Microsoft 365, um E-Mails zu diesem Kontakt zu sehen.
</p>
<a
href={integrationsApi.getM365ConnectUrl()}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4375rem 1rem',
background: 'var(--color-primary)',
color: 'white',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
textDecoration: 'none',
}}
>
Microsoft 365 verbinden
</a>
</div>
);
}
if (isLoading) {
return <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>Laden</p>;
}
if (error) {
return <p style={{ color: 'var(--color-error)', fontSize: '0.875rem', padding: '1rem 0' }}>E-Mails konnten nicht geladen werden.</p>;
}
if (emails.length === 0) {
return <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>Keine E-Mails gefunden.</p>;
}
return (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{emails.map((email) => (
<a
key={email.id}
href={email.webLink}
target="_blank"
rel="noopener noreferrer"
style={{
display: 'block',
padding: '0.75rem 1rem',
background: email.isRead ? 'var(--color-bg-card)' : 'var(--color-bg-subtle, var(--color-bg-card))',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
textDecoration: 'none',
borderLeft: email.isRead ? undefined : '3px solid var(--color-primary)',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
<span style={{
fontSize: '0.875rem',
fontWeight: email.isRead ? 400 : 600,
color: 'var(--color-text)',
flex: 1,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{email.subject || '(kein Betreff)'}
</span>
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)', flexShrink: 0 }}>
{formatEmailDate(email.receivedDateTime)}
</span>
</div>
<div style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginTop: '0.25rem' }}>
Von: {email.from?.emailAddress?.name ?? email.from?.emailAddress?.address ?? '—'}
</div>
{email.bodyPreview && (
<div style={{
fontSize: '0.8125rem',
color: 'var(--color-text-secondary)',
marginTop: '0.25rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}>
{email.bodyPreview}
</div>
)}
</a>
))}
</div>
);
}

View file

@ -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<M365Task['status'], string> = {
notStarted: 'Offen',
inProgress: 'In Bearbeitung',
completed: 'Erledigt',
waitingOnOthers: 'Wartet',
deferred: 'Zurückgestellt',
};
const TASK_STATUS_COLORS: Record<M365Task['status'], { bg: string; color: string }> = {
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<M365Task['importance'], string> = {
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 (
<div style={{
display: 'flex',
alignItems: 'flex-start',
gap: '0.75rem',
padding: '0.625rem 0',
borderBottom: '1px solid var(--color-border)',
opacity: isCompleted ? 0.6 : 1,
}}>
<div style={{
width: 14,
height: 14,
borderRadius: '50%',
border: `2px solid ${isCompleted ? '#16a34a' : 'var(--color-border)'}`,
background: isCompleted ? '#16a34a' : 'transparent',
flexShrink: 0,
marginTop: 2,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}>
{isCompleted && (
<svg width="8" height="8" viewBox="0 0 8 8" fill="white">
<path d="M1 4l2 2 4-4" stroke="white" strokeWidth="1.5" fill="none" strokeLinecap="round" />
</svg>
)}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{
fontSize: '0.875rem',
fontWeight: 500,
color: 'var(--color-text)',
textDecoration: isCompleted ? 'line-through' : undefined,
}}>
{task.title}
</div>
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.25rem', flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{
fontSize: '0.6875rem',
fontWeight: 600,
padding: '0.125rem 0.375rem',
borderRadius: '999px',
...status,
}}>
{TASK_STATUS_LABELS[task.status]}
</span>
{task.importance !== 'normal' && (
<span style={{ fontSize: '0.75rem', color: IMPORTANCE_COLORS[task.importance], fontWeight: 600 }}>
{task.importance === 'high' ? '↑ Hoch' : '↓ Niedrig'}
</span>
)}
{task.dueDateTime && (
<span style={{ fontSize: '0.75rem', color: 'var(--color-text-muted)' }}>
Fällig: {formatDueDate(task.dueDateTime.dateTime)}
</span>
)}
</div>
</div>
</div>
);
}
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 (
<div style={{ padding: '1.5rem 0', textAlign: 'center' }}>
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.9375rem', marginBottom: '1rem' }}>
Verbinden Sie Microsoft 365, um Aufgaben zu diesem Kontakt zu sehen.
</p>
<a
href={integrationsApi.getM365ConnectUrl()}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.4375rem 1rem',
background: 'var(--color-primary)',
color: 'white',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
textDecoration: 'none',
}}
>
Microsoft 365 verbinden
</a>
</div>
);
}
if (isLoading) {
return <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>Laden</p>;
}
if (error) {
return <p style={{ color: 'var(--color-error)', fontSize: '0.875rem', padding: '1rem 0' }}>Aufgaben konnten nicht geladen werden.</p>;
}
const allTasks = taskLists.flatMap((list) => list.tasks);
if (allTasks.length === 0) {
return <p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem', padding: '1rem 0' }}>Keine Aufgaben gefunden.</p>;
}
return (
<div>
{taskLists.map((list) =>
list.tasks.length > 0 ? (
<div key={list.id} style={{ marginBottom: '1rem' }}>
<div style={{
fontSize: '0.8125rem',
fontWeight: 600,
color: 'var(--color-text-muted)',
textTransform: 'uppercase',
letterSpacing: '0.04em',
marginBottom: '0.25rem',
}}>
{list.displayName}
</div>
{list.tasks.map((task) => (
<TaskItem key={task.id} task={task} />
))}
</div>
) : null,
)}
</div>
);
}

View file

@ -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,
});
}

View file

@ -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[];
}

View file

@ -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<ProfileTab>('personal');
const location = useLocation();
const searchParams = new URLSearchParams(location.search);
const integrationParam = searchParams.get('integration');
const integrationStatus = searchParams.get('status');
const [activeTab, setActiveTab] = useState<ProfileTab>(
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 (
<div>
@ -337,6 +362,13 @@ export function ProfilePage() {
>
Passwort ändern
</button>
<button
type="button"
className={`${styles.tab} ${activeTab === 'integrations' ? styles.tabActive : ''}`}
onClick={() => setActiveTab('integrations')}
>
Integrationen
</button>
</div>
{/* === Tab: Profil === */}
@ -754,6 +786,89 @@ export function ProfilePage() {
</div>
</>
)}
{/* === Tab: Integrationen === */}
{activeTab === 'integrations' && (
<>
<div className={styles.section}>
<h2 className={styles.sectionTitle}>Microsoft 365</h2>
{m365Msg && (
<div className={m365IsError ? styles.error : styles.success} style={{ marginBottom: '1rem' }}>
{m365Msg}
</div>
)}
{integrationsLoading ? (
<p style={{ color: 'var(--color-text-muted)', fontSize: '0.875rem' }}>Laden</p>
) : m365Integration?.connected ? (
<div>
<p style={{ fontSize: '0.9375rem', color: 'var(--color-text)', marginBottom: '0.5rem' }}>
<span style={{ color: 'var(--color-success, #16a34a)', fontWeight: 600 }}> Verbunden</span>
{m365Integration.msTenantId && (
<span style={{ marginLeft: '0.75rem', fontSize: '0.8125rem', color: 'var(--color-text-muted)' }}>
Tenant: {m365Integration.msTenantId}
</span>
)}
</p>
{m365Integration.expiresAt && (
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-muted)', marginBottom: '1rem' }}>
Token gültig bis: {new Date(m365Integration.expiresAt).toLocaleString('de-DE')}
</p>
)}
<button
type="button"
className={styles.buttonDanger}
disabled={disconnectM365.isPending}
onClick={() => {
setM365Msg('');
disconnectM365.mutate(undefined, {
onSuccess: () => {
setM365Msg('Microsoft 365 wurde getrennt.');
setM365IsError(false);
},
onError: () => {
setM365Msg('Fehler beim Trennen der Verbindung.');
setM365IsError(true);
},
});
}}
>
{disconnectM365.isPending ? 'Trennen…' : 'Verbindung trennen'}
</button>
</div>
) : (
<div>
<p style={{ fontSize: '0.9375rem', color: 'var(--color-text-secondary)', marginBottom: '1rem' }}>
Verbinden Sie Ihr Microsoft 365 Konto, um E-Mails, Kalendertermine und Aufgaben
direkt in Kontaktprofilen zu sehen.
</p>
<a
href={integrationsApi.getM365ConnectUrl()}
className={styles.buttonPrimary ?? undefined}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.5rem 1.25rem',
background: 'var(--color-primary)',
color: 'white',
borderRadius: 'var(--radius-sm)',
fontSize: '0.9375rem',
fontWeight: 600,
textDecoration: 'none',
}}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M0 0h7.5v7.5H0zm8.5 0H16v7.5H8.5zM0 8.5h7.5V16H0zm8.5 0H16V16H8.5z" />
</svg>
Microsoft 365 verbinden
</a>
</div>
)}
</div>
</>
)}
</div>
);
}