mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 01:36:39 +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
|
## 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)
|
### Aktueller Sprint: CRM Phase 2 / Vertraege-Modul (Feature-Branch: feature/crm-service)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,10 @@ import type {
|
||||||
ContractsQueryParams,
|
ContractsQueryParams,
|
||||||
PaginatedResponse,
|
PaginatedResponse,
|
||||||
SingleResponse,
|
SingleResponse,
|
||||||
|
UserIntegration,
|
||||||
|
M365Email,
|
||||||
|
M365CalendarEvent,
|
||||||
|
M365TaskList,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
// --- Contacts ---
|
// --- Contacts ---
|
||||||
|
|
@ -749,3 +753,49 @@ export const contractFilesApi = {
|
||||||
)
|
)
|
||||||
.then((r) => r.data),
|
.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 { ActivityFormModal } from '../activities/ActivityFormModal';
|
||||||
import { Modal } from '../../components/Modal';
|
import { Modal } from '../../components/Modal';
|
||||||
import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
import { CustomFieldsDisplay } from '../CustomFieldsDisplay';
|
||||||
|
import { EmailsTab } from './EmailsTab';
|
||||||
|
import { CalendarTab } from './CalendarTab';
|
||||||
|
import { TasksTab } from './TasksTab';
|
||||||
import type { Contact, Activity, ActivityType } from '../types';
|
import type { Contact, Activity, ActivityType } from '../types';
|
||||||
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
import { CONTACT_SOURCE_LABELS, ENTITY_STATUS_LABELS } from '../types';
|
||||||
import styles from './ContactDetailPage.module.css';
|
import styles from './ContactDetailPage.module.css';
|
||||||
|
|
||||||
|
type M365Tab = 'emails' | 'calendar' | 'tasks';
|
||||||
|
|
||||||
const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
|
const ACTIVITY_TYPE_LABELS: Record<ActivityType, string> = {
|
||||||
NOTE: 'Notiz',
|
NOTE: 'Notiz',
|
||||||
CALL: 'Anruf',
|
CALL: 'Anruf',
|
||||||
|
|
@ -112,6 +117,7 @@ export function ContactDetailPage() {
|
||||||
const [isEditOpen, setEditOpen] = useState(false);
|
const [isEditOpen, setEditOpen] = useState(false);
|
||||||
const [isActivityOpen, setActivityOpen] = useState(false);
|
const [isActivityOpen, setActivityOpen] = useState(false);
|
||||||
const [isDeleteOpen, setDeleteOpen] = useState(false);
|
const [isDeleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
const [m365Tab, setM365Tab] = useState<M365Tab>('emails');
|
||||||
|
|
||||||
if (isLoading) return <p>Laden…</p>;
|
if (isLoading) return <p>Laden…</p>;
|
||||||
if (error || !data)
|
if (error || !data)
|
||||||
|
|
@ -546,6 +552,47 @@ export function ContactDetailPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 ── */}
|
{/* ── Modals ── */}
|
||||||
<ContactFormModal
|
<ContactFormModal
|
||||||
isOpen={isEditOpen}
|
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,
|
enrichmentApi,
|
||||||
contractsApi,
|
contractsApi,
|
||||||
contractFilesApi,
|
contractFilesApi,
|
||||||
|
integrationsApi,
|
||||||
|
graphApi,
|
||||||
} from './api';
|
} from './api';
|
||||||
import type {
|
import type {
|
||||||
ContactsQueryParams,
|
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;
|
apiKey: string;
|
||||||
configured: boolean;
|
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 { useState, useEffect, useRef, type FormEvent, type ChangeEvent } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
import api from '../api/client';
|
import api from '../api/client';
|
||||||
import { UserAvatar } from '../components/UserAvatar';
|
import { UserAvatar } from '../components/UserAvatar';
|
||||||
import { resizeImageToBase64 } from '../utils/imageUtils';
|
import { resizeImageToBase64 } from '../utils/imageUtils';
|
||||||
import { ExpertProfileTab } from './ExpertProfileTab';
|
import { ExpertProfileTab } from './ExpertProfileTab';
|
||||||
|
import { useIntegrations, useDisconnectM365 } from '../crm/hooks';
|
||||||
|
import { integrationsApi } from '../crm/api';
|
||||||
import styles from './ProfilePage.module.css';
|
import styles from './ProfilePage.module.css';
|
||||||
|
|
||||||
type ProfileTab = 'personal' | 'expert' | 'password';
|
type ProfileTab = 'personal' | 'expert' | 'password' | 'integrations';
|
||||||
|
|
||||||
export function ProfilePage() {
|
export function ProfilePage() {
|
||||||
const { user, refreshUser } = useAuth();
|
const { user, refreshUser } = useAuth();
|
||||||
|
|
@ -283,7 +286,29 @@ export function ProfilePage() {
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Tab-Navigation ---
|
// --- 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -337,6 +362,13 @@ export function ProfilePage() {
|
||||||
>
|
>
|
||||||
Passwort ändern
|
Passwort ändern
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${styles.tab} ${activeTab === 'integrations' ? styles.tabActive : ''}`}
|
||||||
|
onClick={() => setActiveTab('integrations')}
|
||||||
|
>
|
||||||
|
Integrationen
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* === Tab: Profil === */}
|
{/* === Tab: Profil === */}
|
||||||
|
|
@ -754,6 +786,89 @@ export function ProfilePage() {
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue