diff --git a/packages/crm-service/src/activities/activities.controller.ts b/packages/crm-service/src/activities/activities.controller.ts index 6d14463..d22d470 100644 --- a/packages/crm-service/src/activities/activities.controller.ts +++ b/packages/crm-service/src/activities/activities.controller.ts @@ -69,6 +69,13 @@ export class ActivitiesController { ); } + @Get('open-tasks') + @ApiOperation({ summary: 'Offene Aufgaben (TASK + FOLLOWUP) abrufen' }) + async findOpenTasks(@CurrentUser() user: JwtPayload) { + const tasks = await this.activitiesService.findOpenTasks(user.tenantId!); + return { success: true, data: tasks, meta: { count: tasks.length } }; + } + @Get(':id') @ApiOperation({ summary: 'Aktivitaet-Details abrufen' }) @ApiParam({ name: 'id', type: 'string', format: 'uuid' }) diff --git a/packages/crm-service/src/activities/activities.service.ts b/packages/crm-service/src/activities/activities.service.ts index 43426e5..5c6ca41 100644 --- a/packages/crm-service/src/activities/activities.service.ts +++ b/packages/crm-service/src/activities/activities.service.ts @@ -168,4 +168,27 @@ export class ActivitiesService { await this.findOne(tenantId, id); return this.prisma.activity.delete({ where: { id } }); } + + /** Alle offenen Aufgaben (TASK + FOLLOWUP, nicht erledigt) */ + async findOpenTasks(tenantId: string) { + return this.prisma.activity.findMany({ + where: { + tenantId, + type: { in: ['TASK', 'FOLLOWUP'] }, + completedAt: null, + }, + orderBy: [ + { scheduledAt: { sort: 'asc', nulls: 'last' } }, + { createdAt: 'desc' }, + ], + include: { + contact: { + select: { id: true, firstName: true, lastName: true, companyName: true }, + }, + company: { + select: { id: true, name: true }, + }, + }, + }); + } } diff --git a/packages/crm-service/src/graph/graph.service.ts b/packages/crm-service/src/graph/graph.service.ts index 78e3653..13813fd 100644 --- a/packages/crm-service/src/graph/graph.service.ts +++ b/packages/crm-service/src/graph/graph.service.ts @@ -47,6 +47,19 @@ export interface M365TaskList { tasks: M365Task[]; } +/** Flache Aufgabe inkl. Body-Inhalt (für Dashboard Aufgaben-Tab) */ +export interface M365TaskFlat { + id: string; + listId: string; + listName: string; + title: string; + status: string; + importance: string; + dueDateTime: { dateTime: string; timeZone: string } | null; + bodyContent: string | null; // Enthält "[INSIGHT_CRM:{activityId}]" wenn synchronisiert + createdDateTime: string; +} + export interface M365Contact { id: string; displayName: string; @@ -125,6 +138,70 @@ export class GraphService { // ── Graph API Helpers ───────────────────────────────────────────────── + private async graphPost( + accessToken: string, + path: string, + body: unknown, + ): Promise { + const resp = await fetch(`${GRAPH_BASE}${path}`, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10000), + }); + + if (!resp.ok) { + const errBody = (await resp.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + this.logger.error( + `Graph API POST Fehler ${resp.status} auf ${path}: ${errBody.error?.message ?? resp.statusText}`, + ); + throw new ServiceUnavailableException( + `Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`, + ); + } + + return resp.json() as Promise; + } + + private async graphPatch( + accessToken: string, + path: string, + body: unknown, + ): Promise { + const resp = await fetch(`${GRAPH_BASE}${path}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(10000), + }); + + if (!resp.ok) { + const errBody = (await resp.json().catch(() => ({}))) as { + error?: { message?: string }; + }; + this.logger.error( + `Graph API PATCH Fehler ${resp.status} auf ${path}: ${errBody.error?.message ?? resp.statusText}`, + ); + throw new ServiceUnavailableException( + `Graph API Fehler ${resp.status}: ${errBody.error?.message ?? resp.statusText}`, + ); + } + + // PATCH kann 204 No Content zurückgeben + if (resp.status === 204) return {} as T; + return resp.json() as Promise; + } + private async graphGet( accessToken: string, path: string, @@ -411,6 +488,115 @@ export class GraphService { return folders; } + /** Alle offenen Aufgaben flach (inkl. Body für CRM-Sync-Erkennung) */ + async getAllTasksFlat(userJwt: string, userId: string): Promise { + // Kein Cache — Aufgaben müssen immer aktuell sein + const accessToken = await this.getM365Token(userJwt); + + const listsData = await this.graphGet<{ + value: Array<{ id: string; displayName: string }>; + }>(accessToken, '/me/todo/lists', { $top: '20' }); + + const flatTasks: M365TaskFlat[] = []; + + for (const list of listsData.value ?? []) { + const tasksData = await this.graphGet<{ + value: Array<{ + id: string; + title: string; + status: string; + importance: string; + dueDateTime: { dateTime: string; timeZone: string } | null; + body: { content: string; contentType: string } | null; + createdDateTime: string; + }>; + }>(accessToken, `/me/todo/lists/${list.id}/tasks`, { + $top: '100', + $filter: "status ne 'completed'", + $select: 'id,title,status,importance,dueDateTime,body,createdDateTime', + $orderby: 'importance desc', + }); + + for (const task of tasksData.value ?? []) { + flatTasks.push({ + id: task.id, + listId: list.id, + listName: list.displayName, + title: task.title, + status: task.status, + importance: task.importance, + dueDateTime: task.dueDateTime ?? null, + bodyContent: task.body?.content ?? null, + createdDateTime: task.createdDateTime, + }); + } + } + + this.logger.debug(`Graph: ${flatTasks.length} Aufgaben (flat) fuer ${userId} geladen`); + return flatTasks; + } + + /** Neue Aufgabe in Standard-Aufgabenliste erstellen */ + async createM365Task( + userJwt: string, + title: string, + bodyContent?: string, + dueDateISO?: string, + ): Promise<{ id: string; listId: string }> { + const accessToken = await this.getM365Token(userJwt); + + // Standard-Liste finden ("Tasks" / "Aufgaben" / erste verfügbare Liste) + const listsData = await this.graphGet<{ + value: Array<{ id: string; displayName: string; wellknownListName?: string }>; + }>(accessToken, '/me/todo/lists', { $top: '20' }); + + const lists = listsData.value ?? []; + const targetList = + lists.find( + (l) => + l.displayName.toLowerCase() === 'tasks' || + l.displayName.toLowerCase() === 'aufgaben' || + (l as { wellknownListName?: string }).wellknownListName === 'defaultList', + ) ?? lists[0]; + + if (!targetList) { + throw new ServiceUnavailableException('Keine M365-Aufgabenliste gefunden'); + } + + const taskPayload: Record = { title }; + + if (bodyContent) { + taskPayload['body'] = { content: bodyContent, contentType: 'text' }; + } + if (dueDateISO) { + taskPayload['dueDateTime'] = { dateTime: dueDateISO, timeZone: 'UTC' }; + } + + const created = await this.graphPost<{ id: string }>( + accessToken, + `/me/todo/lists/${targetList.id}/tasks`, + taskPayload, + ); + + this.logger.debug(`Graph: Aufgabe "${title}" in Liste "${targetList.displayName}" erstellt`); + return { id: created.id, listId: targetList.id }; + } + + /** Aufgabe als erledigt markieren */ + async completeM365Task( + userJwt: string, + listId: string, + taskId: string, + ): Promise { + const accessToken = await this.getM365Token(userJwt); + await this.graphPatch( + accessToken, + `/me/todo/lists/${listId}/tasks/${taskId}`, + { status: 'completed' }, + ); + this.logger.debug(`Graph: Aufgabe ${taskId} als erledigt markiert`); + } + /** E-Mails in einem bestimmten Ordner (mit optionalem Tages-Filter) */ async getMailsByFolder( userJwt: string, diff --git a/packages/crm-service/src/graph/office365.controller.ts b/packages/crm-service/src/graph/office365.controller.ts index 3c619c0..0dc96df 100644 --- a/packages/crm-service/src/graph/office365.controller.ts +++ b/packages/crm-service/src/graph/office365.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Param, Query, Req, Logger } from '@nestjs/common'; +import { Controller, Get, Post, Patch, Param, Query, Body, Req, Logger } from '@nestjs/common'; import { Request } from 'express'; import { GraphService } from './graph.service'; @@ -76,6 +76,42 @@ export class Office365Controller { }; } + /** Alle offenen Aufgaben flach (inkl. Body für CRM-Sync-Erkennung) */ + @Get('tasks/flat') + async getTasksFlat(@Req() req: Request & { user: JwtUser }) { + const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); + const tasks = await this.graphService.getAllTasksFlat(jwt, req.user.sub); + return { success: true, data: tasks, meta: { count: tasks.length } }; + } + + /** Neue Aufgabe in M365 erstellen (z.B. CRM-Aufgabe übernehmen) */ + @Post('tasks') + async createTask( + @Req() req: Request & { user: JwtUser }, + @Body() body: { title: string; bodyContent?: string; dueDateISO?: string }, + ) { + const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); + const result = await this.graphService.createM365Task( + jwt, + body.title, + body.bodyContent, + body.dueDateISO, + ); + return { success: true, data: result }; + } + + /** Aufgabe als erledigt markieren */ + @Patch('tasks/:listId/:taskId/complete') + async completeTask( + @Req() req: Request & { user: JwtUser }, + @Param('listId') listId: string, + @Param('taskId') taskId: string, + ) { + const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); + await this.graphService.completeM365Task(jwt, listId, taskId); + return { success: true }; + } + @Get('folders') async getMailFolders(@Req() req: Request & { user: JwtUser }) { const jwt = (req.headers.authorization ?? '').replace('Bearer ', ''); diff --git a/packages/frontend/src/crm/api.ts b/packages/frontend/src/crm/api.ts index e164c0c..551ecf5 100644 --- a/packages/frontend/src/crm/api.ts +++ b/packages/frontend/src/crm/api.ts @@ -74,6 +74,8 @@ import type { M365Email, M365CalendarEvent, M365TaskList, + M365TaskFlat, + CrmOpenTask, M365Contact, M365MailFolder, } from './types'; @@ -234,6 +236,13 @@ export const activitiesApi = { api .delete>(`/crm/activities/${id}`) .then((r) => r.data), + + getOpenTasks: () => + api + .get<{ success: boolean; data: CrmOpenTask[]; meta: { count: number } }>( + '/crm/activities/open-tasks', + ) + .then((r) => r.data), }; // --- Companies --- @@ -868,4 +877,27 @@ export const office365Api = { { params: { days } }, ) .then((r) => r.data), + + getTasksFlat: () => + api + .get<{ success: boolean; data: M365TaskFlat[]; meta: { count: number } }>( + '/crm/office365/tasks/flat', + ) + .then((r) => r.data), + + createTask: (title: string, bodyContent?: string, dueDateISO?: string) => + api + .post<{ success: boolean; data: { id: string; listId: string } }>( + '/crm/office365/tasks', + { title, bodyContent, dueDateISO }, + ) + .then((r) => r.data), + + completeTask: (listId: string, taskId: string) => + api + .patch<{ success: boolean }>( + `/crm/office365/tasks/${listId}/${taskId}/complete`, + {}, + ) + .then((r) => r.data), }; diff --git a/packages/frontend/src/crm/hooks.ts b/packages/frontend/src/crm/hooks.ts index 9fc632a..bff349c 100644 --- a/packages/frontend/src/crm/hooks.ts +++ b/packages/frontend/src/crm/hooks.ts @@ -1440,6 +1440,69 @@ export function useOffice365MailsInFolder( }); } +export function useOffice365TasksFlat() { + const { data: integrationsData } = useIntegrations(); + const isConnected = + integrationsData?.data?.some( + (i) => i.provider === 'MICROSOFT_365' && i.connected, + ) ?? false; + + return useQuery({ + queryKey: ['office365', 'tasks-flat'], + queryFn: () => office365Api.getTasksFlat(), + enabled: isConnected, + staleTime: 2 * 60 * 1000, + }); +} + +export function useCrmOpenTasks() { + return useQuery({ + queryKey: ['crm', 'activities', 'open-tasks'], + queryFn: () => activitiesApi.getOpenTasks(), + staleTime: 2 * 60 * 1000, + }); +} + +export function usePushTaskToO365() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ + title, + bodyContent, + dueDateISO, + }: { + title: string; + bodyContent?: string; + dueDateISO?: string; + }) => office365Api.createTask(title, bodyContent, dueDateISO), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['office365', 'tasks-flat'] }); + }, + }); +} + +export function useCompleteO365Task() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ listId, taskId }: { listId: string; taskId: string }) => + office365Api.completeTask(listId, taskId), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['office365', 'tasks-flat'] }); + }, + }); +} + +export function useCompleteCrmTask() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => + activitiesApi.update(id, { completedAt: new Date().toISOString() }), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ['crm', 'activities', 'open-tasks'] }); + }, + }); +} + export function useContactByEmail(email: string | null) { return useQuery({ queryKey: ['crm', 'contacts', 'lookup', email], diff --git a/packages/frontend/src/crm/types.ts b/packages/frontend/src/crm/types.ts index b600513..c86e12f 100644 --- a/packages/frontend/src/crm/types.ts +++ b/packages/frontend/src/crm/types.ts @@ -1004,6 +1004,37 @@ export interface M365TaskList { tasks: M365Task[]; } +/** Flache Aufgabe inkl. Body-Inhalt (für Dashboard Aufgaben-Tab) */ +export interface M365TaskFlat { + id: string; + listId: string; + listName: string; + title: string; + status: string; + importance: 'low' | 'normal' | 'high'; + dueDateTime?: { dateTime: string; timeZone: string } | null; + bodyContent?: string | null; // Enthält "[INSIGHT_CRM:{activityId}]" wenn synchronisiert + createdDateTime: string; +} + +/** Offene CRM-Aufgabe (TASK / FOLLOWUP, noch nicht erledigt) */ +export interface CrmOpenTask { + id: string; + type: 'TASK' | 'FOLLOWUP'; + subject: string; + description: string | null; + scheduledAt: string | null; + contactId: string | null; + companyId: string | null; + contact?: { + id: string; + firstName: string | null; + lastName: string | null; + companyName: string | null; + } | null; + company?: { id: string; name: string } | null; +} + export interface M365Contact { id: string; displayName: string; diff --git a/packages/frontend/src/shell/DashboardPage.tsx b/packages/frontend/src/shell/DashboardPage.tsx index 71771ac..43d45ab 100644 --- a/packages/frontend/src/shell/DashboardPage.tsx +++ b/packages/frontend/src/shell/DashboardPage.tsx @@ -4,6 +4,7 @@ import { WeatherWidget } from '../components/WeatherWidget'; import { EventCountdownTiles } from '../components/EventCountdownTiles'; import { DashboardEmailTab } from './DashboardEmailTab'; import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab'; +import { DashboardTasksTab } from './DashboardTasksTab'; import { useIntegrations, useOffice365CalendarRange } from '../crm/hooks'; import type { M365CalendarEvent } from '../crm/types'; import styles from './DashboardPage.module.css'; @@ -126,7 +127,7 @@ export function DashboardPage() { )} {activeTab === 'emails' && } {activeTab === 'calendar' && } - {activeTab === 'tasks' && } + {activeTab === 'tasks' && } {activeTab === 'contacts' && } diff --git a/packages/frontend/src/shell/DashboardTasksTab.module.css b/packages/frontend/src/shell/DashboardTasksTab.module.css new file mode 100644 index 0000000..647ed0c --- /dev/null +++ b/packages/frontend/src/shell/DashboardTasksTab.module.css @@ -0,0 +1,276 @@ +/* ============================================================ + DashboardTasksTab — Vereinheitlichte Aufgabenliste + ============================================================ */ + +.root { + display: flex; + flex-direction: column; + gap: 1rem; +} + +/* ── Header ── */ + +.header { + display: flex; + align-items: center; + gap: 1.25rem; + flex-wrap: wrap; +} + +.title { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); + margin: 0; +} + +.legend { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.8125rem; + color: var(--color-text-muted); +} + +.legendLabel { + margin-right: 0.625rem; +} + +/* ── Source Badges ── */ + +.badgeGroup { + display: flex; + gap: 3px; + flex-shrink: 0; +} + +.badge { + display: inline-flex; + align-items: center; + font-size: 0.625rem; + font-weight: 700; + border-radius: 999px; + padding: 0.0625rem 0.4375rem; + letter-spacing: 0.03em; + text-transform: uppercase; + white-space: nowrap; +} + +.badgeO365 { + background: rgba(59, 130, 246, 0.12); + color: var(--color-primary); + border: 1px solid rgba(59, 130, 246, 0.3); +} + +.badgeCrm { + background: rgba(249, 115, 22, 0.1); + color: #f97316; + border: 1px solid rgba(249, 115, 22, 0.3); +} + +/* ── Liste ── */ + +.list { + display: flex; + flex-direction: column; + background: var(--color-bg-card); + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + overflow: hidden; +} + +/* ── Task-Zeile ── */ + +.taskRow { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--color-border); + border-left: 3px solid transparent; + transition: background 0.1s; +} + +.taskRow:last-child { + border-bottom: none; +} + +.taskRow:hover { + background: rgba(59, 130, 246, 0.03); +} + +/* Farbige linke Linie je nach Quelle */ +.taskRow_o365 { + border-left-color: var(--color-primary); +} + +.taskRow_crm { + border-left-color: #f97316; +} + +.taskRow_synced { + border-left-color: #22c55e; /* Grün für synchronisiert */ +} + +/* ── Task Inhalt ── */ + +.taskMain { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.taskTitle { + font-size: 0.9375rem; + font-weight: 500; + color: var(--color-text); + line-height: 1.35; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.taskMeta { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.taskMetaLink { + font-size: 0.75rem; + color: var(--color-primary); + text-decoration: none; + white-space: nowrap; +} + +.taskMetaLink:hover { + text-decoration: underline; +} + +.taskDue { + font-size: 0.75rem; + color: var(--color-text-muted); + white-space: nowrap; +} + +.taskDueOverdue { + color: #ef4444; + font-weight: 600; +} + +.taskImportance { + font-size: 0.75rem; + font-weight: 700; + color: #ef4444; + flex-shrink: 0; +} + +/* ── Aktions-Buttons ── */ + +.taskActions { + display: flex; + gap: 0.375rem; + flex-shrink: 0; +} + +.actionPush { + padding: 0.25rem 0.625rem; + background: rgba(249, 115, 22, 0.08); + border: 1px solid rgba(249, 115, 22, 0.3); + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; + color: #f97316; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.actionPush:hover:not(:disabled) { + background: rgba(249, 115, 22, 0.15); + border-color: #f97316; +} + +.actionPush:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.actionComplete { + padding: 0.25rem 0.625rem; + background: rgba(34, 197, 94, 0.08); + border: 1px solid rgba(34, 197, 94, 0.3); + border-radius: var(--radius-sm); + font-size: 0.75rem; + font-weight: 600; + color: #22c55e; + cursor: pointer; + transition: all 0.15s; + white-space: nowrap; +} + +.actionComplete:hover:not(:disabled) { + background: rgba(34, 197, 94, 0.15); + border-color: #22c55e; +} + +.actionComplete:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ── Status / Leer-Zustände ── */ + +.status { + color: var(--color-text-muted); + font-size: 0.9375rem; + padding: 1rem 0; +} + +.empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 4rem 2rem; + text-align: center; + gap: 0.5rem; +} + +.emptyIcon { + font-size: 2.5rem; + margin-bottom: 0.5rem; +} + +.emptyTitle { + font-size: 1.125rem; + font-weight: 600; + color: var(--color-text); + margin: 0; +} + +.emptySub { + font-size: 0.9375rem; + color: var(--color-text-muted); + margin: 0; +} + +/* ── Hinweis ── */ + +.hint { + font-size: 0.875rem; + color: var(--color-text-muted); + margin: 0; +} + +.hintLink { + color: var(--color-primary); + text-decoration: none; +} + +.hintLink:hover { + text-decoration: underline; +} diff --git a/packages/frontend/src/shell/DashboardTasksTab.tsx b/packages/frontend/src/shell/DashboardTasksTab.tsx new file mode 100644 index 0000000..df98636 --- /dev/null +++ b/packages/frontend/src/shell/DashboardTasksTab.tsx @@ -0,0 +1,372 @@ +import { useState } from 'react'; +import { Link } from 'react-router-dom'; +import { + useIntegrations, + useOffice365TasksFlat, + useCrmOpenTasks, + usePushTaskToO365, + useCompleteO365Task, + useCompleteCrmTask, +} from '../crm/hooks'; +import type { M365TaskFlat, CrmOpenTask } from '../crm/types'; +import styles from './DashboardTasksTab.module.css'; + +// ── CRM-Sync-Marker Regex ──────────────────────────────────────────────────── + +const CRM_MARKER_RE = /\[INSIGHT_CRM:([^\]]+)\]/; + +function extractCrmId(body: string | null | undefined): string | null { + if (!body) return null; + const m = CRM_MARKER_RE.exec(body); + return m ? m[1] : null; +} + +// ── Typen für die vereinheitlichte Liste ───────────────────────────────────── + +type TaskSource = 'o365' | 'crm' | 'synced'; + +interface UnifiedTask { + key: string; + source: TaskSource; + title: string; + dueDate: string | null; + contactLabel: string | null; + contactId: string | null; + companyLabel: string | null; + companyId: string | null; + importance?: 'low' | 'normal' | 'high'; + // O365-spezifisch + o365ListId?: string; + o365TaskId?: string; + // CRM-spezifisch + crmActivityId?: string; + crmType?: 'TASK' | 'FOLLOWUP'; +} + +// ── Datum formatieren ──────────────────────────────────────────────────────── + +function formatDue(isoOrDateTime: string | null): string | null { + if (!isoOrDateTime) return null; + try { + const d = new Date(isoOrDateTime); + const today = new Date(); + const todayStr = today.toISOString().slice(0, 10); + const dStr = d.toISOString().slice(0, 10); + const tomorrow = new Date(today.getTime() + 86_400_000).toISOString().slice(0, 10); + + if (dStr === todayStr) return 'Heute'; + if (dStr === tomorrow) return 'Morgen'; + if (dStr < todayStr) { + const days = Math.round((today.getTime() - d.getTime()) / 86_400_000); + return `Überfällig (${days}d)`; + } + return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }); + } catch { + return null; + } +} + +function isDue(isoOrDateTime: string | null): boolean { + if (!isoOrDateTime) return false; + try { + const d = new Date(isoOrDateTime); + return d < new Date(); + } catch { + return false; + } +} + +// ── Source-Badge ───────────────────────────────────────────────────────────── + +function SourceBadge({ source }: { source: TaskSource }) { + return ( + + {(source === 'o365' || source === 'synced') && ( + O365 + )} + {(source === 'crm' || source === 'synced') && ( + CRM + )} + + ); +} + +// ── Einzelne Aufgaben-Zeile ─────────────────────────────────────────────────── + +function TaskRow({ + task, + onComplete, + onPushToO365, + isPushPending, + isCompletePending, + isO365Connected, +}: { + task: UnifiedTask; + onComplete: (t: UnifiedTask) => void; + onPushToO365: (t: UnifiedTask) => void; + isPushPending: boolean; + isCompletePending: boolean; + isO365Connected: boolean; +}) { + const overdue = isDue(task.dueDate); + const dueFmt = formatDue(task.dueDate); + + return ( +
+ + +
+ {task.title} + +
+ {task.contactLabel && task.contactId && ( + + {task.contactLabel} + + )} + {task.companyLabel && task.companyId && ( + + {task.companyLabel} + + )} + {dueFmt && ( + + {dueFmt} + + )} + {task.importance === 'high' && ( + ! + )} +
+
+ +
+ {/* CRM-Aufgabe in O365 übernehmen */} + {task.source === 'crm' && isO365Connected && ( + + )} + + {/* Erledigen */} + +
+
+ ); +} + +// ── Hauptkomponente ─────────────────────────────────────────────────────────── + +export function DashboardTasksTab() { + const { data: integrationsData } = useIntegrations(); + const isO365Connected = + integrationsData?.data?.some( + (i) => i.provider === 'MICROSOFT_365' && i.connected, + ) ?? false; + + const { data: o365Data, isLoading: o365Loading } = useOffice365TasksFlat(); + const { data: crmData, isLoading: crmLoading } = useCrmOpenTasks(); + + const pushMutation = usePushTaskToO365(); + const completeO365 = useCompleteO365Task(); + const completeCrm = useCompleteCrmTask(); + + // IDs, auf denen gerade eine Aktion läuft + const [pendingComplete, setPendingComplete] = useState(null); + const [pendingPush, setPendingPush] = useState(null); + + // ── Unified list aufbauen ──────────────────────────────────────────────── + + const o365Tasks: M365TaskFlat[] = o365Data?.data ?? []; + const crmTasks: CrmOpenTask[] = crmData?.data ?? []; + + // CRM-IDs, die bereits in einer O365-Aufgabe verknüpft sind + const syncedCrmIds = new Set(); + for (const t of o365Tasks) { + const crmId = extractCrmId(t.bodyContent); + if (crmId) syncedCrmIds.add(crmId); + } + + const unified: UnifiedTask[] = []; + + // O365-Aufgaben (inkl. synced) + for (const t of o365Tasks) { + const crmId = extractCrmId(t.bodyContent); + const crmActivity = crmId + ? crmTasks.find((c) => c.id === crmId) ?? null + : null; + + const source: TaskSource = crmId ? 'synced' : 'o365'; + + unified.push({ + key: `o365-${t.id}`, + source, + title: t.title, + dueDate: t.dueDateTime?.dateTime ?? null, + importance: t.importance as 'low' | 'normal' | 'high', + contactLabel: crmActivity?.contact + ? (`${crmActivity.contact.firstName ?? ''} ${crmActivity.contact.lastName ?? ''}`.trim() || + crmActivity.contact.companyName) ?? null + : null, + contactId: crmActivity?.contactId ?? null, + companyLabel: crmActivity?.company?.name ?? null, + companyId: crmActivity?.companyId ?? null, + o365ListId: t.listId, + o365TaskId: t.id, + crmActivityId: crmId ?? undefined, + }); + } + + // CRM-Aufgaben, die NICHT in O365 synchronisiert sind + for (const c of crmTasks) { + if (syncedCrmIds.has(c.id)) continue; + + const contactLabel = c.contact + ? (`${c.contact.firstName ?? ''} ${c.contact.lastName ?? ''}`.trim() || + c.contact.companyName) ?? null + : null; + + unified.push({ + key: `crm-${c.id}`, + source: 'crm', + title: c.subject, + dueDate: c.scheduledAt, + contactLabel, + contactId: c.contactId, + companyLabel: c.company?.name ?? null, + companyId: c.companyId, + crmActivityId: c.id, + crmType: c.type, + }); + } + + // Sortierung: Überfällige zuerst, dann nach Datum, dann Rest + unified.sort((a, b) => { + const aOver = a.dueDate && isDue(a.dueDate); + const bOver = b.dueDate && isDue(b.dueDate); + if (aOver && !bOver) return -1; + if (!aOver && bOver) return 1; + if (a.dueDate && b.dueDate) return a.dueDate.localeCompare(b.dueDate); + if (a.dueDate) return -1; + if (b.dueDate) return 1; + return 0; + }); + + // ── Event-Handler ──────────────────────────────────────────────────────── + + function handleComplete(task: UnifiedTask) { + setPendingComplete(task.key); + + const promises: Promise[] = []; + + if (task.o365TaskId && task.o365ListId) { + promises.push( + completeO365.mutateAsync({ listId: task.o365ListId, taskId: task.o365TaskId }), + ); + } + if (task.crmActivityId) { + promises.push(completeCrm.mutateAsync(task.crmActivityId)); + } + + Promise.allSettled(promises).finally(() => setPendingComplete(null)); + } + + function handlePushToO365(task: UnifiedTask) { + if (!task.crmActivityId) return; + setPendingPush(task.key); + + const crmActivity = crmTasks.find((c) => c.id === task.crmActivityId); + const dueDateISO = crmActivity?.scheduledAt ?? undefined; + + pushMutation + .mutateAsync({ + title: task.title, + bodyContent: `[INSIGHT_CRM:${task.crmActivityId}] ${crmActivity?.description ?? ''}`.trim(), + dueDateISO: dueDateISO ?? undefined, + }) + .finally(() => setPendingPush(null)); + } + + // ── Render ─────────────────────────────────────────────────────────────── + + const isLoading = (isO365Connected && o365Loading) || crmLoading; + + return ( +
+ {/* Header */} +
+

Aufgaben

+
+ O365 + Microsoft 365 + CRM + CRM-Aktivität +
+
+ + {isLoading && ( +

Aufgaben werden geladen…

+ )} + + {!isLoading && unified.length === 0 && ( +
+ +

Keine offenen Aufgaben

+

+ {isO365Connected + ? 'Alle Aufgaben aus CRM und Microsoft 365 sind erledigt.' + : 'Alle CRM-Aufgaben sind erledigt. Microsoft 365 nicht verbunden.'} +

+
+ )} + + {!isLoading && unified.length > 0 && ( +
+ {unified.map((task) => ( + + ))} +
+ )} + + {!isO365Connected && ( +

+ 💡 Verbinden Sie{' '} + + Microsoft 365 + {' '} + um auch To-Do-Aufgaben anzuzeigen. +

+ )} +
+ ); +}