From 235c19e51b17f392fdbb080c4aab57991a41aa74 Mon Sep 17 00:00:00 2001 From: Michael Date: Tue, 15 Sep 2026 10:49:33 +0000 Subject: [PATCH] Update --- backend/src/index.ts | 95 ++++++++++++++++++-- frontend/src/App.tsx | 6 +- frontend/src/api.ts | 1 + frontend/src/components/CopyTicketButton.tsx | 25 +++++- frontend/src/timers.ts | 10 ++- frontend/src/views/DashboardPage.tsx | 22 ++--- frontend/src/views/ProjectsPage.tsx | 2 +- frontend/src/views/TicketDetailPage.tsx | 2 +- frontend/src/views/TimerPage.tsx | 4 +- frontend/src/views/ZammadSyncPage.tsx | 39 +++++++- 10 files changed, 175 insertions(+), 31 deletions(-) diff --git a/backend/src/index.ts b/backend/src/index.ts index aecfe11..7ba3241 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -354,6 +354,7 @@ type ZammadTicketImport = { teamspaceBudgetMinutes: number | null; teamspaceProject: string | null; teamspaceProjects: string[]; + pendingTime: string | null; }; type ZammadSyncExistingFields = { @@ -363,6 +364,7 @@ type ZammadSyncExistingFields = { projectName: boolean; budget: boolean; overrideBudget: boolean; + pendingTodo: boolean; }; type ZammadSyncChange = { @@ -490,10 +492,17 @@ function parseZammadTeamspaceProjects(value: unknown) { return []; } - return value - .split("+") - .map((part) => part.trim()) - .filter(Boolean); + return value.match(/[^\s+]+/g) ?? []; +} + +function parseZammadPendingTime(value: unknown) { + if (typeof value !== "string" || !value.trim()) { + return null; + } + + const date = new Date(value); + + return Number.isNaN(date.getTime()) ? null : date.toISOString(); } function zammadPreferenceValue(ticket: Record, key: string) { @@ -700,7 +709,8 @@ function zammadTicketImportFromRaw(rawTicket: Record, fallbackT preferences, teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")), teamspaceProject: teamspaceProjects[0] ?? null, - teamspaceProjects + teamspaceProjects, + pendingTime: parseZammadPendingTime(zammadPreferenceValue(rawTicket, "pending_time")) }; } @@ -1883,7 +1893,8 @@ function zammadSyncExistingFields(value: unknown): ZammadSyncExistingFields { type: optionalBoolean(source.type, false), projectName: optionalBoolean(source.projectName, false), budget: optionalBoolean(source.budget, false), - overrideBudget: optionalBoolean(source.overrideBudget, false) + overrideBudget: optionalBoolean(source.overrideBudget, false), + pendingTodo: optionalBoolean(source.pendingTodo, false) }; } @@ -2014,7 +2025,8 @@ function zammadSyncSnapshot(zammadTicket: ZammadTicketImport) { preferences: zammadTicket.preferences, teamspaceProject: zammadTicket.teamspaceProject, teamspaceProjects: zammadTicket.teamspaceProjects, - teamspaceBudgetMinutes: zammadTicket.teamspaceBudgetMinutes + teamspaceBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, + pendingTime: zammadTicket.pendingTime }; } @@ -2051,7 +2063,8 @@ function zammadTicketFromSyncSnapshot(snapshot: Record | null): teamspaceProject: typeof snapshot.teamspaceProject === "string" && snapshot.teamspaceProject.trim() ? snapshot.teamspaceProject.trim() : null, teamspaceProjects: Array.isArray(snapshot.teamspaceProjects) ? snapshot.teamspaceProjects.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean) - : parseZammadTeamspaceProjects(snapshot.teamspaceProject) + : parseZammadTeamspaceProjects(snapshot.teamspaceProject), + pendingTime: parseZammadPendingTime(snapshot.pendingTime) }; } @@ -2160,6 +2173,63 @@ function formatTeamspaceProjectList(projects: Array) return projects.map((project) => project?.trim()).filter(Boolean).join(" + "); } +function zammadPendingTodoPayload(zammadTicket: ZammadTicketImport) { + if ((zammadTicket.zammadStateId !== 3 && zammadTicket.zammadStateId !== 7) || !zammadTicket.pendingTime) { + return null; + } + + const kind = zammadTicket.zammadStateId === 7 ? "Abschluss" : "Erinnerung"; + const day = zammadTicket.pendingTime.slice(0, 10); + const ticketLabel = zammadTicket.ticketNumber.replace(/^Ticket#?/i, "").replace(/^#/, ""); + + return { + day, + title: `${kind} ${ticketLabel ? `Ticket#${ticketLabel}` : "Ticket"}`, + description: `${kind} für dieses Ticket prüfen.` + }; +} + +async function zammadPendingTodoExists(client: QueryClient, ticketId: string, userId: string, zammadTicket: ZammadTicketImport) { + const payload = zammadPendingTodoPayload(zammadTicket); + + if (!payload) { + return true; + } + + const result = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM ticket_todos + WHERE ticket_id = $1 + AND user_id = $2 + AND title = $3 + AND starts_on = $4::date + AND ends_on = $4::date + ) AS exists; + `, + [ticketId, userId, payload.title, payload.day] + ); + + return Boolean(result.rows[0]?.exists); +} + +async function ensureZammadPendingTodo(client: QueryClient, ticketId: string, userId: string, zammadTicket: ZammadTicketImport) { + const payload = zammadPendingTodoPayload(zammadTicket); + + if (!payload || await zammadPendingTodoExists(client, ticketId, userId, zammadTicket)) { + return; + } + + await client.query( + ` + INSERT INTO ticket_todos (ticket_id, user_id, title, description, priority, starts_on, ends_on, sort_order) + VALUES ($1, $2, $3, $4, 'high', $5::date, $5::date, (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2)); + `, + [ticketId, userId, payload.title, payload.description, payload.day] + ); +} + async function syncProjectTeamspaceEntriesFromZammad( client: QueryClient, projectId: string, @@ -2251,6 +2321,11 @@ async function planExistingZammadTicketSync( addSyncChange(changes, "Zammad-Priorität", localTicket.zammad_priority_id, zammadTicket.zammadPriorityId); addSyncChange(changes, "Zammad-Status", localTicket.zammad_state_id, zammadTicket.zammadStateId); + const pendingTodo = zammadPendingTodoPayload(zammadTicket); + if (fields.pendingTodo && pendingTodo && !(await zammadPendingTodoExists(client, localTicket.id, userId, zammadTicket))) { + changes.push({ field: "ToDo", from: null, to: `${pendingTodo.title} (${pendingTodo.day})` }); + } + if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) { return changes.length > 0 ? { @@ -2579,6 +2654,10 @@ async function applyZammadTicketSync( ); } + if (fields.pendingTodo) { + await ensureZammadPendingTodo(client, localTicket.id, userId, zammadTicket); + } + if (!metadataOnly && fields.type && zammadTicket.workType === "support") { await deleteAllProjectDataForTicket(client, localTicket.id); await client.query( diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index cdfe581..9c29fcb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -69,7 +69,7 @@ import { cn } from "./lib/utils"; import { ApiError, deleteTodo, getCurrentUser, getRecurringBillings, getSetupStatus, getTodos, logout, lookupTicket, searchTickets, updateTodo } from "./api"; import { currentDay, formatDateTime, formatMinutes, formatTimer } from "./format"; import { isRecurringBillingTicketNumber, ticketHasRecurringBilling } from "./recurring-billings"; -import { activeElapsedMs, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers"; +import { activeElapsedMs, createClientId, pauseEntry, readStoredTimers, resumeEntry, storageKeyForUser, ticketPattern, type TimerEntry } from "./timers"; import { formatTodoPeriod, isTodoActiveOnDay, isTodoFixedOnDay, isTodoOverdue } from "./todos"; import { AdminUsersPage } from "./views/AdminUsersPage"; import { AnalysisPage } from "./views/AnalysisPage"; @@ -679,7 +679,7 @@ function SidebarTodayTodos({ todos, onNavigate }: SidebarTodayTodosProps) {