mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-24 22:36:38 +02:00
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:
parent
47b1938605
commit
30c4b208d9
9 changed files with 815 additions and 2 deletions
65
Summarize.md
65
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)
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
|
|
|
|||
116
packages/frontend/src/crm/contacts/CalendarTab.tsx
Normal file
116
packages/frontend/src/crm/contacts/CalendarTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
120
packages/frontend/src/crm/contacts/EmailsTab.tsx
Normal file
120
packages/frontend/src/crm/contacts/EmailsTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
packages/frontend/src/crm/contacts/TasksTab.tsx
Normal file
178
packages/frontend/src/crm/contacts/TasksTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue