mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
feat: Aufgaben-Tab im Dashboard (O365 + CRM bidirektional)
- GraphService: graphPost/graphPatch Helfer; getAllTasksFlat (inkl.
Body für CRM-Sync-Marker), createM365Task, completeM365Task
- Office365Controller: GET tasks/flat, POST tasks, PATCH tasks/:listId/:taskId/complete
- ActivitiesService/Controller: GET /crm/activities/open-tasks
(TASK + FOLLOWUP, nicht erledigt)
- Frontend types: M365TaskFlat + CrmOpenTask Interfaces
- Frontend api/hooks: getTasksFlat, createTask, completeTask,
getOpenTasks; neue Hooks useOffice365TasksFlat, useCrmOpenTasks,
usePushTaskToO365, useCompleteO365Task, useCompleteCrmTask
- DashboardTasksTab: vereinheitlichte Aufgabenliste mit Farbcodierung
(O365 blau, CRM orange, Synced grün), Push-Button, Erledigen-Button
- Bidirektionaler Sync via [INSIGHT_CRM:{activityId}] Marker im O365
Task Body; Erledigen eines Synced-Tasks aktualisiert beide Systeme
- DashboardPage: Tasks-Tab auf DashboardTasksTab umgestellt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fbf0b33a1f
commit
3b15c8ab9b
10 changed files with 1029 additions and 2 deletions
|
|
@ -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')
|
@Get(':id')
|
||||||
@ApiOperation({ summary: 'Aktivitaet-Details abrufen' })
|
@ApiOperation({ summary: 'Aktivitaet-Details abrufen' })
|
||||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||||
|
|
|
||||||
|
|
@ -168,4 +168,27 @@ export class ActivitiesService {
|
||||||
await this.findOne(tenantId, id);
|
await this.findOne(tenantId, id);
|
||||||
return this.prisma.activity.delete({ where: { 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 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,19 @@ export interface M365TaskList {
|
||||||
tasks: M365Task[];
|
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 {
|
export interface M365Contact {
|
||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
@ -125,6 +138,70 @@ export class GraphService {
|
||||||
|
|
||||||
// ── Graph API Helpers ─────────────────────────────────────────────────
|
// ── Graph API Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private async graphPost<T>(
|
||||||
|
accessToken: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async graphPatch<T>(
|
||||||
|
accessToken: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
): Promise<T> {
|
||||||
|
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<T>;
|
||||||
|
}
|
||||||
|
|
||||||
private async graphGet<T>(
|
private async graphGet<T>(
|
||||||
accessToken: string,
|
accessToken: string,
|
||||||
path: string,
|
path: string,
|
||||||
|
|
@ -411,6 +488,115 @@ export class GraphService {
|
||||||
return folders;
|
return folders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Alle offenen Aufgaben flach (inkl. Body für CRM-Sync-Erkennung) */
|
||||||
|
async getAllTasksFlat(userJwt: string, userId: string): Promise<M365TaskFlat[]> {
|
||||||
|
// 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<string, unknown> = { 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<void> {
|
||||||
|
const accessToken = await this.getM365Token(userJwt);
|
||||||
|
await this.graphPatch<unknown>(
|
||||||
|
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) */
|
/** E-Mails in einem bestimmten Ordner (mit optionalem Tages-Filter) */
|
||||||
async getMailsByFolder(
|
async getMailsByFolder(
|
||||||
userJwt: string,
|
userJwt: string,
|
||||||
|
|
|
||||||
|
|
@ -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 { Request } from 'express';
|
||||||
import { GraphService } from './graph.service';
|
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')
|
@Get('folders')
|
||||||
async getMailFolders(@Req() req: Request & { user: JwtUser }) {
|
async getMailFolders(@Req() req: Request & { user: JwtUser }) {
|
||||||
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
|
const jwt = (req.headers.authorization ?? '').replace('Bearer ', '');
|
||||||
|
|
|
||||||
|
|
@ -74,6 +74,8 @@ import type {
|
||||||
M365Email,
|
M365Email,
|
||||||
M365CalendarEvent,
|
M365CalendarEvent,
|
||||||
M365TaskList,
|
M365TaskList,
|
||||||
|
M365TaskFlat,
|
||||||
|
CrmOpenTask,
|
||||||
M365Contact,
|
M365Contact,
|
||||||
M365MailFolder,
|
M365MailFolder,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
@ -234,6 +236,13 @@ export const activitiesApi = {
|
||||||
api
|
api
|
||||||
.delete<SingleResponse<Activity>>(`/crm/activities/${id}`)
|
.delete<SingleResponse<Activity>>(`/crm/activities/${id}`)
|
||||||
.then((r) => r.data),
|
.then((r) => r.data),
|
||||||
|
|
||||||
|
getOpenTasks: () =>
|
||||||
|
api
|
||||||
|
.get<{ success: boolean; data: CrmOpenTask[]; meta: { count: number } }>(
|
||||||
|
'/crm/activities/open-tasks',
|
||||||
|
)
|
||||||
|
.then((r) => r.data),
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Companies ---
|
// --- Companies ---
|
||||||
|
|
@ -868,4 +877,27 @@ export const office365Api = {
|
||||||
{ params: { days } },
|
{ params: { days } },
|
||||||
)
|
)
|
||||||
.then((r) => r.data),
|
.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),
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
export function useContactByEmail(email: string | null) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['crm', 'contacts', 'lookup', email],
|
queryKey: ['crm', 'contacts', 'lookup', email],
|
||||||
|
|
|
||||||
|
|
@ -1004,6 +1004,37 @@ export interface M365TaskList {
|
||||||
tasks: M365Task[];
|
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 {
|
export interface M365Contact {
|
||||||
id: string;
|
id: string;
|
||||||
displayName: string;
|
displayName: string;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { WeatherWidget } from '../components/WeatherWidget';
|
||||||
import { EventCountdownTiles } from '../components/EventCountdownTiles';
|
import { EventCountdownTiles } from '../components/EventCountdownTiles';
|
||||||
import { DashboardEmailTab } from './DashboardEmailTab';
|
import { DashboardEmailTab } from './DashboardEmailTab';
|
||||||
import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
|
import { DashboardCalendarTab, DayAgenda } from './DashboardCalendarTab';
|
||||||
|
import { DashboardTasksTab } from './DashboardTasksTab';
|
||||||
import { useIntegrations, useOffice365CalendarRange } from '../crm/hooks';
|
import { useIntegrations, useOffice365CalendarRange } from '../crm/hooks';
|
||||||
import type { M365CalendarEvent } from '../crm/types';
|
import type { M365CalendarEvent } from '../crm/types';
|
||||||
import styles from './DashboardPage.module.css';
|
import styles from './DashboardPage.module.css';
|
||||||
|
|
@ -126,7 +127,7 @@ export function DashboardPage() {
|
||||||
)}
|
)}
|
||||||
{activeTab === 'emails' && <DashboardEmailTab />}
|
{activeTab === 'emails' && <DashboardEmailTab />}
|
||||||
{activeTab === 'calendar' && <DashboardCalendarTab />}
|
{activeTab === 'calendar' && <DashboardCalendarTab />}
|
||||||
{activeTab === 'tasks' && <ComingSoonTab label="Aufgaben" />}
|
{activeTab === 'tasks' && <DashboardTasksTab />}
|
||||||
{activeTab === 'contacts' && <ComingSoonTab label="Kontakte" />}
|
{activeTab === 'contacts' && <ComingSoonTab label="Kontakte" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
276
packages/frontend/src/shell/DashboardTasksTab.module.css
Normal file
276
packages/frontend/src/shell/DashboardTasksTab.module.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
372
packages/frontend/src/shell/DashboardTasksTab.tsx
Normal file
372
packages/frontend/src/shell/DashboardTasksTab.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<span className={styles.badgeGroup}>
|
||||||
|
{(source === 'o365' || source === 'synced') && (
|
||||||
|
<span className={`${styles.badge} ${styles.badgeO365}`}>O365</span>
|
||||||
|
)}
|
||||||
|
{(source === 'crm' || source === 'synced') && (
|
||||||
|
<span className={`${styles.badge} ${styles.badgeCrm}`}>CRM</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 (
|
||||||
|
<div
|
||||||
|
className={`${styles.taskRow} ${styles[`taskRow_${task.source}`]}`}
|
||||||
|
>
|
||||||
|
<SourceBadge source={task.source} />
|
||||||
|
|
||||||
|
<div className={styles.taskMain}>
|
||||||
|
<span className={styles.taskTitle}>{task.title}</span>
|
||||||
|
|
||||||
|
<div className={styles.taskMeta}>
|
||||||
|
{task.contactLabel && task.contactId && (
|
||||||
|
<Link
|
||||||
|
to={`/crm/contacts/${task.contactId}`}
|
||||||
|
className={styles.taskMetaLink}
|
||||||
|
>
|
||||||
|
{task.contactLabel}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{task.companyLabel && task.companyId && (
|
||||||
|
<Link
|
||||||
|
to={`/crm/companies/${task.companyId}`}
|
||||||
|
className={styles.taskMetaLink}
|
||||||
|
>
|
||||||
|
{task.companyLabel}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{dueFmt && (
|
||||||
|
<span className={`${styles.taskDue} ${overdue ? styles.taskDueOverdue : ''}`}>
|
||||||
|
{dueFmt}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{task.importance === 'high' && (
|
||||||
|
<span className={styles.taskImportance}>!</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.taskActions}>
|
||||||
|
{/* CRM-Aufgabe in O365 übernehmen */}
|
||||||
|
{task.source === 'crm' && isO365Connected && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.actionPush}
|
||||||
|
onClick={() => onPushToO365(task)}
|
||||||
|
disabled={isPushPending}
|
||||||
|
title="In Microsoft 365 übernehmen"
|
||||||
|
>
|
||||||
|
{isPushPending ? '…' : '→ O365'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Erledigen */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={styles.actionComplete}
|
||||||
|
onClick={() => onComplete(task)}
|
||||||
|
disabled={isCompletePending}
|
||||||
|
title="Als erledigt markieren"
|
||||||
|
>
|
||||||
|
{isCompletePending ? '…' : '✓'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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<string | null>(null);
|
||||||
|
const [pendingPush, setPendingPush] = useState<string | null>(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<string>();
|
||||||
|
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<unknown>[] = [];
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className={styles.root}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={styles.header}>
|
||||||
|
<h2 className={styles.title}>Aufgaben</h2>
|
||||||
|
<div className={styles.legend}>
|
||||||
|
<span className={`${styles.badge} ${styles.badgeO365}`}>O365</span>
|
||||||
|
<span className={styles.legendLabel}>Microsoft 365</span>
|
||||||
|
<span className={`${styles.badge} ${styles.badgeCrm}`}>CRM</span>
|
||||||
|
<span className={styles.legendLabel}>CRM-Aktivität</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<p className={styles.status}>Aufgaben werden geladen…</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && unified.length === 0 && (
|
||||||
|
<div className={styles.empty}>
|
||||||
|
<span className={styles.emptyIcon}>✅</span>
|
||||||
|
<p className={styles.emptyTitle}>Keine offenen Aufgaben</p>
|
||||||
|
<p className={styles.emptySub}>
|
||||||
|
{isO365Connected
|
||||||
|
? 'Alle Aufgaben aus CRM und Microsoft 365 sind erledigt.'
|
||||||
|
: 'Alle CRM-Aufgaben sind erledigt. Microsoft 365 nicht verbunden.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && unified.length > 0 && (
|
||||||
|
<div className={styles.list}>
|
||||||
|
{unified.map((task) => (
|
||||||
|
<TaskRow
|
||||||
|
key={task.key}
|
||||||
|
task={task}
|
||||||
|
onComplete={handleComplete}
|
||||||
|
onPushToO365={handlePushToO365}
|
||||||
|
isPushPending={pendingPush === task.key}
|
||||||
|
isCompletePending={pendingComplete === task.key}
|
||||||
|
isO365Connected={isO365Connected}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isO365Connected && (
|
||||||
|
<p className={styles.hint}>
|
||||||
|
💡 Verbinden Sie{' '}
|
||||||
|
<a href="/settings" className={styles.hintLink}>
|
||||||
|
Microsoft 365
|
||||||
|
</a>{' '}
|
||||||
|
um auch To-Do-Aufgaben anzuzeigen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue