diff --git a/backend/src/index.ts b/backend/src/index.ts index 7ba3241..cc31a8f 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1097,6 +1097,32 @@ function parseProjectTeamspaceEntries( return entries; } +type ParsedProjectTeamspaceEntry = ReturnType[number]; + +function budgetMinutesChanged(left: number | null | undefined, right: number | null | undefined) { + return (left ?? null) !== (right ?? null); +} + +function protectNewManualBudgetEntries(entries: ParsedProjectTeamspaceEntry[]) { + return entries.map((entry) => entry.hourBudgetMinutes !== null ? { ...entry, budgetSyncDisabled: true } : entry); +} + +function protectChangedManualBudgetEntries( + entries: ParsedProjectTeamspaceEntry[], + currentEntries: Array<{ id: string; hour_budget_minutes: number | null }> +) { + const currentById = new Map(currentEntries.map((entry) => [entry.id, entry.hour_budget_minutes])); + + return entries.map((entry) => { + const currentBudget = entry.id ? currentById.get(String(entry.id)) : undefined; + const manuallyChanged = entry.id + ? currentBudget !== undefined && budgetMinutesChanged(currentBudget, entry.hourBudgetMinutes) + : entry.hourBudgetMinutes !== null; + + return manuallyChanged ? { ...entry, budgetSyncDisabled: true } : entry; + }); +} + function parseTodoPriority(value: unknown) { if (value !== "low" && value !== "medium" && value !== "high" && value !== "urgent") { throw badRequest("priority must be low, medium, high or urgent"); @@ -1994,8 +2020,11 @@ function syncComparableValueForField(field: string, value: unknown) { return syncIdComparableValue(value); } - if (field === "Teamspace-Projekte" && Array.isArray(value)) { - return value.map((entry) => syncComparableValue(entry)); + if (field === "Teamspace-Projekte") { + const entries = Array.isArray(value) + ? value.map((entry) => (typeof entry === "string" ? entry : null)) + : parseZammadTeamspaceProjects(value); + return normalizedTeamspaceProjectList(entries).sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" })); } return syncComparableValue(value); @@ -2007,7 +2036,7 @@ function syncValueChanged(field: string, left: unknown, right: unknown) { function addSyncChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) { if (syncValueChanged(field, from, to)) { - changes.push({ field, from: syncComparableValue(from), to: syncComparableValue(to) }); + changes.push({ field, from: syncComparableValueForField(field, from), to: syncComparableValueForField(field, to) }); } } @@ -2068,7 +2097,11 @@ function zammadTicketFromSyncSnapshot(snapshot: Record | null): }; } -function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProjectSnapshot | null = null) { +function localSyncSnapshot( + localTicket: LocalZammadSyncTicket, + project: SyncProjectSnapshot | null = null, + options: { zammadPendingTodoExists?: boolean | null } = {} +) { return { ticketId: localTicket.id, ticketNumber: localTicket.ticket_number, @@ -2081,6 +2114,7 @@ function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProj zammadPriorityId: localTicket.zammad_priority_id, zammadStateId: localTicket.zammad_state_id, zammadOwnerId: localTicket.zammad_owner_id, + zammadPendingTodoExists: options.zammadPendingTodoExists ?? null, project: project ? { id: project.id, @@ -2169,8 +2203,60 @@ function zammadTeamspaceProjectList(zammadTicket: ZammadTicketImport) { return zammadTicket.teamspaceProjects.length ? zammadTicket.teamspaceProjects : zammadTicket.teamspaceProject ? [zammadTicket.teamspaceProject] : []; } +function normalizedTeamspaceProjectList(projects: Array) { + const seen = new Set(); + const normalized: string[] = []; + + for (const project of projects) { + const value = project?.trim(); + + if (!value || seen.has(value)) { + continue; + } + + seen.add(value); + normalized.push(value); + } + + return normalized; +} + function formatTeamspaceProjectList(projects: Array) { - return projects.map((project) => project?.trim()).filter(Boolean).join(" + "); + return normalizedTeamspaceProjectList(projects).join(" + "); +} + +function teamspaceProjectDiff(localProjects: Array, zammadProjects: Array) { + const local = normalizedTeamspaceProjectList(localProjects); + const zammad = normalizedTeamspaceProjectList(zammadProjects); + const localSet = new Set(local); + const zammadSet = new Set(zammad); + + return { + local, + zammad, + additions: zammad.filter((project) => !localSet.has(project)), + missingInZammad: local.filter((project) => !zammadSet.has(project)) + }; +} + +function addTeamspaceProjectSyncChanges(changes: ZammadSyncChange[], localProjects: Array, zammadProjects: Array) { + const diff = teamspaceProjectDiff(localProjects, zammadProjects); + + if (diff.additions.length > 0) { + changes.push({ + field: "Teamspace-Projekte", + from: diff.local, + to: [...diff.local, ...diff.additions] + }); + } + + if (diff.missingInZammad.length > 0) { + changes.push({ + field: "Teamspace-Prüfung", + from: diff.missingInZammad, + to: "nicht mehr aus Zammad; bitte manuell prüfen" + }); + } } function zammadPendingTodoPayload(zammadTicket: ZammadTicketImport) { @@ -2224,7 +2310,16 @@ async function ensureZammadPendingTodo(client: QueryClient, ticketId: string, us 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)); + SELECT $1, $2, $3, $4, 'high', $5::date, $5::date, (SELECT COALESCE(MAX(sort_order), 0) + 1024 FROM ticket_todos WHERE user_id = $2) + WHERE NOT EXISTS ( + SELECT 1 + FROM ticket_todos + WHERE ticket_id = $1 + AND user_id = $2 + AND title = $3 + AND starts_on = $5::date + AND ends_on = $5::date + ); `, [ticketId, userId, payload.title, payload.description, payload.day] ); @@ -2237,13 +2332,15 @@ async function syncProjectTeamspaceEntriesFromZammad( budgetMinutes: number | null, syncBudget: boolean ) { - if (teamspaceProjects.length === 0) { + const zammadProjects = normalizedTeamspaceProjectList(teamspaceProjects); + + if (zammadProjects.length === 0) { return; } - const existingResult = await client.query<{ id: string }>( + const existingResult = await client.query<{ id: string; teamspace_project: string | null; is_default: boolean; display_order: number }>( ` - SELECT id::text AS id + SELECT id::text AS id, teamspace_project, is_default, display_order FROM project_teamspace_entries WHERE project_id = $1 ORDER BY is_default DESC, display_order ASC, id ASC @@ -2251,51 +2348,69 @@ async function syncProjectTeamspaceEntriesFromZammad( `, [projectId] ); - const savedIds: string[] = []; - await client.query("UPDATE project_teamspace_entries SET is_default = false, updated_at = now() WHERE project_id = $1;", [projectId]); - - for (const [index, teamspaceProject] of teamspaceProjects.entries()) { - const existing = existingResult.rows[index]; - const isDefault = index === 0; - - if (existing) { + if (existingResult.rows.length === 0) { + for (const [index, teamspaceProject] of zammadProjects.entries()) { await client.query( - ` - UPDATE project_teamspace_entries - SET teamspace_project = $1, - is_default = $2, - hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END, - budget_sync_disabled = CASE WHEN $3::boolean THEN false ELSE budget_sync_disabled END, - display_order = $5, - updated_at = now() - WHERE id = $6 - AND project_id = $7; - `, - [teamspaceProject, isDefault, syncBudget && isDefault, budgetMinutes, index * 1024, existing.id, projectId] - ); - savedIds.push(existing.id); - } else { - const inserted = await client.query<{ id: string }>( ` INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) - VALUES ($1, $2, $3, $4, false, 0, $5) - RETURNING id::text AS id; + VALUES ($1, $2, $3, $4, false, 0, $5); `, - [projectId, teamspaceProject, isDefault, syncBudget && isDefault ? budgetMinutes : null, index * 1024] + [projectId, teamspaceProject, index === 0, syncBudget && index === 0 ? budgetMinutes : null, index * 1024] ); - savedIds.push(inserted.rows[0].id); } + + await client.query( + ` + UPDATE projects + SET teamspace_project = $2, + hour_budget_minutes = CASE WHEN $3::boolean THEN $4 ELSE hour_budget_minutes END, + updated_at = now() + WHERE id = $1; + `, + [projectId, zammadProjects[0] ?? null, syncBudget, budgetMinutes] + ); + return; } - await client.query( - ` - DELETE FROM project_teamspace_entries - WHERE project_id = $1 - AND NOT (id = ANY($2::bigint[])); - `, - [projectId, savedIds] - ); + let defaultEntry = existingResult.rows.find((entry) => entry.is_default) ?? existingResult.rows[0]; + + if (!defaultEntry.is_default) { + await client.query("UPDATE project_teamspace_entries SET is_default = (id = $2), updated_at = now() WHERE project_id = $1;", [projectId, defaultEntry.id]); + defaultEntry = { ...defaultEntry, is_default: true }; + } + + const existingTeamspaceProjects = new Set(normalizedTeamspaceProjectList(existingResult.rows.map((entry) => entry.teamspace_project))); + let nextDisplayOrder = Math.max(...existingResult.rows.map((entry) => Number(entry.display_order ?? 0)), 0) + 1024; + + for (const teamspaceProject of zammadProjects) { + if (existingTeamspaceProjects.has(teamspaceProject)) { + continue; + } + + await client.query( + ` + INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, manual_consumption_minutes, display_order) + VALUES ($1, $2, false, NULL, false, 0, $3); + `, + [projectId, teamspaceProject, nextDisplayOrder] + ); + nextDisplayOrder += 1024; + } + + if (syncBudget) { + await client.query( + ` + UPDATE project_teamspace_entries + SET hour_budget_minutes = $1, + budget_sync_disabled = false, + updated_at = now() + WHERE id = $2 + AND project_id = $3; + `, + [budgetMinutes, defaultEntry.id, projectId] + ); + } await client.query( ` @@ -2305,7 +2420,7 @@ async function syncProjectTeamspaceEntriesFromZammad( updated_at = now() WHERE id = $1; `, - [projectId, teamspaceProjects[0] ?? null, syncBudget, budgetMinutes] + [projectId, defaultEntry.teamspace_project, syncBudget, budgetMinutes] ); } @@ -2317,12 +2432,13 @@ async function planExistingZammadTicketSync( fields: ZammadSyncExistingFields ): Promise { const changes: ZammadSyncChange[] = []; + const pendingTodo = zammadPendingTodoPayload(zammadTicket); + const pendingTodoExists = pendingTodo ? await zammadPendingTodoExists(client, localTicket.id, userId, zammadTicket) : null; 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))) { + if (fields.pendingTodo && pendingTodo && !pendingTodoExists) { changes.push({ field: "ToDo", from: null, to: `${pendingTodo.title} (${pendingTodo.day})` }); } @@ -2332,7 +2448,7 @@ async function planExistingZammadTicketSync( ticketNumber: localTicket.ticket_number, action: "update", reason: "Zammad-Metadaten werden aktualisiert.", - local: localSyncSnapshot(localTicket), + local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), changes } @@ -2346,7 +2462,7 @@ async function planExistingZammadTicketSync( ticketNumber: localTicket.ticket_number, action: "skip", reason: "Zammad-Organisation ist lokal nicht synchronisiert.", - local: localSyncSnapshot(localTicket), + local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), changes: [] }; @@ -2374,7 +2490,7 @@ async function planExistingZammadTicketSync( ticketNumber: localTicket.ticket_number, action: changes.length > 0 ? "update" : "skip", reason: changes.length > 0 ? "Ticket wird als Support synchronisiert." : "Keine Änderung nach aktueller Auswahl.", - local: localSyncSnapshot(localTicket), + local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), changes }; @@ -2390,7 +2506,7 @@ async function planExistingZammadTicketSync( const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); const localTeamspaceProjects = project?.teamspace_entries?.length ? project.teamspace_entries.map((entry) => entry.teamspace_project) - : [project?.teamspace_project ?? null]; + : project?.teamspace_project ? [project.teamspace_project] : []; const canSyncBudget = fields.budget && (!project?.default_budget_sync_disabled || fields.overrideBudget); if (zammadTicket.zammadStateId === 4 && project?.closed_at) { @@ -2402,7 +2518,7 @@ async function planExistingZammadTicketSync( } if (fields.projectName) { - addSyncChange(changes, "Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects); + addTeamspaceProjectSyncChanges(changes, localTeamspaceProjects, zammadTeamspaceProjects); } if (canSyncBudget) { @@ -2424,7 +2540,7 @@ async function planExistingZammadTicketSync( ticketNumber: localTicket.ticket_number, action: changes.length > 0 ? "update" : "skip", reason: changes.length > 0 ? "Ticket wird aktualisiert." : "Keine Änderung nach aktueller Auswahl.", - local: localSyncSnapshot(localTicket, project), + local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }), zammad: zammadSyncSnapshot(zammadTicket), changes }; @@ -6230,6 +6346,8 @@ app.post("/api/projects", requireUser, async (req, res) => { if (zammadTicket) { const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; + const defaultBudgetManuallyChanged = currentDefaultTeamspaceEntry.hourBudgetMinutes !== null + && budgetMinutesChanged(currentDefaultTeamspaceEntry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes); teamspaceEntries = zammadTeamspaceProjects.length > 0 ? zammadTeamspaceProjects.map((teamspaceProjectName, index) => ({ @@ -6237,7 +6355,7 @@ app.post("/api/projects", requireUser, async (req, res) => { teamspaceProject: teamspaceProjectName, isDefault: index === 0, hourBudgetMinutes: index === 0 ? currentDefaultTeamspaceEntry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes : null, - budgetSyncDisabled: false, + budgetSyncDisabled: index === 0 && (currentDefaultTeamspaceEntry.budgetSyncDisabled || defaultBudgetManuallyChanged), manualConsumptionMinutes: index === 0 ? currentDefaultTeamspaceEntry.manualConsumptionMinutes : 0, displayOrder: index * 1024 })) @@ -6247,10 +6365,14 @@ app.post("/api/projects", requireUser, async (req, res) => { ...entry, teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject, hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes, - budgetSyncDisabled: false + budgetSyncDisabled: entry.budgetSyncDisabled || (entry.hourBudgetMinutes !== null && budgetMinutesChanged(entry.hourBudgetMinutes, zammadTicket.teamspaceBudgetMinutes)) } : entry - ); + ) + .map((entry) => !entry.isDefault && entry.hourBudgetMinutes !== null ? { ...entry, budgetSyncDisabled: true } : entry); + defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; + } else { + teamspaceEntries = protectNewManualBudgetEntries(teamspaceEntries); defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; } @@ -6474,8 +6596,8 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { const endsOn = parseOptionalDateOnly(req.body.endsOn, "endsOn"); const hourBudgetMinutes = parseOptionalNonNegativeInteger(req.body.hourBudgetMinutes, "hourBudgetMinutes"); const manualConsumptionMinutes = parseOptionalNonNegativeInteger(req.body.manualConsumptionMinutes, "manualConsumptionMinutes") ?? 0; - const teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes }); - const defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; + let teamspaceEntries = parseProjectTeamspaceEntries(req.body.teamspaceEntries, { teamspaceProject, hourBudgetMinutes, manualConsumptionMinutes }); + let defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; if (startsOn && endsOn && endsOn < startsOn) { throw badRequest("endsOn must be after startsOn"); @@ -6498,6 +6620,18 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { return null; } + const currentTeamspaceEntries = await client.query<{ id: string; hour_budget_minutes: number | null }>( + ` + SELECT id::text AS id, hour_budget_minutes + FROM project_teamspace_entries + WHERE project_id = $1 + FOR UPDATE; + `, + [projectId] + ); + teamspaceEntries = protectChangedManualBudgetEntries(teamspaceEntries, currentTeamspaceEntries.rows); + defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; + const organization = await getOrganizationByZammadId(client, organizationId); if (!organization) { diff --git a/frontend/src/api.ts b/frontend/src/api.ts index d563e26..ff6994e 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -375,7 +375,7 @@ function wait(milliseconds: number) { return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); } -async function waitForZammadSyncJob(jobId: string) { +export async function waitForZammadSyncJob(jobId: string) { const startedAt = Date.now(); const timeoutMs = 10 * 60 * 1000; @@ -398,12 +398,13 @@ async function waitForZammadSyncJob(jobId: string) { } } -async function runZammadSyncJob(url: string, body: Record) { +async function runZammadSyncJob(url: string, body: Record, onJobStarted?: (job: ZammadSyncJob) => void) { const { job } = await request<{ job: ZammadSyncJob }>(url, { method: "POST", timeoutMs: 20_000, body: JSON.stringify(body) }); + onJobStarted?.(job); const result = await waitForZammadSyncJob(job.id); return { jobId: job.id, result }; @@ -428,30 +429,30 @@ export async function importOwnZammadConsultingTickets(options: { dryRun?: boole return result; } -export function previewExistingZammadTickets(fields: ZammadSyncExistingFields) { +export function previewExistingZammadTickets(fields: ZammadSyncExistingFields, onJobStarted?: (job: ZammadSyncJob) => void) { return runZammadSyncJob("/api/zammad-sync/jobs/existing", { fields, dryRun: true - }); + }, onJobStarted); } -export function applyExistingZammadTicketsFromPreview(previewJobId: string, fields: ZammadSyncExistingFields, ticketNumbers: string[]) { +export function applyExistingZammadTicketsFromPreview(previewJobId: string, fields: ZammadSyncExistingFields, ticketNumbers: string[], onJobStarted?: (job: ZammadSyncJob) => void) { return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-existing`, { fields, ticketNumbers - }); + }, onJobStarted); } -export function previewOwnZammadConsultingTickets() { +export function previewOwnZammadConsultingTickets(onJobStarted?: (job: ZammadSyncJob) => void) { return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", { dryRun: true - }); + }, onJobStarted); } -export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string, ticketNumbers: string[]) { +export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string, ticketNumbers: string[], onJobStarted?: (job: ZammadSyncJob) => void) { return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-consulting-import`, { ticketNumbers - }); + }, onJobStarted); } export function getRecurringBillings() { diff --git a/frontend/src/help-content.ts b/frontend/src/help-content.ts index 7608f4f..f27e1ca 100644 --- a/frontend/src/help-content.ts +++ b/frontend/src/help-content.ts @@ -16,6 +16,7 @@ export const helpSections: HelpSection[] = [ quickItems: [ "Sessions sind die getrackten oder nachgetragenen Arbeitszeiten.", "Teamspace-Werte sind Tageswerte pro Ticket, nicht einzelne Sessions.", + "Projekt-Sessions können einem Teamspace-Eintrag zugeordnet sein.", "Abgeschlossen wird nur der Monat." ], details: [ @@ -23,6 +24,7 @@ export const helpSections: HelpSection[] = [ "TicketTracker ersetzt auch nicht das CRM. Die CRM- beziehungsweise Teamspace-Zeit wird als Kontrollwert gepflegt, damit abgerechnete Zeiten und eingetragene Abrechnung gegenübergestellt werden können.", "Eine Session ist immer einem Benutzer zugeordnet. In den normalen Auswertungen sieht jeder Benutzer nur seine eigenen Sessions.", "Ticketdaten wie Organisation und Art können aus bestehenden Tickets übernommen werden, auch wenn ein anderer Benutzer das Ticket schon bearbeitet hat.", + "Consulting-Projekte können einen oder mehrere Teamspace-Einträge haben. Einer davon ist immer der Standard-Eintrag.", "Tickets werden nicht abgeschlossen. Sie gruppieren Sessions. Relevant für den Abschluss ist nur, ob alle Sessions eines Monats bewertet sind.", "Der Monatsabschluss sperrt den Monat gegen versehentliche Änderungen. Muss doch noch etwas korrigiert werden, kann der Monat wieder geöffnet werden." ] @@ -41,6 +43,7 @@ export const helpSections: HelpSection[] = [ "Während der Arbeit läuft genau ein Timer aktiv. Wechselst du zu einem anderen Timer, pausiert TicketTracker die übrigen Timer automatisch.", "Wenn die Arbeit erledigt ist, beendest du den Timer. Die Zeit stoppt sofort beim Klick auf Beenden; das anschließende Ausfüllen der Tätigkeit zählt nicht mehr zur Sessionzeit.", "Im Beenden-Dialog trägst du die Tätigkeit ein. Wenn das Ticket bereits bekannt ist, übernimmt TicketTracker Organisation und Art automatisch. Bei neuen Tickets wählst du Organisation und Art zusätzlich aus.", + "Hat ein Consulting-Projekt mehrere Teamspace-Einträge, wählst du beim Speichern der Session den passenden Eintrag aus. Bei genau einem Teamspace-Eintrag ordnet TicketTracker automatisch zu.", "Vergessene Sessions trägst du auf der Seite Session starten im Bereich Session nachtragen nach. Wenn das Ticket bereits in der Ticketliste geöffnet ist, nutzt du in der Ticketdetailansicht ebenfalls Session nachtragen; dann ist die Zuordnung zum Ticket bereits vorbelegt.", "Unter Tickets wählst du Monat oder Tag, öffnest ein Ticket über Öffnen und siehst dort alle Sessions nach Tag gruppiert.", "Im Ticket bewertest du jede Session mit Abgerechnet oder Nicht abrechenbar. Ein erneuter Klick auf den aktiven Status nimmt die Bewertung zurück.", @@ -116,6 +119,8 @@ export const helpSections: HelpSection[] = [ "Wenn ein anderer Timer aktiviert wird, pausiert TicketTracker die übrigen Timer automatisch. Dadurch kann nie versehentlich auf zwei Tickets gleichzeitig Zeit laufen.", "Beim Klick auf Beenden wird die Stoppuhr sofort angehalten. Das Speichern der Tätigkeit findet danach statt und verlängert die gemessene Zeit nicht.", "Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Art. Dann muss beim Beenden nur noch die Tätigkeit eingetragen werden.", + "Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt der Beenden-Dialog eine Auswahl. Der Standard-Eintrag ist dort mit Standard gekennzeichnet.", + "Bei Projekt-Tickets mit genau einem Teamspace-Eintrag wird die Session automatisch diesem Eintrag zugeordnet.", "Existiert das Ticket noch nicht, müssen Organisation, Tätigkeit und Art angegeben werden.", "Pausen werden von der Gesamtdauer abgezogen. Die gespeicherte Zeit wird auf volle Minuten gerundet.", "Der Schnellstart im Kopfbereich funktioniert auf allen Unterseiten und nutzt dieselbe Timerlogik." @@ -136,7 +141,8 @@ export const helpSections: HelpSection[] = [ "Die Organisation muss aus der synchronisierten Organisationsliste gewählt werden. Freitext ist absichtlich gesperrt.", "Die Tätigkeit ist eine Textarea. Zeilenumbrüche bleiben erhalten und werden in der Ticketdetailansicht wieder ausgegeben.", "Nachgetragene Sessions starten immer offen. Sie müssen später als Abgerechnet oder Nicht abrechenbar bewertet werden.", - "In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet." + "In der Ticketdetailansicht kann eine Session direkt zum geöffneten Ticket nachgetragen werden. Ticketnummer und Stammdaten sind dort schon vorbelegt beziehungsweise aus dem Ticket abgeleitet.", + "Bei Consulting-Projekten mit mehreren Teamspace-Einträgen ist der passende Teamspace-Eintrag Pflicht. Bei nur einem Eintrag wird er automatisch gesetzt." ] }, { @@ -209,10 +215,12 @@ export const helpSections: HelpSection[] = [ "Oben steht die Ticketnummer mit Copy-Icon, damit sie schnell in andere Systeme übernommen werden kann.", "Die Session-Einträge sind nach Kalendertagen gruppiert. Pro Tagesgruppe steht die Summe der abgerechneten Zeit.", "Nicht abrechenbare und offene Zeiten werden neben der abgerechneten Tageszeit separat angezeigt.", + "Bei Projekt-Tickets mit mehreren Teamspace-Einträgen zeigt jede zugeordnete Session den gewählten Teamspace-Eintrag. Nicht zugeordnete Sessions werden hervorgehoben.", "Das Teamspace-Feld wird als Stundenwert gepflegt, zum Beispiel 1, 1,5 oder 2.25.", "Während der Eingabe wird der Teamspace-Wert nicht sofort gespeichert. Sobald eine Änderung vorliegt, erscheint ein Speichern-Button.", "Ein leeres Teamspace-Feld entfernt den gespeicherten Teamspace-Wert für diesen Ticket-Tag.", "Der Teamspace-Wert hängt am Ticket, Benutzer und Tag. Deshalb ist er in Monats- und Tagesansicht identisch.", + "Im Projektbereich der Ticketdetailansicht werden einzelne Teamspace-Budgetzeilen nur angezeigt, wenn mehrere Teamspace-Einträge tatsächlich ein Budget haben. Ein einzelnes Budget bleibt im Hauptbalken.", "Geöffnete Tickets werden in der Seitenleiste unter Geöffnete Tickets abgelegt und bleiben nach Refresh oder Browserneustart erhalten.", "Die Reihenfolge bleibt beim Anklicken stabil und kann nur per Drag and Drop geändert werden.", "Der Schließen-Button in der Seitenleiste entfernt ein Ticket nur, wenn im aktuell geöffneten Ticket keine ungespeicherten Werte mehr vorhanden sind.", @@ -244,6 +252,7 @@ export const helpSections: HelpSection[] = [ quickItems: [ "Projekte werden über eine Ticketnummer angelegt.", "Vorhandene Ticketdaten werden automatisch übernommen.", + "Ein Projekt kann mehrere Teamspace-Einträge haben.", "Projekt-Sessions sind immer Consulting." ], details: [ @@ -251,11 +260,18 @@ export const helpSections: HelpSection[] = [ "Beim Anlegen gibst du die Ticketnummer ein. Existiert das Ticket bereits, übernimmt TicketTracker Organisation und Stammdaten automatisch.", "Existiert das Ticket noch nicht, muss eine Organisation aus der synchronisierten Organisationsliste gewählt werden.", "Ein Projekt-Ticket bleibt ein normales Ticket. Es kann über Suche, Tickets, geöffnete Tickets und ToDos geöffnet werden.", - "Zusätzlich speicherst du Teamspace-Projekt, Beschreibung/Ziele, ungefähren Zeitraum und gesamtes Stundenkontingent.", - "Der Projektverbrauch wird nur aus gepflegten Teamspace-Tageswerten und einem optionalen manuellen Projektverbrauch berechnet. Getrackte Sessionzeiten zählen nicht in den Projektverbrauch.", + "Zusätzlich speicherst du einen oder mehrere Teamspace-Einträge, Beschreibung/Ziele, ungefähren Zeitraum und Budgets.", + "Der erste Teamspace-Eintrag ist der Standard. Er bekommt die aus Teamspace gebuchten Tageswerte und das Budget aus Zammad, solange kein Budgetschutz greift.", + "Hat ein Projekt genau einen Teamspace-Eintrag, ordnet TicketTracker Consulting-Sessions automatisch diesem Eintrag zu. Bei mehreren Einträgen muss der passende Eintrag beim Speichern oder Bearbeiten der Session gewählt werden.", + "Die Auswahl zeigt den Standard-Eintrag ausdrücklich mit Standard an, auch wenn der Eintrag eine normale Teamspace-ID hat.", + "Der Projektverbrauch wird aus gepflegten Teamspace-Tageswerten und optionalem manuellem Verbrauch je Teamspace-Eintrag berechnet. Getrackte Sessionzeiten zählen nicht in den Projektverbrauch.", "Der manuelle Projektverbrauch ist für Altbestände oder einmalige Vorbuchungen gedacht und wirkt ausschließlich im Projektverbrauch. Er erscheint nicht in Auswertungen, Statistiken, CSV-Export oder Monatsabschluss.", + "Manuelle Änderungen am Budget setzen den Budgetschutz des betroffenen Teamspace-Eintrags automatisch. Der Haken kann im Projekt wieder entfernt werden, wenn das Budget künftig wieder aus Zammad synchronisiert werden soll.", "Projekte können offen oder geschlossen sein. Standardmäßig zeigt die Projektseite offene Projekte; geschlossene können zusätzlich eingeblendet werden.", - "Auf der Projektseite kannst du Projekte nach Freitext, Status, Organisation, Kontingentstatus und ToDos filtern sowie nach Ticket, Titel, Organisation, Teamspace-ID, Verbrauch, Reststunden, Kontingent oder ToDos sortieren. Diese Einstellungen bleiben im Browser gespeichert.", + "Auf der Projektseite gibt es ein gemeinsames Filter-Dropdown. Filter derselben Gruppe wirken als Oder, unterschiedliche Gruppen werden kombiniert.", + "Filterbar sind unter anderem TicketTracker-Status, Zammad-Status, Zammad-Priorität, Organisation, Kontingentstatus und Nur Std.-Budget.", + "Nur Std.-Budget zeigt Projekte, bei denen ein Budget vorhanden ist und dieses ausschließlich auf dem Standard-Teamspace-Eintrag liegt.", + "In der Projektliste und im Budget-Widget werden einzelne Teamspace-Budgetzeilen nur angezeigt, wenn mehrere Teamspace-Einträge ein Budget haben. Nicht budgetierte Einträge werden dort ausgeblendet.", "Reststunden werden aus dem Stundenkontingent minus Projektverbrauch berechnet. Gibt es kein Kontingent, kann TicketTracker keinen offenen Rest berechnen.", "Offene Projekte stehen immer vor geschlossenen Projekten. Der Projektstatus ändert nicht die Anzeige der Sessions im Ticket.", "Projekt-Tickets sind immer Consulting. Auch wenn Sessions später über den normalen Timer oder das Nachtragen erfasst werden, erkennt TicketTracker das Projekt und übernimmt Consulting.", @@ -282,6 +298,7 @@ export const helpSections: HelpSection[] = [ "In der linken Navigation zeigt die Kachel Termine die festen heutigen ToDos, laufende ToDos als Summe und überfällige Aufgaben.", "Unter der Aufgabenliste zeigt der ToDo-Kalender offene ToDos im Monatsüberblick. Erledigte ToDos werden dort ausgeblendet; Wochenenden und bayerische Feiertage bleiben ohne ToDo-Einträge.", "Ein-Tages-ToDos werden einzeln und farblich nach Priorität angezeigt. Zeiträume, offene Von-Daten und Bis-Daten werden pro Tag nur als Summe angezeigt.", + "Wenn der Zammad-Sync entsprechend aktiviert ist, kann aus Zammad-Wartedaten automatisch ein ToDo als Erinnerung oder Abschlussprüfung erzeugt werden.", "Bei Nur-Von läuft die Summe ab diesem Datum weiter, bis das ToDo erledigt, gelöscht oder mit einem Bis-Datum begrenzt wird. Bei Nur-Bis zählt der Kalender ab heute bis zum Bis-Datum.", "Ein offenes ToDo ist überfällig, wenn Umsetzung bis vor heute liegt. Im Kalender bleibt es ab dem Tag nach der Deadline an jedem Arbeitstag als roter +X üf-Badge sichtbar, bis es erledigt oder gelöscht wird.", "Ein Klick auf ein einzelnes ToDo öffnet die Bearbeitung. Eine Tagessumme öffnet eine kompakte Tagesliste; daraus kannst du ein ToDo separat öffnen, erledigen, löschen oder das Ticket öffnen.", @@ -512,7 +529,7 @@ export const helpSections: HelpSection[] = [ "Beim Speichern eines neuen Tokens fragt TicketTracker Zammad über /api/v1/users/me ab und speichert die zurückgelieferte Zammad User-ID.", "Die gespeicherte Zammad User-ID wird danach read-only im Profil angezeigt.", "Die Token-Prüfung läuft nur, wenn der Token im Profil geändert oder entfernt wird. Normale Profiländerungen prüfen Zammad nicht erneut.", - "Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Es kann zufällig derselbe Token sein, wird aber separat gespeichert und verwendet.", + "Der persönliche Zammad Token ist vom Admin-Token für den Organisations-Sync getrennt. Er wird für ticketbezogene Zammad-Abgleiche, Consulting-Importe und die Zuordnung zum Zammad-Owner verwendet.", "Im Admin-Profil wird kein persönlicher Zammad-Token angezeigt.", "Admins können Benutzer ebenfalls verwalten, aber jeder Benutzer kann die eigenen Daten jederzeit selbst pflegen." ] @@ -531,6 +548,10 @@ export const helpSections: HelpSection[] = [ "Admins können bestehende Benutzer bearbeiten, Passwörter setzen und Benutzer deaktivieren.", "Der feste Admin-Zugang kann nicht deaktiviert oder zur normalen User-Rolle herabgestuft werden.", "Der Data-Sync speichert Zammad-Zugangsdaten und synchronisiert Organisationen in die lokale Datenbank.", + "Der Zammad-Ticket-Sync aktualisiert Titel, Organisation, Typ, Zammad-Status, Zammad-Priorität sowie Teamspace-Projekte und Budgets je nach gewählter Option.", + "Zammad-Projektwerte können mehrere Teamspace-IDs enthalten. TicketTracker erkennt zusammenhängende IDs und legt entsprechend mehrere Teamspace-Einträge an.", + "Beim Budget-Sync bleiben geschützte TicketTracker-Budgets unverändert. Die Option Budget-Schutz im Sync ignorieren überschreibt auch geschützte Budgets mit Zammad-Werten.", + "Optional kann der Ticket-Sync bei Zammad-Status Wartend oder Abschluss und vorhandenem Wartedatum ein hoch priorisiertes ToDo für dieses Datum erzeugen.", "Der Datenbankexport erzeugt eine SQL-Datei mit Benutzern, Einstellungen, Organisationen, Tickets, Sessions, Teamspace-Werten, fixen Abrechnungen und Abschlüssen.", "Der Restore im Adminbereich prüft zuerst, ob die Dump-Struktur zur aktuellen Datenbank passt.", "Der Restore-Button wird erst nach erfolgreicher Prüfung aktiv.", diff --git a/frontend/src/views/DashboardPage.tsx b/frontend/src/views/DashboardPage.tsx index 4e96f04..1d3a63e 100644 --- a/frontend/src/views/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage.tsx @@ -585,6 +585,12 @@ function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, f return budgetMinutes <= 0; } + if (filterBy === "standardBudgetOnly") { + const budgetedEntries = budgetedProjectTeamspaceEntries(project); + + return budgetedEntries.length > 0 && budgetedEntries.every((entry) => entry.is_default); + } + if (filterBy === "critical90") { return budgetMinutes > 0 && percent >= 0.9; } @@ -653,6 +659,10 @@ function projectTeamspaceEntries(project: ProjectSummary): ProjectTeamspaceEntry ]; } +function budgetedProjectTeamspaceEntries(project: ProjectSummary) { + return projectTeamspaceEntries(project).filter((entry) => Number(entry.hour_budget_minutes ?? 0) > 0); +} + function openRatingFilterGroup(filter: string) { if (filter === "fixedBilling" || filter === "manualBilling") { return "source"; @@ -1649,6 +1659,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU [ "budgeted", "withoutBudget", + "standardBudgetOnly", "critical90", "overBudget", "open", @@ -1746,6 +1757,7 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU { value: "critical90", label: "Ab 90%" }, { value: "overBudget", label: "Ab 100%" }, { value: "withoutBudget", label: "Ohne Kontingent" }, + { value: "standardBudgetOnly", label: "Nur Std.-Budget" }, { value: "open", label: "TT Offen" }, { value: "closed", label: "TT Geschl." }, ...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `ZA ${option.label}` })), @@ -1780,14 +1792,14 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU const consumptionMinutes = project.consumption_minutes ?? 0; const budgetMinutes = project.hour_budget_minutes ?? 0; const percent = projectConsumptionPercent(project); - const entries = projectTeamspaceEntries(project); - const hasMultipleTeamspaceEntries = entries.length > 1; + const budgetedEntries = budgetedProjectTeamspaceEntries(project); + const showBudgetedTeamspaceEntries = budgetedEntries.length > 1; return ( + + + setSearch(event.currentTarget.value)} /> +
+ {visibleOptions.length === 0 ? ( +

{emptyLabel}

+ ) : ( +
+ {visibleOptions.map((option) => { + const checked = values.includes(option.value); + + return ( +
toggle(option.value)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + toggle(option.value); + } + }} + > + + {option.label} +
+ ); + })} +
+ )} +
+ {values.length > 0 ? ( + + ) : null} +
+ + ); +} + function parseHoursInput(value: string) { const trimmed = value.trim(); @@ -274,24 +464,42 @@ function projectOrganizationKey(project: ProjectSummary) { return project.organization_id ? `id:${project.organization_id}` : `name:${projectOrganizationLabel(project)}`; } -function projectConsumption(project: ProjectSummary) { +function projectBudget(project: ProjectSummary, standardTeamspaceOnly = false) { + if (standardTeamspaceOnly) { + return defaultProjectTeamspaceEntry(project)?.hour_budget_minutes ?? null; + } + + return project.hour_budget_minutes ?? null; +} + +function projectConsumption(project: ProjectSummary, standardTeamspaceOnly = false) { + if (standardTeamspaceOnly) { + const defaultEntry = defaultProjectTeamspaceEntry(project); + + return defaultEntry ? projectEntryConsumption(defaultEntry) : 0; + } + return Number(project.consumption_minutes ?? 0); } -function projectRemaining(project: ProjectSummary) { - if (project.hour_budget_minutes === null || project.hour_budget_minutes === undefined) { +function projectRemaining(project: ProjectSummary, standardTeamspaceOnly = false) { + const budgetMinutes = projectBudget(project, standardTeamspaceOnly); + + if (budgetMinutes === null || budgetMinutes === undefined) { return null; } - return project.hour_budget_minutes - projectConsumption(project); + return budgetMinutes - projectConsumption(project, standardTeamspaceOnly); } -function projectConsumptionPercent(project: ProjectSummary) { - if (!project.hour_budget_minutes || project.hour_budget_minutes <= 0) { +function projectConsumptionPercent(project: ProjectSummary, standardTeamspaceOnly = false) { + const budgetMinutes = projectBudget(project, standardTeamspaceOnly); + + if (!budgetMinutes || budgetMinutes <= 0) { return null; } - return Math.round((projectConsumption(project) / project.hour_budget_minutes) * 100); + return Math.round((projectConsumption(project, standardTeamspaceOnly) / budgetMinutes) * 100); } function projectMatchesQuery(project: ProjectSummary, query: string) { @@ -317,7 +525,103 @@ function projectMatchesQuery(project: ProjectSummary, query: string) { return values.some((value) => value.toLocaleLowerCase("de-DE").includes(normalized)); } -function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: ProjectSortBy, direction: ProjectSortDirection) { +function groupedProjectFilterMatch(filters: string[], groupForFilter: (filter: string) => string, matchesFilter: (filter: string) => boolean) { + if (filters.length === 0) { + return true; + } + + const groups = new Map(); + + for (const filter of filters) { + const group = groupForFilter(filter); + groups.set(group, [...(groups.get(group) ?? []), filter]); + } + + return Array.from(groups.values()).every((groupFilters) => groupFilters.some((filter) => matchesFilter(filter))); +} + +function projectFilterGroup(filter: string) { + if (filter === "open" || filter === "closed") { + return "status"; + } + + if (filter === "hidden") { + return "visibility"; + } + + if (filter.startsWith("zammadState:")) { + return "zammadState"; + } + + if (filter.startsWith("zammadPriority:")) { + return "zammadPriority"; + } + + return "budget"; +} + +function matchesSingleProjectFilter(project: ProjectSummary, filter: string, standardTeamspaceOnly = false) { + if (filter === "open") { + return !project.closed_at; + } + + if (filter === "closed") { + return Boolean(project.closed_at); + } + + if (filter === "hidden") { + return Boolean(project.hidden_for_user); + } + + if (filter.startsWith("zammadState:")) { + return matchesZammadStateFilter(project.zammad_state_id, filter.slice("zammadState:".length) as Parameters[1]); + } + + if (filter.startsWith("zammadPriority:")) { + return matchesZammadPriorityFilter(project.zammad_priority_id, filter.slice("zammadPriority:".length) as Parameters[1]); + } + + const remaining = projectRemaining(project, standardTeamspaceOnly); + const budgetMinutes = projectBudget(project, standardTeamspaceOnly) ?? 0; + + if (filter === "budgeted") { + return budgetMinutes > 0; + } + + if (filter === "withoutBudget") { + return budgetMinutes <= 0; + } + + if (filter === "remaining") { + return budgetMinutes > 0 && remaining !== null && remaining > 0; + } + + if (filter === "overBudget") { + return budgetMinutes > 0 && remaining !== null && remaining < 0; + } + + return true; +} + +function matchesProjectFilters(project: ProjectSummary, filters: string[], organizationValues: string[], standardTeamspaceOnly = false) { + if (organizationValues.length > 0 && !organizationValues.includes(projectOrganizationKey(project))) { + return false; + } + + return groupedProjectFilterMatch(filters, projectFilterGroup, (filter) => matchesSingleProjectFilter(project, filter, standardTeamspaceOnly)); +} + +function projectShouldLoadClosed(filters: string[]) { + const statusFilters = filters.filter((filter) => filter === "open" || filter === "closed"); + + return statusFilters.length === 0 || statusFilters.includes("closed"); +} + +function projectShouldLoadHidden(filters: string[]) { + return filters.includes("hidden"); +} + +function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: ProjectSortBy, direction: ProjectSortDirection, standardTeamspaceOnly = false) { const factor = direction === "asc" ? 1 : -1; let comparison = 0; @@ -334,10 +638,10 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr } else if (sortBy === "zammadPriority") { comparison = zammadPrioritySortRank(left.zammad_priority_id) - zammadPrioritySortRank(right.zammad_priority_id); } else if (sortBy === "consumption") { - comparison = projectConsumption(left) - projectConsumption(right); + comparison = projectConsumption(left, standardTeamspaceOnly) - projectConsumption(right, standardTeamspaceOnly); } else if (sortBy === "consumptionPercent") { - const leftPercent = projectConsumptionPercent(left); - const rightPercent = projectConsumptionPercent(right); + const leftPercent = projectConsumptionPercent(left, standardTeamspaceOnly); + const rightPercent = projectConsumptionPercent(right, standardTeamspaceOnly); if (leftPercent === null || rightPercent === null) { return leftPercent === rightPercent ? compareText(left.ticket_number, right.ticket_number) : leftPercent === null ? 1 : -1; @@ -345,9 +649,9 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr comparison = leftPercent - rightPercent; } else if (sortBy === "remaining") { - comparison = (projectRemaining(left) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right) ?? Number.POSITIVE_INFINITY); + comparison = (projectRemaining(left, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY); } else if (sortBy === "budget") { - comparison = (left.hour_budget_minutes ?? Number.POSITIVE_INFINITY) - (right.hour_budget_minutes ?? Number.POSITIVE_INFINITY); + comparison = (projectBudget(left, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY) - (projectBudget(right, standardTeamspaceOnly) ?? Number.POSITIVE_INFINITY); } else { comparison = new Date(left.updated_at).getTime() - new Date(right.updated_at).getTime(); } @@ -638,7 +942,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { async function load() { setLoading(true); try { - const result = await getProjects(viewSettings.status !== "open", viewSettings.status === "hidden"); + const result = await getProjects(projectShouldLoadClosed(viewSettings.filterValues), projectShouldLoadHidden(viewSettings.filterValues)); setProjects(result.projects); setHiddenProjectCount(result.hiddenCount ?? 0); setTotalProjectCount(result.totalCount ?? result.projects.length); @@ -653,7 +957,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { useEffect(() => { void load(); - }, [viewSettings.status]); + }, [viewSettings.filterValues]); useEffect(() => { window.localStorage.setItem(projectViewSettingsKey, JSON.stringify(viewSettings)); @@ -727,68 +1031,28 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { const filteredProjects = useMemo(() => { return projects .filter((project) => { - if (viewSettings.status === "hidden" && !project.hidden_for_user) { - return false; - } - - if (viewSettings.status === "open" && project.closed_at) { - return false; - } - - if (viewSettings.status === "closed" && !project.closed_at) { - return false; - } - - if (!matchesZammadStateFilter(project.zammad_state_id, viewSettings.zammadStatus)) { - return false; - } - - if (!matchesZammadPriorityFilter(project.zammad_priority_id, viewSettings.zammadPriority)) { - return false; - } - - if (viewSettings.organization !== "all" && projectOrganizationKey(project) !== viewSettings.organization) { - return false; - } - if (!projectMatchesQuery(project, viewSettings.query)) { return false; } - const remaining = projectRemaining(project); - - if (viewSettings.budget === "with-budget" && remaining === null) { - return false; - } - - if (viewSettings.budget === "without-budget" && remaining !== null) { - return false; - } - - if (viewSettings.budget === "remaining" && (remaining === null || remaining <= 0)) { - return false; - } - - if (viewSettings.budget === "over-budget" && (remaining === null || remaining >= 0)) { - return false; - } - - return true; + return matchesProjectFilters(project, viewSettings.filterValues, viewSettings.organizationValues, viewSettings.standardTeamspaceOnly); }) - .sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection)); + .sort((left, right) => compareProjects(left, right, viewSettings.sortBy, viewSettings.sortDirection, viewSettings.standardTeamspaceOnly)); }, [projects, viewSettings]); - const projectConsumptionMinutes = useMemo(() => filteredProjects.reduce((sum, project) => sum + projectConsumption(project), 0), [filteredProjects]); - const remainingMinutes = useMemo( - () => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project) ?? 0, 0), 0), - [filteredProjects] + const projectConsumptionMinutes = useMemo( + () => filteredProjects.reduce((sum, project) => sum + projectConsumption(project, viewSettings.standardTeamspaceOnly), 0), + [filteredProjects, viewSettings.standardTeamspaceOnly] ); + const remainingMinutes = useMemo( + () => filteredProjects.reduce((sum, project) => sum + Math.max(projectRemaining(project, viewSettings.standardTeamspaceOnly) ?? 0, 0), 0), + [filteredProjects, viewSettings.standardTeamspaceOnly] + ); + const closedProjectsLoaded = projectShouldLoadClosed(viewSettings.filterValues); const activeFilterCount = [ viewSettings.query.trim() !== "", - viewSettings.status !== defaultProjectViewSettings.status, - viewSettings.zammadStatus !== defaultProjectViewSettings.zammadStatus, - viewSettings.zammadPriority !== defaultProjectViewSettings.zammadPriority, - viewSettings.organization !== defaultProjectViewSettings.organization, - viewSettings.budget !== defaultProjectViewSettings.budget, + JSON.stringify(viewSettings.filterValues) !== JSON.stringify(defaultProjectViewSettings.filterValues), + viewSettings.organizationValues.length > 0, + viewSettings.standardTeamspaceOnly, viewSettings.sortBy !== defaultProjectViewSettings.sortBy, viewSettings.sortDirection !== defaultProjectViewSettings.sortDirection ].filter(Boolean).length; @@ -1070,7 +1334,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
Geschlossen - {viewSettings.status !== "open" ? closedProjects.length : "-"} + {closedProjectsLoaded ? closedProjects.length : "-"}
Verbrauch @@ -1102,7 +1366,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
- +
- - + + setViewSettings((current) => ({ ...current, filterValues }))} + />
- - -
-
- - -
-
- - -
-
- - + + setViewSettings((current) => ({ ...current, organizationValues }))} + placeholder="Alle Org." + countLabel="Org." + emptyLabel="Keine Org." + />
+
@@ -1943,7 +1955,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO {projectEntries.map((entry) => ( ))} diff --git a/frontend/src/views/TimerPage.tsx b/frontend/src/views/TimerPage.tsx index 51bd85a..59be5ac 100644 --- a/frontend/src/views/TimerPage.tsx +++ b/frontend/src/views/TimerPage.tsx @@ -61,6 +61,12 @@ function ticketTeamspaceEntries(ticket: TicketMeta | null | undefined) { return ticket?.project_teamspace_entries ?? []; } +function teamspaceEntryOptionLabel(entry: { teamspace_project: string | null; is_default: boolean }) { + const label = entry.teamspace_project || "Ohne Teamspace-ID"; + + return entry.is_default ? `${label} (Standard)` : label; +} + function requiresTeamspaceEntry(ticket: TicketMeta | null | undefined) { return Boolean(ticket?.is_project && ticketTeamspaceEntries(ticket).length > 1); } @@ -741,7 +747,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer {ticketTeamspaceEntries(manualLookupTicket).map((entry) => ( ))} @@ -880,7 +886,7 @@ export function TimerPage({ timers, setTimers, selectedTimerId, setSelectedTimer {ticketTeamspaceEntries(finished?.ticket).map((entry) => ( ))} diff --git a/frontend/src/views/ZammadSyncPage.tsx b/frontend/src/views/ZammadSyncPage.tsx index ed0e153..0f3d5f3 100644 --- a/frontend/src/views/ZammadSyncPage.tsx +++ b/frontend/src/views/ZammadSyncPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useSyncExternalStore } from "react"; +import { useEffect, useMemo, useState, useSyncExternalStore } from "react"; import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, RefreshCw } from "lucide-react"; import { toast } from "sonner"; @@ -14,11 +14,14 @@ import { applyOwnZammadConsultingTicketsFromPreview, previewExistingZammadTickets, previewOwnZammadConsultingTickets, + waitForZammadSyncJob, type ZammadSyncExistingFields, type ZammadSyncChange, + type ZammadSyncJob, type ZammadSyncItem, type ZammadSyncResult } from "../api"; +import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client"; import type { AuthUser } from "../types"; import { zammadPriorityLabel, zammadStateLabel } from "../zammad-meta"; @@ -27,6 +30,7 @@ type ZammadSyncPageProps = { }; type SelectionMap = Record; +type RunningJobKind = "preview" | "apply"; const defaultFields: ZammadSyncExistingFields = { title: true, @@ -51,25 +55,39 @@ type ZammadSyncPageState = { fields: ZammadSyncExistingFields; existingResult: ZammadSyncResult | null; existingPreviewJobId: string | null; + existingRunningJobId: string | null; + existingRunningJobKind: RunningJobKind | null; importResult: ZammadSyncResult | null; importPreviewJobId: string | null; + importRunningJobId: string | null; + importRunningJobKind: RunningJobKind | null; existingSelection: SelectionMap; importSelection: SelectionMap; syncingExisting: boolean; importingConsulting: boolean; }; -let zammadSyncPageState: ZammadSyncPageState = { - fields: defaultFields, - existingResult: null, - existingPreviewJobId: null, - importResult: null, - importPreviewJobId: null, - existingSelection: {}, - importSelection: {}, - syncingExisting: false, - importingConsulting: false -}; +function emptyZammadSyncPageState(): ZammadSyncPageState { + return { + fields: defaultFields, + existingResult: null, + existingPreviewJobId: null, + existingRunningJobId: null, + existingRunningJobKind: null, + importResult: null, + importPreviewJobId: null, + importRunningJobId: null, + importRunningJobKind: null, + existingSelection: {}, + importSelection: {}, + syncingExisting: false, + importingConsulting: false + }; +} + +let zammadSyncPageState: ZammadSyncPageState = emptyZammadSyncPageState(); +let hydratedZammadSyncUserId: string | null = null; +let activeZammadSyncStorageUserId: string | null = null; const zammadSyncPageListeners = new Set<() => void>(); @@ -89,9 +107,104 @@ function setZammadSyncPageState(update: Partial | ((current ...zammadSyncPageState, ...(typeof update === "function" ? update(zammadSyncPageState) : update) }; + + if (activeZammadSyncStorageUserId) { + saveZammadSyncPageState(activeZammadSyncStorageUserId, zammadSyncPageState); + } + zammadSyncPageListeners.forEach((listener) => listener()); } +function zammadSyncStorageKey(userId: string) { + return `tickettracker.zammad-sync.${userId}`; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function selectionMap(value: unknown): SelectionMap { + if (!isRecord(value)) { + return {}; + } + + return Object.fromEntries(Object.entries(value).filter(([, selected]) => typeof selected === "boolean")) as SelectionMap; +} + +function syncResult(value: unknown): ZammadSyncResult | null { + if (!isRecord(value) || (value.mode !== "preview" && value.mode !== "applied") || !Array.isArray(value.items)) { + return null; + } + + return value as ZammadSyncResult; +} + +function syncFields(value: unknown): ZammadSyncExistingFields { + const source = isRecord(value) ? value : {}; + + return { + ...defaultFields, + title: typeof source.title === "boolean" ? source.title : defaultFields.title, + organization: typeof source.organization === "boolean" ? source.organization : defaultFields.organization, + type: typeof source.type === "boolean" ? source.type : defaultFields.type, + projectName: typeof source.projectName === "boolean" ? source.projectName : defaultFields.projectName, + budget: typeof source.budget === "boolean" ? source.budget : defaultFields.budget, + overrideBudget: typeof source.overrideBudget === "boolean" ? source.overrideBudget : defaultFields.overrideBudget, + pendingTodo: typeof source.pendingTodo === "boolean" ? source.pendingTodo : defaultFields.pendingTodo + }; +} + +function nullableString(value: unknown) { + return typeof value === "string" && value.trim() ? value : null; +} + +function runningJobKind(value: unknown): RunningJobKind | null { + return value === "preview" || value === "apply" ? value : null; +} + +function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null { + const raw = getLocalStorageValue(zammadSyncStorageKey(userId)); + + if (!raw) { + return null; + } + + try { + const parsed = JSON.parse(raw) as unknown; + + if (!isRecord(parsed)) { + return null; + } + + const existingRunningJobId = nullableString(parsed.existingRunningJobId); + const existingRunningJobKind = runningJobKind(parsed.existingRunningJobKind); + const importRunningJobId = nullableString(parsed.importRunningJobId); + const importRunningJobKind = runningJobKind(parsed.importRunningJobKind); + + return { + fields: syncFields(parsed.fields), + existingResult: syncResult(parsed.existingResult), + existingPreviewJobId: nullableString(parsed.existingPreviewJobId), + existingRunningJobId, + existingRunningJobKind, + importResult: syncResult(parsed.importResult), + importPreviewJobId: nullableString(parsed.importPreviewJobId), + importRunningJobId, + importRunningJobKind, + existingSelection: selectionMap(parsed.existingSelection), + importSelection: selectionMap(parsed.importSelection), + syncingExisting: Boolean(existingRunningJobId && existingRunningJobKind), + importingConsulting: Boolean(importRunningJobId && importRunningJobKind) + }; + } catch { + return null; + } +} + +function saveZammadSyncPageState(userId: string, state: ZammadSyncPageState) { + setLocalStorageValue(zammadSyncStorageKey(userId), JSON.stringify(state)); +} + function useZammadSyncPageState() { return useSyncExternalStore(subscribeZammadSyncPageState, getZammadSyncPageState, getZammadSyncPageState); } @@ -206,7 +319,7 @@ function formatChangeValue(field: string, value: unknown) { return formatMinutes(value); } - if (field === "Teamspace-Projekte") { + if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung") { return formatTeamspaceProjectList(value) || "-"; } @@ -262,6 +375,10 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f return fields.projectName && consultingProjectCanBeTouched; } + if (change.field === "Teamspace-Prüfung") { + return fields.projectName && consultingProjectCanBeTouched; + } + if (change.field === "Teamspace-Budget") { return fields.budget && consultingProjectCanBeTouched; } @@ -293,7 +410,7 @@ function cachedStringList(source: Record | null | undefined, ke function formatTeamspaceProjectList(value: unknown) { if (Array.isArray(value)) { - return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean).join(" + "); + return normalizedTeamspaceProjectList(value).join(" + "); } return typeof value === "string" ? value.trim() : ""; @@ -303,6 +420,50 @@ function splitTeamspaceProjects(value: unknown) { return typeof value === "string" ? value.match(/[^\s+]+/g) ?? [] : []; } +function normalizedTeamspaceProjectList(value: unknown) { + const entries = Array.isArray(value) ? value : splitTeamspaceProjects(value); + const seen = new Set(); + const normalized: string[] = []; + + for (const entry of entries) { + const teamspaceProject = typeof entry === "string" ? entry.trim() : ""; + + if (!teamspaceProject || seen.has(teamspaceProject)) { + continue; + } + + seen.add(teamspaceProject); + normalized.push(teamspaceProject); + } + + return normalized; +} + +function teamspaceProjectDiff(localProjects: unknown, zammadProjects: unknown) { + const local = normalizedTeamspaceProjectList(localProjects); + const zammad = normalizedTeamspaceProjectList(zammadProjects); + const localSet = new Set(local); + const zammadSet = new Set(zammad); + + return { + local, + additions: zammad.filter((project) => !localSet.has(project)), + missingInZammad: local.filter((project) => !zammadSet.has(project)) + }; +} + +function addCachedTeamspaceProjectChanges(changes: ZammadSyncChange[], localProjects: unknown, zammadProjects: unknown) { + const diff = teamspaceProjectDiff(localProjects, zammadProjects); + + if (diff.additions.length > 0) { + changes.push({ field: "Teamspace-Projekte", from: diff.local, to: [...diff.local, ...diff.additions] }); + } + + if (diff.missingInZammad.length > 0) { + changes.push({ field: "Teamspace-Prüfung", from: diff.missingInZammad, to: "nicht mehr aus Zammad; bitte manuell prüfen" }); + } +} + function zammadPendingTodoPreview(zammad: Record) { const stateId = numericValue(cachedValue(zammad, "stateId")); const pendingTime = cachedValue(zammad, "pendingTime"); @@ -355,8 +516,8 @@ function comparablePreviewValueForField(field: string, value: unknown) { return comparablePreviewIdValue(value); } - if (field === "Teamspace-Projekte" && Array.isArray(value)) { - return value.map((entry) => comparablePreviewValue(entry)); + if (field === "Teamspace-Projekte") { + return normalizedTeamspaceProjectList(value).sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" })); } return comparablePreviewValue(value); @@ -368,7 +529,7 @@ function cachedValueChanged(field: string, left: unknown, right: unknown) { function addCachedChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) { if (cachedValueChanged(field, from, to)) { - changes.push({ field, from: comparablePreviewValue(from), to: comparablePreviewValue(to) }); + changes.push({ field, from: comparablePreviewValueForField(field, from), to: comparablePreviewValueForField(field, to) }); } } @@ -387,6 +548,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn const localWorkType = cachedValue(local, "workType"); const zammadWorkType = cachedValue(zammad, "workType"); const zammadStateId = numericValue(cachedValue(zammad, "stateId")); + const pendingTodoExists = cachedValue(local, "zammadPendingTodoExists"); const localTeamspaceProjects = cachedStringList(project, "teamspaceProjects"); const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects"); const localTeamspaceProjectList = localTeamspaceProjects.length ? localTeamspaceProjects : [cachedValue(project, "teamspaceProject")].filter((entry): entry is string => typeof entry === "string"); @@ -398,7 +560,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn addCachedChange(changes, "Zammad-Status", cachedValue(local, "zammadStateId"), cachedValue(zammad, "stateId")); const pendingTodo = zammadPendingTodoPreview(zammad); - if (fields.pendingTodo && pendingTodo) { + if (fields.pendingTodo && pendingTodo && pendingTodoExists === false) { changes.push({ field: "ToDo", from: null, to: pendingTodo }); } @@ -430,7 +592,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn } if (fields.projectName) { - addCachedChange(changes, "Teamspace-Projekte", localTeamspaceProjectList, zammadTeamspaceProjectList); + addCachedTeamspaceProjectChanges(changes, localTeamspaceProjectList, zammadTeamspaceProjectList); } if (canSyncBudget) { @@ -587,6 +749,7 @@ function ResultSummary({ const selectable = selectableItems(result); const selectedCount = selection ? selectable.filter((item) => itemIsSelected(item, selection)).length : 0; const allSelected = selectable.length > 0 && selectedCount === selectable.length; + const listedItems = result.items.filter((item) => item.action !== "skip" || visibleTicketTrackerChanges(item).length > 0); const metrics = [ ["Geprüft", result.checked], [result.mode === "preview" ? "Würde ändern" : "Aktualisiert", result.updated], @@ -642,14 +805,14 @@ function ResultSummary({ - {result.items.length === 0 ? ( + {listedItems.length === 0 ? ( - Keine Datensätze im Lauf. + Keine Änderungen für die aktuelle Auswahl. ) : ( - result.items.map((item) => { + listedItems.map((item) => { const action = formatAction(item); const isSelectable = item.action === "update" || item.action === "create"; const visibleChanges = visibleTicketTrackerChanges(item); @@ -717,20 +880,155 @@ function ResultSummary({ } export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { + const pageState = useZammadSyncPageState(); const { fields, existingResult, + existingRunningJobId, + existingRunningJobKind, importResult, + importRunningJobId, + importRunningJobKind, existingSelection, importSelection, syncingExisting, importingConsulting - } = useZammadSyncPageState(); + } = pageState; + const [storageReady, setStorageReady] = useState(false); const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]); const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]); const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]); const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id); + useEffect(() => { + setStorageReady(false); + activeZammadSyncStorageUserId = currentUser.id; + const restored = loadZammadSyncPageState(currentUser.id); + + if (restored) { + setZammadSyncPageState(restored); + } else if (hydratedZammadSyncUserId !== currentUser.id) { + setZammadSyncPageState(emptyZammadSyncPageState()); + } + + hydratedZammadSyncUserId = currentUser.id; + setStorageReady(true); + }, [currentUser.id]); + + useEffect(() => { + if (!storageReady) { + return; + } + + saveZammadSyncPageState(currentUser.id, pageState); + }, [currentUser.id, pageState, storageReady]); + + useEffect(() => { + if (!storageReady || !existingRunningJobId || !existingRunningJobKind) { + return; + } + + let cancelled = false; + + void waitForZammadSyncJob(existingRunningJobId) + .then((result) => { + if (cancelled) { + return; + } + + if (existingRunningJobKind === "preview") { + setZammadSyncPageState((current) => ({ + existingResult: result, + existingPreviewJobId: existingRunningJobId, + existingSelection: Object.keys(current.existingSelection).length ? current.existingSelection : selectionFromResult(result), + existingRunningJobId: null, + existingRunningJobKind: null, + syncingExisting: false + })); + } else { + setZammadSyncPageState({ + existingResult: result, + existingPreviewJobId: null, + existingSelection: {}, + existingRunningJobId: null, + existingRunningJobKind: null, + syncingExisting: false + }); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + + setZammadSyncPageState({ + existingRunningJobId: null, + existingRunningJobKind: null, + syncingExisting: false + }); + toast.error("Zammad-Lauf konnte nicht fortgesetzt werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + }); + + return () => { + cancelled = true; + }; + }, [existingRunningJobId, existingRunningJobKind, storageReady]); + + useEffect(() => { + if (!storageReady || !importRunningJobId || !importRunningJobKind) { + return; + } + + let cancelled = false; + + void waitForZammadSyncJob(importRunningJobId) + .then((result) => { + if (cancelled) { + return; + } + + if (importRunningJobKind === "preview") { + setZammadSyncPageState((current) => ({ + importResult: result, + importPreviewJobId: importRunningJobId, + importSelection: Object.keys(current.importSelection).length ? current.importSelection : selectionFromResult(result), + importRunningJobId: null, + importRunningJobKind: null, + importingConsulting: false + })); + } else { + setZammadSyncPageState({ + importResult: result, + importPreviewJobId: null, + importSelection: {}, + importRunningJobId: null, + importRunningJobKind: null, + importingConsulting: false + }); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + + setZammadSyncPageState({ + importRunningJobId: null, + importRunningJobKind: null, + importingConsulting: false + }); + toast.error("Zammad-Importlauf konnte nicht fortgesetzt werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + }); + + return () => { + cancelled = true; + }; + }, [importRunningJobId, importRunningJobKind, storageReady]); + function setField(key: keyof ZammadSyncExistingFields, checked: boolean) { setZammadSyncPageState((current) => ({ fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) } @@ -740,7 +1038,11 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { function resetRunningState() { setZammadSyncPageState({ syncingExisting: false, - importingConsulting: false + importingConsulting: false, + existingRunningJobId: null, + existingRunningJobKind: null, + importRunningJobId: null, + importRunningJobKind: null }); toast.info("Laufstatus zurückgesetzt"); } @@ -751,13 +1053,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { } const syncFields = zammadSyncPageState.fields; - setZammadSyncPageState({ syncingExisting: true }); + setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null }); try { - const { jobId, result } = await previewExistingZammadTickets(syncFields); + const { jobId, result } = await previewExistingZammadTickets(syncFields, (job: ZammadSyncJob) => { + setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "preview" }); + }); setZammadSyncPageState({ existingResult: result, existingPreviewJobId: jobId, - existingSelection: selectionFromResult(result) + existingSelection: selectionFromResult(result), + existingRunningJobId: null, + existingRunningJobKind: null }); toast.success("Vorschau erstellt", { description: `${result.updated + result.created} Datensatz/Datensätze können übernommen werden.` @@ -767,7 +1073,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { - setZammadSyncPageState({ syncingExisting: false }); + setZammadSyncPageState({ syncingExisting: false, existingRunningJobId: null, existingRunningJobKind: null }); } } @@ -790,13 +1096,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { } const syncFields = zammadSyncPageState.fields; - setZammadSyncPageState({ syncingExisting: true }); + setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null }); try { - const { result } = await applyExistingZammadTicketsFromPreview(zammadSyncPageState.existingPreviewJobId, syncFields, ticketNumbers); + const { result } = await applyExistingZammadTicketsFromPreview(zammadSyncPageState.existingPreviewJobId, syncFields, ticketNumbers, (job: ZammadSyncJob) => { + setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "apply" }); + }); setZammadSyncPageState({ existingResult: result, existingPreviewJobId: null, - existingSelection: {} + existingSelection: {}, + existingRunningJobId: null, + existingRunningJobKind: null }); toast.success("Auswahl synchronisiert", { description: `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.` @@ -806,7 +1116,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { - setZammadSyncPageState({ syncingExisting: false }); + setZammadSyncPageState({ syncingExisting: false, existingRunningJobId: null, existingRunningJobKind: null }); } } @@ -815,13 +1125,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { return; } - setZammadSyncPageState({ importingConsulting: true }); + setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null }); try { - const { jobId, result } = await previewOwnZammadConsultingTickets(); + const { jobId, result } = await previewOwnZammadConsultingTickets((job: ZammadSyncJob) => { + setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "preview" }); + }); setZammadSyncPageState({ importResult: result, importPreviewJobId: jobId, - importSelection: selectionFromResult(result) + importSelection: selectionFromResult(result), + importRunningJobId: null, + importRunningJobKind: null }); toast.success("Import-Vorschau erstellt", { description: `${result.created} Consulting-Ticket(s) können angelegt werden.` @@ -831,7 +1145,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { - setZammadSyncPageState({ importingConsulting: false }); + setZammadSyncPageState({ importingConsulting: false, importRunningJobId: null, importRunningJobKind: null }); } } @@ -852,13 +1166,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { return; } - setZammadSyncPageState({ importingConsulting: true }); + setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null }); try { - const { result } = await applyOwnZammadConsultingTicketsFromPreview(zammadSyncPageState.importPreviewJobId, ticketNumbers); + const { result } = await applyOwnZammadConsultingTicketsFromPreview(zammadSyncPageState.importPreviewJobId, ticketNumbers, (job: ZammadSyncJob) => { + setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "apply" }); + }); setZammadSyncPageState({ importResult: result, importPreviewJobId: null, - importSelection: {} + importSelection: {}, + importRunningJobId: null, + importRunningJobKind: null }); toast.success("Auswahl importiert", { description: `${result.created} angelegt, ${result.skipped} ohne Transfer.` @@ -868,7 +1186,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { description: error instanceof Error ? error.message : "Unbekannter Fehler" }); } finally { - setZammadSyncPageState({ importingConsulting: false }); + setZammadSyncPageState({ importingConsulting: false, importRunningJobId: null, importRunningJobKind: null }); } } @@ -950,8 +1268,8 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { ) : null}