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')
|
||||
@ApiOperation({ summary: 'Aktivitaet-Details abrufen' })
|
||||
@ApiParam({ name: 'id', type: 'string', format: 'uuid' })
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<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>(
|
||||
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<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) */
|
||||
async getMailsByFolder(
|
||||
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 { 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 ', '');
|
||||
|
|
|
|||
|
|
@ -74,6 +74,8 @@ import type {
|
|||
M365Email,
|
||||
M365CalendarEvent,
|
||||
M365TaskList,
|
||||
M365TaskFlat,
|
||||
CrmOpenTask,
|
||||
M365Contact,
|
||||
M365MailFolder,
|
||||
} from './types';
|
||||
|
|
@ -234,6 +236,13 @@ export const activitiesApi = {
|
|||
api
|
||||
.delete<SingleResponse<Activity>>(`/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),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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' && <DashboardEmailTab />}
|
||||
{activeTab === 'calendar' && <DashboardCalendarTab />}
|
||||
{activeTab === 'tasks' && <ComingSoonTab label="Aufgaben" />}
|
||||
{activeTab === 'tasks' && <DashboardTasksTab />}
|
||||
{activeTab === 'contacts' && <ComingSoonTab label="Kontakte" />}
|
||||
</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