diff --git a/.env_dev b/.env_dev index b632e20..88f4e7a 100644 --- a/.env_dev +++ b/.env_dev @@ -1,5 +1,6 @@ -DATABASE_URL=postgresql://ticket:ticket@10.10.10.21:5433/tickettracker_dev -APP_PORT=3910 +PP_PASSWORD='rabenNest7$' +DATABASE_URL=postgresql://tickettracker:${PP_PASSWORD}@172.22.0.2:5432/tickettracker +APP_PORT=3911 TICKETTRACKER_IMAGE=git.d-razz.de/michael/tickettracker:latest COOKIE_SECURE=false DB_POOL_SIZE=10 diff --git a/backend/src/index.ts b/backend/src/index.ts index d653ddd..aecfe11 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -60,6 +60,46 @@ function logDebug(event: string, details: Record = {}) { console.info(`[tickettracker:${event}] ${JSON.stringify(details)}`); } +process.on("uncaughtException", (error) => { + logDebug("process", { + event: "uncaughtException", + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : null + }); + process.exit(1); +}); + +process.on("unhandledRejection", (reason) => { + logDebug("process", { + event: "unhandledRejection", + message: reason instanceof Error ? reason.message : String(reason), + stack: reason instanceof Error ? reason.stack : null + }); +}); + +app.use("/api", (req, res, next) => { + const requestId = randomUUID(); + const startedAt = Date.now(); + + logDebug("request-start", { + requestId, + method: req.method, + path: req.originalUrl + }); + + res.on("finish", () => { + logDebug("request-finish", { + requestId, + method: req.method, + path: req.originalUrl, + status: res.statusCode, + durationMs: Date.now() - startedAt + }); + }); + + next(); +}); + function updateRestoreDebug(id: string, update: Partial) { if (!lastRestoreDebugState || lastRestoreDebugState.id !== id) { return; @@ -256,6 +296,8 @@ type ProjectRow = { organization_name: string | null; customer_name: string | null; work_type: "support" | "consulting" | null; + zammad_priority_id?: string | null; + zammad_state_id?: string | null; teamspace_project: string | null; description: string; goals: string; @@ -279,6 +321,7 @@ type ProjectTeamspaceEntryRow = { teamspace_project: string | null; is_default: boolean; hour_budget_minutes: number | null; + budget_sync_disabled?: boolean; manual_consumption_minutes: number; teamspace_consumption_minutes?: number; consumption_minutes?: number; @@ -310,6 +353,7 @@ type ZammadTicketImport = { preferences: Record; teamspaceBudgetMinutes: number | null; teamspaceProject: string | null; + teamspaceProjects: string[]; }; type ZammadSyncExistingFields = { @@ -318,6 +362,7 @@ type ZammadSyncExistingFields = { type: boolean; projectName: boolean; budget: boolean; + overrideBudget: boolean; }; type ZammadSyncChange = { @@ -440,6 +485,17 @@ function parseZammadBudgetMinutes(value: unknown) { return Math.round(hours * 60); } +function parseZammadTeamspaceProjects(value: unknown) { + if (typeof value !== "string") { + return []; + } + + return value + .split("+") + .map((part) => part.trim()) + .filter(Boolean); +} + function zammadPreferenceValue(ticket: Record, key: string) { const preferences = isPlainObject(ticket.preferences) ? ticket.preferences : {}; return preferences[key] ?? ticket[key]; @@ -629,6 +685,7 @@ function zammadTicketImportFromRaw(rawTicket: Record, fallbackT } const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {}; + const teamspaceProjects = parseZammadTeamspaceProjects(zammadPreferenceValue(rawTicket, "project")); return { zammadTicketId, @@ -642,10 +699,8 @@ function zammadTicketImportFromRaw(rawTicket: Record, fallbackT workType, preferences, teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")), - teamspaceProject: - typeof zammadPreferenceValue(rawTicket, "project") === "string" && String(zammadPreferenceValue(rawTicket, "project")).trim() - ? String(zammadPreferenceValue(rawTicket, "project")).trim() - : null + teamspaceProject: teamspaceProjects[0] ?? null, + teamspaceProjects }; } @@ -855,6 +910,7 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm await ensureProjectForTicket(client, result.rows[0].id, ownerUserId, { teamspaceProject: zammadTicket.teamspaceProject, + teamspaceProjects: zammadTicket.teamspaceProjects, hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, closeReviewRequired: zammadTicket.zammadStateId === 4 }); @@ -1018,6 +1074,7 @@ function parseProjectTeamspaceEntries( teamspaceProject: optionalString(entry.teamspaceProject), isDefault, hourBudgetMinutes: parseOptionalNonNegativeInteger(entry.hourBudgetMinutes, "teamspaceEntries.hourBudgetMinutes"), + budgetSyncDisabled: optionalBoolean(entry.budgetSyncDisabled, false), manualConsumptionMinutes: parseOptionalNonNegativeInteger(entry.manualConsumptionMinutes, "teamspaceEntries.manualConsumptionMinutes") ?? 0, displayOrder: index * 1024 }; @@ -1427,10 +1484,14 @@ async function ensureProjectForTicket( client: QueryClient, ticketId: string, userId: string, - defaults: { teamspaceProject?: string | null; hourBudgetMinutes?: number | null; closeReviewRequired?: boolean } = {} + defaults: { teamspaceProject?: string | null; teamspaceProjects?: string[]; hourBudgetMinutes?: number | null; closeReviewRequired?: boolean } = {} ) { - const hasTeamspaceProjectDefault = Object.prototype.hasOwnProperty.call(defaults, "teamspaceProject"); - const teamspaceProjectDefault = hasTeamspaceProjectDefault ? defaults.teamspaceProject ?? "" : null; + const teamspaceProjects = defaults.teamspaceProjects?.length + ? defaults.teamspaceProjects + : defaults.teamspaceProject + ? [defaults.teamspaceProject] + : []; + const teamspaceProjectDefault = teamspaceProjects[0] ?? (Object.prototype.hasOwnProperty.call(defaults, "teamspaceProject") ? defaults.teamspaceProject ?? "" : null); const result = await client.query<{ id: string; teamspace_project: string | null; hour_budget_minutes: number | null; manual_consumption_minutes: number }>( ` INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals, close_review_required) @@ -1459,8 +1520,8 @@ async function ensureProjectForTicket( await client.query( ` - INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order) - VALUES ($1, $2, true, $3, $4, 0) + 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, true, $3, false, $4, 0) ON CONFLICT DO NOTHING; `, [ @@ -1472,6 +1533,24 @@ async function ensureProjectForTicket( ); } + const projectResult = result.rows[0] + ? { rows: [{ id: result.rows[0].id }] } + : await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM projects + WHERE ticket_id = $1 + AND user_id = $2 + LIMIT 1; + `, + [ticketId, userId] + ); + const projectId = projectResult.rows[0]?.id; + + if (result.rows[0] && projectId && teamspaceProjects.length > 1) { + await syncProjectTeamspaceEntriesFromZammad(client, projectId, teamspaceProjects, defaults.hourBudgetMinutes ?? null, false); + } + if (defaults.closeReviewRequired) { await client.query( ` @@ -1502,6 +1581,8 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, + t.zammad_priority_id::text AS zammad_priority_id, + t.zammad_state_id::text AS zammad_state_id, p.teamspace_project, p.description, p.goals, @@ -1561,6 +1642,7 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro 'teamspace_project', pte.teamspace_project, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, + 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, @@ -1800,7 +1882,8 @@ function zammadSyncExistingFields(value: unknown): ZammadSyncExistingFields { organization: optionalBoolean(source.organization, false), type: optionalBoolean(source.type, false), projectName: optionalBoolean(source.projectName, false), - budget: optionalBoolean(source.budget, false) + budget: optionalBoolean(source.budget, false), + overrideBudget: optionalBoolean(source.overrideBudget, false) }; } @@ -1858,6 +1941,15 @@ type SyncProjectSnapshot = { closed_at: string | null; default_teamspace_project: string | null; default_hour_budget_minutes: number | null; + default_budget_sync_disabled: boolean; + teamspace_entries: Array<{ + id: string; + teamspace_project: string | null; + is_default: boolean; + hour_budget_minutes: number | null; + budget_sync_disabled: boolean; + display_order: number; + }>; }; function syncComparableValue(value: unknown) { @@ -1887,10 +1979,14 @@ function syncIdComparableValue(value: unknown) { } function syncComparableValueForField(field: string, value: unknown) { - if (field === "Organisation") { + if (field === "Organisation" || field === "Zammad-Priorität" || field === "Zammad-Status") { return syncIdComparableValue(value); } + if (field === "Teamspace-Projekte" && Array.isArray(value)) { + return value.map((entry) => syncComparableValue(entry)); + } + return syncComparableValue(value); } @@ -1917,6 +2013,7 @@ function zammadSyncSnapshot(zammadTicket: ZammadTicketImport) { workType: zammadTicket.workType, preferences: zammadTicket.preferences, teamspaceProject: zammadTicket.teamspaceProject, + teamspaceProjects: zammadTicket.teamspaceProjects, teamspaceBudgetMinutes: zammadTicket.teamspaceBudgetMinutes }; } @@ -1951,7 +2048,10 @@ function zammadTicketFromSyncSnapshot(snapshot: Record | null): workType, preferences: isPlainObject(snapshot.preferences) ? snapshot.preferences : {}, teamspaceBudgetMinutes: typeof snapshot.teamspaceBudgetMinutes === "number" ? snapshot.teamspaceBudgetMinutes : null, - teamspaceProject: typeof snapshot.teamspaceProject === "string" && snapshot.teamspaceProject.trim() ? snapshot.teamspaceProject.trim() : 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) }; } @@ -1977,7 +2077,10 @@ function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProj closeReviewRequired: project.close_review_required, closedAt: project.closed_at, defaultTeamspaceProject: project.default_teamspace_project, - defaultHourBudgetMinutes: project.default_hour_budget_minutes + defaultHourBudgetMinutes: project.default_hour_budget_minutes, + defaultBudgetSyncDisabled: project.default_budget_sync_disabled, + teamspaceProjects: project.teamspace_entries.map((entry) => entry.teamspace_project).filter(Boolean), + teamspaceEntries: project.teamspace_entries } : null }; @@ -1994,16 +2097,36 @@ async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: s p.close_review_required, p.closed_at, pte.teamspace_project AS default_teamspace_project, - pte.hour_budget_minutes AS default_hour_budget_minutes + pte.hour_budget_minutes AS default_hour_budget_minutes, + COALESCE(pte.budget_sync_disabled, false) AS default_budget_sync_disabled, + COALESCE(entries.teamspace_entries, '[]'::json) AS teamspace_entries FROM projects p LEFT JOIN LATERAL ( - SELECT teamspace_project, hour_budget_minutes + SELECT teamspace_project, hour_budget_minutes, budget_sync_disabled FROM project_teamspace_entries WHERE project_id = p.id AND is_default ORDER BY id ASC LIMIT 1 ) pte ON true + LEFT JOIN LATERAL ( + SELECT json_agg( + json_build_object( + 'id', ordered.id::text, + 'teamspace_project', ordered.teamspace_project, + 'is_default', ordered.is_default, + 'hour_budget_minutes', ordered.hour_budget_minutes, + 'budget_sync_disabled', ordered.budget_sync_disabled, + 'display_order', ordered.display_order + ) + ) AS teamspace_entries + FROM ( + SELECT id, teamspace_project, is_default, hour_budget_minutes, budget_sync_disabled, display_order + FROM project_teamspace_entries + WHERE project_id = p.id + ORDER BY is_default DESC, display_order ASC, id ASC + ) ordered + ) entries ON true WHERE p.ticket_id = $1 AND p.user_id = $2 LIMIT 1; @@ -2029,6 +2152,93 @@ async function ticketHasAnyProject(client: QueryClient, ticketId: string) { return Boolean(result.rows[0]?.exists); } +function zammadTeamspaceProjectList(zammadTicket: ZammadTicketImport) { + return zammadTicket.teamspaceProjects.length ? zammadTicket.teamspaceProjects : zammadTicket.teamspaceProject ? [zammadTicket.teamspaceProject] : []; +} + +function formatTeamspaceProjectList(projects: Array) { + return projects.map((project) => project?.trim()).filter(Boolean).join(" + "); +} + +async function syncProjectTeamspaceEntriesFromZammad( + client: QueryClient, + projectId: string, + teamspaceProjects: string[], + budgetMinutes: number | null, + syncBudget: boolean +) { + if (teamspaceProjects.length === 0) { + return; + } + + const existingResult = await client.query<{ id: string }>( + ` + SELECT id::text AS id + FROM project_teamspace_entries + WHERE project_id = $1 + ORDER BY is_default DESC, display_order ASC, id ASC + FOR UPDATE; + `, + [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) { + 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; + `, + [projectId, teamspaceProject, isDefault, syncBudget && isDefault ? budgetMinutes : null, index * 1024] + ); + savedIds.push(inserted.rows[0].id); + } + } + + await client.query( + ` + DELETE FROM project_teamspace_entries + WHERE project_id = $1 + AND NOT (id = ANY($2::bigint[])); + `, + [projectId, savedIds] + ); + + 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, teamspaceProjects[0] ?? null, syncBudget, budgetMinutes] + ); +} + async function planExistingZammadTicketSync( client: QueryClient, localTicket: LocalZammadSyncTicket, @@ -2036,8 +2246,22 @@ async function planExistingZammadTicketSync( userId: string, fields: ZammadSyncExistingFields ): Promise { + const changes: ZammadSyncChange[] = []; + + addSyncChange(changes, "Zammad-Priorität", localTicket.zammad_priority_id, zammadTicket.zammadPriorityId); + addSyncChange(changes, "Zammad-Status", localTicket.zammad_state_id, zammadTicket.zammadStateId); + if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) { - return null; + return changes.length > 0 + ? { + ticketNumber: localTicket.ticket_number, + action: "update", + reason: "Zammad-Metadaten werden aktualisiert.", + local: localSyncSnapshot(localTicket), + zammad: zammadSyncSnapshot(zammadTicket), + changes + } + : null; } const organization = fields.organization || !localTicket.work_type ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null; @@ -2053,8 +2277,6 @@ async function planExistingZammadTicketSync( }; } - const changes: ZammadSyncChange[] = []; - if (fields.title) { addSyncChange(changes, "Titel", localTicket.title, zammadTicket.title); } @@ -2090,6 +2312,11 @@ async function planExistingZammadTicketSync( if (shouldManageConsultingProject) { const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); project = await getProjectSnapshotForTicketOwner(client, localTicket.id, projectOwnerUserId); + const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); + const localTeamspaceProjects = project?.teamspace_entries?.length + ? project.teamspace_entries.map((entry) => entry.teamspace_project) + : [project?.teamspace_project ?? null]; + const canSyncBudget = fields.budget && (!project?.default_budget_sync_disabled || fields.overrideBudget); if (zammadTicket.zammadStateId === 4 && project?.closed_at) { return null; @@ -2100,11 +2327,11 @@ async function planExistingZammadTicketSync( } if (fields.projectName) { - addSyncChange(changes, "Teamspace-Projekt", project?.teamspace_project ?? null, zammadTicket.teamspaceProject); + addSyncChange(changes, "Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects); } - if (fields.budget) { - addSyncChange(changes, "Teamspace-Budget", project?.hour_budget_minutes ?? null, zammadTicket.teamspaceBudgetMinutes); + if (canSyncBudget) { + addSyncChange(changes, "Teamspace-Budget", project?.default_hour_budget_minutes ?? project?.hour_budget_minutes ?? null, zammadTicket.teamspaceBudgetMinutes); } if (zammadTicket.zammadStateId === 4 && project?.close_review_required !== true) { @@ -2164,7 +2391,7 @@ async function planConsultingImport(client: QueryClient, zammadTicket: ZammadTic { field: "Titel", from: null, to: zammadTicket.title }, { field: "Organisation", from: null, to: organization.id }, { field: "Typ", from: null, to: zammadTicket.workType }, - { field: "Projekt", from: null, to: zammadTicket.teamspaceProject ?? "" }, + { field: "Projekt", from: null, to: formatTeamspaceProjectList(zammadTeamspaceProjectList(zammadTicket)) }, { field: "Teamspace-Budget", from: null, to: zammadTicket.teamspaceBudgetMinutes }, ...(zammadTicket.zammadStateId === 4 ? [{ field: "TS/TT-Prüfung", from: false, to: true }] : []) ] @@ -2220,6 +2447,7 @@ async function updateDefaultProjectTeamspaceEntry( if (Object.prototype.hasOwnProperty.call(updates, "budgetMinutes")) { params.push(updates.budgetMinutes ?? null); setParts.push(`hour_budget_minutes = $${params.length}`); + setParts.push("budget_sync_disabled = false"); } if (setParts.length === 0) { @@ -2239,6 +2467,58 @@ async function updateDefaultProjectTeamspaceEntry( ); } +async function applyZammadConsultingProjectSync( + client: QueryClient, + ticketId: string, + projectOwnerUserId: string, + zammadTicket: ZammadTicketImport, + fields: ZammadSyncExistingFields +) { + await ensureProjectForTicket(client, ticketId, projectOwnerUserId, { + teamspaceProject: zammadTicket.teamspaceProject, + teamspaceProjects: zammadTicket.teamspaceProjects, + hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, + closeReviewRequired: zammadTicket.zammadStateId === 4 + }); + + const project = await getProjectSnapshotForTicketOwner(client, ticketId, projectOwnerUserId); + + if (!project) { + return; + } + + const syncBudget = fields.budget && (!project.default_budget_sync_disabled || fields.overrideBudget); + + if (fields.projectName) { + await syncProjectTeamspaceEntriesFromZammad(client, project.id, zammadTeamspaceProjectList(zammadTicket), zammadTicket.teamspaceBudgetMinutes, syncBudget); + } else if (syncBudget) { + await updateDefaultProjectTeamspaceEntry(client, project.id, { + budgetMinutes: zammadTicket.teamspaceBudgetMinutes + }); + await client.query( + ` + UPDATE projects + SET hour_budget_minutes = $1, + updated_at = now() + WHERE id = $2; + `, + [zammadTicket.teamspaceBudgetMinutes, project.id] + ); + } + + if (zammadTicket.zammadStateId === 4) { + await client.query( + ` + UPDATE projects + SET close_review_required = true, + updated_at = now() + WHERE id = $1; + `, + [project.id] + ); + } +} + async function applyZammadTicketSync( client: QueryClient, localTicket: { id: string; ticket_number: string; work_type: "support" | "consulting" | null }, @@ -2246,9 +2526,11 @@ async function applyZammadTicketSync( userId: string, fields: ZammadSyncExistingFields ) { - const organization = fields.organization || !localTicket.work_type ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null; + const metadataOnly = zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4; + const shouldResolveOrganization = !metadataOnly && (fields.organization || !localTicket.work_type); + const organization = shouldResolveOrganization ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null; - if ((fields.organization || !localTicket.work_type) && !organization) { + if (shouldResolveOrganization && !organization) { return "organization-missing" as const; } @@ -2269,12 +2551,12 @@ async function applyZammadTicketSync( `, [ localTicket.id, - fields.title, + !metadataOnly && fields.title, zammadTicket.title, - fields.organization, + !metadataOnly && fields.organization, organization?.id ?? null, organization?.name ?? null, - fields.type, + !metadataOnly && fields.type, zammadTicket.workType, zammadTicket.zammadTicketId, zammadTicket.zammadGroupId, @@ -2285,7 +2567,7 @@ async function applyZammadTicketSync( ] ); - if (fields.organization && organization) { + if (!metadataOnly && fields.organization && organization) { await client.query( ` UPDATE sessions @@ -2297,7 +2579,7 @@ async function applyZammadTicketSync( ); } - if (fields.type && zammadTicket.workType === "support") { + if (!metadataOnly && fields.type && zammadTicket.workType === "support") { await deleteAllProjectDataForTicket(client, localTicket.id); await client.query( ` @@ -2314,11 +2596,7 @@ async function applyZammadTicketSync( if (fields.type && zammadTicket.workType === "consulting") { const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); - await ensureProjectForTicket(client, localTicket.id, projectOwnerUserId, { - teamspaceProject: zammadTicket.teamspaceProject, - hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, - closeReviewRequired: zammadTicket.zammadStateId === 4 - }); + await applyZammadConsultingProjectSync(client, localTicket.id, projectOwnerUserId, zammadTicket, fields); await client.query( ` UPDATE sessions @@ -2328,55 +2606,6 @@ async function applyZammadTicketSync( [localTicket.id] ); - const projectUpdateParts: string[] = []; - const projectUpdateParams: unknown[] = []; - - if (fields.projectName) { - projectUpdateParams.push(zammadTicket.teamspaceProject); - projectUpdateParts.push(`teamspace_project = $${projectUpdateParams.length}`); - } - - if (fields.budget) { - projectUpdateParams.push(zammadTicket.teamspaceBudgetMinutes); - projectUpdateParts.push(`hour_budget_minutes = $${projectUpdateParams.length}`); - } - - if (zammadTicket.zammadStateId === 4) { - projectUpdateParts.push("close_review_required = true"); - } - - if (projectUpdateParts.length > 0) { - projectUpdateParams.push(localTicket.id, projectOwnerUserId); - await client.query( - ` - UPDATE projects - SET ${projectUpdateParts.join(", ")}, - updated_at = now() - WHERE ticket_id = $${projectUpdateParams.length - 1} - AND user_id = $${projectUpdateParams.length}; - `, - projectUpdateParams - ); - } - - const projectResult = await client.query<{ id: string }>( - ` - SELECT id::text AS id - FROM projects - WHERE ticket_id = $1 - AND user_id = $2 - LIMIT 1; - `, - [localTicket.id, projectOwnerUserId] - ); - - if (projectResult.rows[0]) { - await updateDefaultProjectTeamspaceEntry(client, projectResult.rows[0].id, { - ...(fields.projectName ? { projectName: zammadTicket.teamspaceProject } : {}), - ...(fields.budget ? { budgetMinutes: zammadTicket.teamspaceBudgetMinutes } : {}) - }); - } - await autoAssignSingleProjectTeamspaceEntry(client, localTicket.id); return zammadTicket.zammadStateId === 4 ? "consulting-close-review" as const : "consulting" as const; } @@ -2384,61 +2613,7 @@ async function applyZammadTicketSync( if (zammadTicket.workType === "consulting" && localTicket.work_type === "consulting") { const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); - await ensureProjectForTicket(client, localTicket.id, projectOwnerUserId, { - teamspaceProject: zammadTicket.teamspaceProject, - hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, - closeReviewRequired: zammadTicket.zammadStateId === 4 - }); - - const projectUpdateParts: string[] = []; - const projectUpdateParams: unknown[] = []; - - if (fields.projectName) { - projectUpdateParams.push(zammadTicket.teamspaceProject); - projectUpdateParts.push(`teamspace_project = $${projectUpdateParams.length}`); - } - - if (fields.budget) { - projectUpdateParams.push(zammadTicket.teamspaceBudgetMinutes); - projectUpdateParts.push(`hour_budget_minutes = $${projectUpdateParams.length}`); - } - - if (zammadTicket.zammadStateId === 4) { - projectUpdateParts.push("close_review_required = true"); - } - - if (projectUpdateParts.length > 0) { - projectUpdateParams.push(localTicket.id, projectOwnerUserId); - await client.query( - ` - UPDATE projects - SET ${projectUpdateParts.join(", ")}, - updated_at = now() - WHERE ticket_id = $${projectUpdateParams.length - 1} - AND user_id = $${projectUpdateParams.length}; - `, - projectUpdateParams - ); - } - - const projectResult = await client.query<{ id: string }>( - ` - SELECT id::text AS id - FROM projects - WHERE ticket_id = $1 - AND user_id = $2 - LIMIT 1; - `, - [localTicket.id, projectOwnerUserId] - ); - - if (projectResult.rows[0]) { - await updateDefaultProjectTeamspaceEntry(client, projectResult.rows[0].id, { - ...(fields.projectName ? { projectName: zammadTicket.teamspaceProject } : {}), - ...(fields.budget ? { budgetMinutes: zammadTicket.teamspaceBudgetMinutes } : {}) - }); - } - + await applyZammadConsultingProjectSync(client, localTicket.id, projectOwnerUserId, zammadTicket, fields); await autoAssignSingleProjectTeamspaceEntry(client, localTicket.id); return zammadTicket.zammadStateId === 4 ? "close-review" as const : "updated" as const; } @@ -3015,6 +3190,8 @@ async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticke o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, + t.zammad_priority_id::text AS zammad_priority_id, + t.zammad_state_id::text AS zammad_state_id, NULL::timestamptz AS closed_at, pc.closed_at AS period_closed_at, COALESCE(period_sessions.session_count, 0)::int AS ticket_session_count, @@ -3150,6 +3327,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, + t.zammad_priority_id::text AS zammad_priority_id, + t.zammad_state_id::text AS zammad_state_id, NULL::timestamptz AS closed_at, NULL::timestamptz AS period_closed_at, COALESCE(all_sessions.session_count, 0)::int AS ticket_session_count, @@ -3291,6 +3470,8 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, + t.zammad_priority_id::text AS zammad_priority_id, + t.zammad_state_id::text AS zammad_state_id, p.teamspace_project, p.description, p.goals, @@ -3335,6 +3516,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon 'teamspace_project', pte.teamspace_project, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, + 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, @@ -3520,8 +3702,13 @@ app.post("/api/setup/restore/validate", async (req, res) => { }); app.post("/api/auth/login", async (req, res) => { + const startedAt = Date.now(); const username = requireString(req.body.username, "username"); const password = requireString(req.body.password, "password"); + logDebug("login", { + phase: "start", + username + }); const result = await query<{ id: string; @@ -3550,13 +3737,35 @@ app.post("/api/auth/login", async (req, res) => { ); const user = result.rows[0]; + logDebug("login", { + phase: "user-loaded", + username, + found: Boolean(user), + userId: user?.id ?? null, + role: user?.role ?? null, + active: user?.active ?? null + }); - if (!user || !user.active || !(await verifyPassword(password, user.password_hash))) { + const passwordValid = user && user.active ? await verifyPassword(password, user.password_hash) : false; + logDebug("login", { + phase: "password-checked", + username, + userId: user?.id ?? null, + ok: passwordValid + }); + + if (!user || !user.active || !passwordValid) { res.status(401).json({ error: "Benutzername oder Passwort ist falsch" }); return; } await createAuthSession(res, user.id); + logDebug("login", { + phase: "session-created", + username, + userId: user.id, + durationMs: Date.now() - startedAt + }); res.json({ user: { id: user.id, @@ -4856,6 +5065,7 @@ app.post("/api/sessions", requireUser, async (req, res) => { ); await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, { teamspaceProject: zammadTicket?.teamspaceProject ?? null, + teamspaceProjects: zammadTicket?.teamspaceProjects ?? [], hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null, closeReviewRequired: zammadTicket?.zammadStateId === 4 }); @@ -4981,6 +5191,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { 'teamspace_project', pte.teamspace_project, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, + 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, 'display_order', pte.display_order ) @@ -5005,18 +5216,18 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { throw badRequest("Zammad Organisation ist lokal nicht synchronisiert"); } + const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); const previewEntries = - zammadTicket.workType === "consulting" && (zammadTicket.teamspaceProject || zammadTicket.teamspaceBudgetMinutes !== null) - ? [ - { - id: `zammad-${zammadTicket.zammadTicketId}-default`, - teamspace_project: zammadTicket.teamspaceProject, - is_default: true, - hour_budget_minutes: zammadTicket.teamspaceBudgetMinutes, + zammadTicket.workType === "consulting" && (zammadTeamspaceProjects.length > 0 || zammadTicket.teamspaceBudgetMinutes !== null) + ? (zammadTeamspaceProjects.length > 0 ? zammadTeamspaceProjects : [zammadTicket.teamspaceProject]).map((teamspaceProject, index) => ({ + id: `zammad-${zammadTicket.zammadTicketId}-${index}`, + teamspace_project: teamspaceProject, + is_default: index === 0, + hour_budget_minutes: index === 0 ? zammadTicket.teamspaceBudgetMinutes : null, + budget_sync_disabled: false, manual_consumption_minutes: 0, - display_order: 0 - } - ] + display_order: index * 1024 + })) : []; res.json({ @@ -5035,7 +5246,7 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { zammad_owner_id: zammadTicket.zammadOwnerId === null ? null : String(zammadTicket.zammadOwnerId), project_id: null, is_project: zammadTicket.workType === "consulting", - project_name: zammadTicket.teamspaceProject, + project_name: formatTeamspaceProjectList(zammadTeamspaceProjects), project_hour_budget_minutes: zammadTicket.teamspaceBudgetMinutes, project_teamspace_entry_count: previewEntries.length, project_teamspace_entries: previewEntries, @@ -5655,6 +5866,8 @@ app.get("/api/projects", requireUser, async (req, res) => { o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, + t.zammad_priority_id::text AS zammad_priority_id, + t.zammad_state_id::text AS zammad_state_id, p.teamspace_project, p.description, p.goals, @@ -5720,6 +5933,7 @@ app.get("/api/projects", requireUser, async (req, res) => { 'teamspace_project', pte.teamspace_project, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, + 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, @@ -5812,6 +6026,8 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { o.name AS organization_name, COALESCE(o.name, t.customer_name) AS customer_name, t.work_type, + t.zammad_priority_id::text AS zammad_priority_id, + t.zammad_state_id::text AS zammad_state_id, p.teamspace_project, p.description, p.goals, @@ -5856,6 +6072,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { 'teamspace_project', pte.teamspace_project, 'is_default', pte.is_default, 'hour_budget_minutes', pte.hour_budget_minutes, + 'budget_sync_disabled', pte.budget_sync_disabled, 'manual_consumption_minutes', pte.manual_consumption_minutes, 'teamspace_consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END, 'consumption_minutes', CASE WHEN pte.is_default THEN COALESCE(teamspace_totals.teamspace_consumption_minutes, 0) ELSE 0 END + pte.manual_consumption_minutes, @@ -5932,15 +6149,29 @@ app.post("/api/projects", requireUser, async (req, res) => { } if (zammadTicket) { - teamspaceEntries = teamspaceEntries.map((entry) => - entry.isDefault - ? { - ...entry, - teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject, - hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes - } - : entry - ); + const zammadTeamspaceProjects = zammadTeamspaceProjectList(zammadTicket); + const currentDefaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; + + teamspaceEntries = zammadTeamspaceProjects.length > 0 + ? zammadTeamspaceProjects.map((teamspaceProjectName, index) => ({ + id: null, + teamspaceProject: teamspaceProjectName, + isDefault: index === 0, + hourBudgetMinutes: index === 0 ? currentDefaultTeamspaceEntry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes : null, + budgetSyncDisabled: false, + manualConsumptionMinutes: index === 0 ? currentDefaultTeamspaceEntry.manualConsumptionMinutes : 0, + displayOrder: index * 1024 + })) + : teamspaceEntries.map((entry) => + entry.isDefault + ? { + ...entry, + teamspaceProject: entry.teamspaceProject ?? zammadTicket.teamspaceProject, + hourBudgetMinutes: entry.hourBudgetMinutes ?? zammadTicket.teamspaceBudgetMinutes, + budgetSyncDisabled: false + } + : entry + ); defaultTeamspaceEntry = teamspaceEntries.find((entry) => entry.isDefault) ?? teamspaceEntries[0]; } @@ -6014,6 +6245,7 @@ app.post("/api/projects", requireUser, async (req, res) => { if (zammadTicket && projectOwnerUserId !== userId) { await ensureProjectForTicket(client, ticketId, projectOwnerUserId, { teamspaceProject: zammadTicket.teamspaceProject, + teamspaceProjects: zammadTicket.teamspaceProjects, hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, closeReviewRequired: zammadTicket.zammadStateId === 4 }); @@ -6105,10 +6337,10 @@ app.post("/api/projects", requireUser, async (req, res) => { for (const entry of teamspaceEntries) { await client.query( ` - INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order) - VALUES ($1, $2, $3, $4, $5, $6); + 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, $5, $6, $7); `, - [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder] + [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder] ); } await autoAssignSingleProjectTeamspaceEntry(client, project.ticket_id); @@ -6118,6 +6350,7 @@ app.post("/api/projects", requireUser, async (req, res) => { teamspace_project: entry.teamspaceProject, is_default: entry.isDefault, hour_budget_minutes: entry.hourBudgetMinutes, + budget_sync_disabled: entry.budgetSyncDisabled, manual_consumption_minutes: entry.manualConsumptionMinutes, teamspace_consumption_minutes: entry.isDefault ? 0 : 0, consumption_minutes: entry.manualConsumptionMinutes, @@ -6294,14 +6527,15 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { SET teamspace_project = $1, is_default = $2, hour_budget_minutes = $3, - manual_consumption_minutes = $4, - display_order = $5, + budget_sync_disabled = $4, + manual_consumption_minutes = $5, + display_order = $6, updated_at = now() - WHERE id = $6 - AND project_id = $7 + WHERE id = $7 + AND project_id = $8 RETURNING id::text AS id; `, - [entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder, entry.id, project.id] + [entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder, entry.id, project.id] ); if (updatedEntry.rowCount === 0) { @@ -6312,11 +6546,11 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { } else { const insertedEntry = await client.query<{ id: string }>( ` - INSERT INTO project_teamspace_entries (project_id, teamspace_project, is_default, hour_budget_minutes, manual_consumption_minutes, display_order) - VALUES ($1, $2, $3, $4, $5, $6) + 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, $5, $6, $7) RETURNING id::text AS id; `, - [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.manualConsumptionMinutes, entry.displayOrder] + [project.id, entry.teamspaceProject, entry.isDefault, entry.hourBudgetMinutes, entry.budgetSyncDisabled, entry.manualConsumptionMinutes, entry.displayOrder] ); savedEntryIds.push(insertedEntry.rows[0].id); @@ -6338,6 +6572,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { teamspace_project: entry.teamspaceProject, is_default: entry.isDefault, hour_budget_minutes: entry.hourBudgetMinutes, + budget_sync_disabled: entry.budgetSyncDisabled, manual_consumption_minutes: entry.manualConsumptionMinutes, teamspace_consumption_minutes: entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0, consumption_minutes: (entry.isDefault ? Number(project.teamspace_consumption_minutes ?? 0) : 0) + entry.manualConsumptionMinutes, diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index db51281..f862eb7 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -503,6 +503,7 @@ export async function migrate() { teamspace_project TEXT, is_default BOOLEAN NOT NULL DEFAULT false, hour_budget_minutes INTEGER, + budget_sync_disabled BOOLEAN NOT NULL DEFAULT false, manual_consumption_minutes INTEGER NOT NULL DEFAULT 0, display_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -512,6 +513,7 @@ export async function migrate() { ); `); + await query("ALTER TABLE project_teamspace_entries ADD COLUMN IF NOT EXISTS budget_sync_disabled BOOLEAN NOT NULL DEFAULT false;"); await query("CREATE UNIQUE INDEX IF NOT EXISTS idx_project_teamspace_entries_default ON project_teamspace_entries(project_id) WHERE is_default;"); await query("CREATE INDEX IF NOT EXISTS idx_project_teamspace_entries_project_order ON project_teamspace_entries(project_id, display_order, id);"); await query(` diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 53ce125..144a00d 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -48,6 +48,7 @@ export type ZammadSyncExistingFields = { type: boolean; projectName: boolean; budget: boolean; + overrideBudget: boolean; }; export type ZammadSyncChange = { @@ -163,6 +164,14 @@ async function requestRestoreEndpoint(path: string, options: ApiRequestInit) return request(path, options); } +function backupValidationSql(sql: string) { + const lines = sql.replace(/^\uFEFF/, "").split(/\r?\n/); + const marker = lines.find((line) => line.includes("TicketTracker Datenbankexport")) ?? "-- TicketTracker Datenbankexport"; + const structuralLines = lines.filter((line) => /^--\s*(?:[a-z_][a-z0-9_]*:\s*\d+\s+Datensatz|columns\s+[a-z_][a-z0-9_]*:)/i.test(line.trim())); + + return [marker, ...structuralLines].join("\n"); +} + export function login(payload: { username: string; password: string }) { return request<{ user: AuthUser }>("/api/auth/login", { method: "POST", @@ -242,7 +251,7 @@ export function validateSetupBackup(sql: string) { method: "POST", timeoutMs: 60_000, headers: { "Content-Type": "text/plain; charset=utf-8" }, - body: sql + body: backupValidationSql(sql) }); } @@ -310,7 +319,7 @@ export function validateDatabaseBackup(sql: string) { method: "POST", timeoutMs: 60_000, headers: { "Content-Type": "text/plain; charset=utf-8" }, - body: sql + body: backupValidationSql(sql) }); } @@ -531,6 +540,7 @@ export function createProject(payload: { teamspaceProject?: string | null; isDefault: boolean; hourBudgetMinutes?: number | null; + budgetSyncDisabled?: boolean; manualConsumptionMinutes?: number | null; }>; description: string; @@ -558,6 +568,7 @@ export function updateProject( teamspaceProject?: string | null; isDefault: boolean; hourBudgetMinutes?: number | null; + budgetSyncDisabled?: boolean; manualConsumptionMinutes?: number | null; }>; description: string; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 0f50de5..7c14727 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -136,6 +136,8 @@ export type ProjectSummary = { organization_name: string | null; customer_name: string | null; work_type: WorkType | null; + zammad_priority_id?: string | null; + zammad_state_id?: string | null; teamspace_project: string | null; description: string; goals: string; @@ -169,6 +171,7 @@ export type ProjectTeamspaceEntry = { teamspace_project: string | null; is_default: boolean; hour_budget_minutes: number | null; + budget_sync_disabled?: boolean; manual_consumption_minutes: number; teamspace_consumption_minutes?: number; consumption_minutes?: number; @@ -313,6 +316,8 @@ export type TicketPeriod = { organization_name: string | null; customer_name: string | null; work_type: WorkType | null; + zammad_priority_id?: string | null; + zammad_state_id?: string | null; closed_at: string | null; requires_closure: boolean; recurring_session_count: number; diff --git a/frontend/src/views/DashboardPage.tsx b/frontend/src/views/DashboardPage.tsx index 8d62aa6..0db1bfc 100644 --- a/frontend/src/views/DashboardPage.tsx +++ b/frontend/src/views/DashboardPage.tsx @@ -47,6 +47,16 @@ import { } from "../api"; import { currentDay, currentMonth, formatDate, formatMinutes } from "../format"; import { ticketPattern } from "../timers"; +import { + matchesZammadPriorityFilter, + matchesZammadStateFilter, + ZammadPriorityBadge, + zammadPriorityFilterOptions, + zammadPrioritySortRank, + ZammadStateBadge, + zammadStateFilterOptions, + zammadStateSortRank +} from "../zammad-meta"; import type { DashboardFavorite, DashboardPreference, @@ -161,7 +171,27 @@ function widgetAllowedFilters(type: DashboardWidgetType) { } if (type === "projectBudgets") { - return ["budgeted", "withoutBudget", "critical90", "overBudget", "open", "closed", "openTodos", "overdueTodos", "fixedBilling", "manualBilling"]; + return [ + "budgeted", + "withoutBudget", + "critical90", + "overBudget", + "open", + "closed", + "zammadState:2", + "zammadState:3", + "zammadState:4", + "zammadState:7", + "zammadState:none", + "zammadPriority:1", + "zammadPriority:2", + "zammadPriority:3", + "zammadPriority:none", + "openTodos", + "overdueTodos", + "fixedBilling", + "manualBilling" + ]; } if (type === "openRatings") { @@ -169,7 +199,23 @@ function widgetAllowedFilters(type: DashboardWidgetType) { } if (type === "favorites") { - return ["projects", "openTodos", "overdueTodos", "missingMeta", "fixedBilling", "manualBilling"]; + return [ + "projects", + "openTodos", + "overdueTodos", + "zammadState:2", + "zammadState:3", + "zammadState:4", + "zammadState:7", + "zammadState:none", + "zammadPriority:1", + "zammadPriority:2", + "zammadPriority:3", + "zammadPriority:none", + "missingMeta", + "fixedBilling", + "manualBilling" + ]; } return []; @@ -498,6 +544,14 @@ function projectFilterGroup(filter: string) { return "status"; } + if (filter.startsWith("zammadState:")) { + return "zammadState"; + } + + if (filter.startsWith("zammadPriority:")) { + return "zammadPriority"; + } + if (filter === "openTodos" || filter === "overdueTodos") { return "todos"; } @@ -547,6 +601,14 @@ function matchesSingleProjectFilter(project: ProjectSummary, filterBy: string, f return Boolean(project.closed_at); } + if (filterBy.startsWith("zammadState:")) { + return matchesZammadStateFilter(project.zammad_state_id, filterBy.slice("zammadState:".length) as Parameters[1]); + } + + if (filterBy.startsWith("zammadPriority:")) { + return matchesZammadPriorityFilter(project.zammad_priority_id, filterBy.slice("zammadPriority:".length) as Parameters[1]); + } + if (filterBy === "openTodos") { return Number(project.open_todos ?? 0) > 0; } @@ -608,11 +670,19 @@ function favoriteFilterGroup(filter: string) { return "todos"; } + if (filter.startsWith("zammadState:")) { + return "zammadState"; + } + + if (filter.startsWith("zammadPriority:")) { + return "zammadPriority"; + } + return filter; } function sortedFavorites(favorites: DashboardFavorite[], metas: Record, config: Record) { - const sortBy = config.sortBy === "ticket" || config.sortBy === "title" || config.sortBy === "recent" ? config.sortBy : "custom"; + const sortBy = config.sortBy === "ticket" || config.sortBy === "title" || config.sortBy === "recent" || config.sortBy === "zammadStatus" || config.sortBy === "zammadPriority" ? config.sortBy : "custom"; const direction: DashboardSortDirection = config.sortDirection === "desc" ? "desc" : "asc"; const factor = directionFactor(direction); @@ -629,6 +699,14 @@ function sortedFavorites(favorites: DashboardFavorite[], metas: Record option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `Status: ${option.label}` })), + ...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `Prio: ${option.label}` })), { value: "openTodos", label: "Mit offenen ToDos" }, { value: "overdueTodos", label: "Mit überfälligen ToDos" }, { value: "fixedBilling", label: "Fixe Abrechnungen" }, @@ -1655,7 +1765,9 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU { value: "todos", label: "ToDos" }, { value: "title", label: "Titel" }, { value: "ticket", label: "Ticket" }, - { value: "organization", label: "Organisation" } + { value: "organization", label: "Organisation" }, + { value: "zammadStatus", label: "Zammad-Status" }, + { value: "zammadPriority", label: "Zammad-Prio" } ]} onChange={onUpdateConfig} /> @@ -1677,6 +1789,10 @@ function ProjectBudgetWidget({ widget, editing, projects, stats, onNavigate, onU {project.ticket_number} · {hasMultipleTeamspaceEntries ? `${entries.length} Teamspace · ` : ""}{budgetMinutes > 0 ? `${decimalHours(consumptionMinutes)} / ${decimalHours(budgetMinutes)} · ${percent ?? 0}%` : "ohne Kontingent"} + + + + {percent !== null ? ( @@ -2196,6 +2312,14 @@ function FavoritesWidget({ return todos.some((todo) => todo.ticket_number === favorite.ticketNumber && todo.status === "open" && todo.ends_on && todo.ends_on < today); } + if (filter.startsWith("zammadState:")) { + return matchesZammadStateFilter(meta?.zammad_state_id, filter.slice("zammadState:".length) as Parameters[1]); + } + + if (filter.startsWith("zammadPriority:")) { + return matchesZammadPriorityFilter(meta?.zammad_priority_id, filter.slice("zammadPriority:".length) as Parameters[1]); + } + if (filter === "missingMeta") { return favoriteMetas[favorite.ticketNumber] === null; } @@ -2233,6 +2357,8 @@ function FavoritesWidget({ { value: "projects", label: "Nur Projekte" }, { value: "openTodos", label: "Mit offenen ToDos" }, { value: "overdueTodos", label: "Mit überfälligen ToDos" }, + ...zammadStateFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadState:${option.value}`, label: `Status: ${option.label}` })), + ...zammadPriorityFilterOptions.filter((option) => option.value !== "all").map((option) => ({ value: `zammadPriority:${option.value}`, label: `Prio: ${option.label}` })), { value: "missingMeta", label: "Nicht gefunden" }, { value: "fixedBilling", label: "Fixe Abrechnungen" }, { value: "manualBilling", label: "Ohne fixe Abrechnung" } @@ -2243,7 +2369,9 @@ function FavoritesWidget({ { value: "custom", label: "Frei" }, { value: "ticket", label: "Ticket" }, { value: "title", label: "Titel" }, - { value: "recent", label: "Neu" } + { value: "recent", label: "Neu" }, + { value: "zammadStatus", label: "Zammad-Status" }, + { value: "zammadPriority", label: "Zammad-Prio" } ]} onChange={onUpdateConfig} /> @@ -2261,6 +2389,10 @@ function FavoritesWidget({ {editing && sortBy === "custom" ? ( <> diff --git a/frontend/src/views/ProfilePage.tsx b/frontend/src/views/ProfilePage.tsx index 29fc8f0..ddcb591 100644 --- a/frontend/src/views/ProfilePage.tsx +++ b/frontend/src/views/ProfilePage.tsx @@ -181,7 +181,7 @@ export function ProfilePage({ currentUser, onUserUpdated }: ProfilePageProps) { }} />

- Die Zammad-User-ID wird nur geprüft, wenn du hier einen neuen Token speicherst. + Die Zammad-ID wird nur geprüft, wenn du hier einen neuen Token speicherst.

diff --git a/frontend/src/views/ProjectsPage.tsx b/frontend/src/views/ProjectsPage.tsx index 74633f7..c635efa 100644 --- a/frontend/src/views/ProjectsPage.tsx +++ b/frontend/src/views/ProjectsPage.tsx @@ -5,6 +5,7 @@ import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Checkbox } from "@/components/ui/checkbox"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; import { Input } from "@/components/ui/input"; @@ -18,6 +19,19 @@ import { currentMonth, formatMinutes } from "../format"; import { projectHasRecurringBilling } from "../recurring-billings"; import { ticketPattern } from "../timers"; import type { ProjectSummary, ProjectTeamspaceEntry, TicketMeta } from "../types"; +import { + matchesZammadPriorityFilter, + matchesZammadStateFilter, + ZammadPriorityBadge, + zammadPriorityFilterOptions, + zammadPriorityLabel, + zammadPrioritySortRank, + ZammadStateBadge, + zammadStateFilterOptions, + zammadStateLabel, + zammadStateSortRank +} from "../zammad-meta"; +import type { ZammadPriorityFilter, ZammadStateFilter } from "../zammad-meta"; type ProjectsPageProps = { onNavigate: (to: string) => void; @@ -39,22 +53,23 @@ type ProjectTeamspaceEntryForm = { clientId: string; teamspaceProject: string; hourBudget: string; + budgetSyncDisabled: boolean; manualConsumption: string; isDefault: boolean; }; type ProjectStatusFilter = "open" | "all" | "closed" | "hidden"; type ProjectBudgetFilter = "all" | "with-budget" | "without-budget" | "remaining" | "over-budget"; -type ProjectTodoFilter = "all" | "open" | "overdue" | "none-open"; -type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "consumption" | "consumptionPercent" | "remaining" | "budget" | "todos"; +type ProjectSortBy = "updated" | "ticket" | "title" | "organization" | "status" | "zammadStatus" | "zammadPriority" | "consumption" | "consumptionPercent" | "remaining" | "budget"; type ProjectSortDirection = "asc" | "desc"; type ProjectViewSettings = { query: string; status: ProjectStatusFilter; + zammadStatus: ZammadStateFilter; + zammadPriority: ZammadPriorityFilter; organization: string; budget: ProjectBudgetFilter; - todos: ProjectTodoFilter; sortBy: ProjectSortBy; sortDirection: ProjectSortDirection; }; @@ -64,9 +79,10 @@ const projectViewSettingsKey = "tickettracker.projects.viewSettings"; const defaultProjectViewSettings: ProjectViewSettings = { query: "", status: "open", + zammadStatus: "all", + zammadPriority: "all", organization: "all", budget: "all", - todos: "all", sortBy: "updated", sortDirection: "desc" }; @@ -86,14 +102,7 @@ const projectBudgetOptions: Array<{ value: ProjectBudgetFilter; label: string }> { value: "over-budget", label: "Überzogen" } ]; -const projectTodoOptions: Array<{ value: ProjectTodoFilter; label: string }> = [ - { value: "all", label: "Alle ToDos" }, - { value: "open", label: "Mit offenen ToDos" }, - { value: "overdue", label: "Mit überfälligen ToDos" }, - { value: "none-open", label: "Keine offenen ToDos" } -]; - -const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"]; +const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "zammadStatus", "zammadPriority", "consumption", "consumptionPercent", "remaining", "budget"]; const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [ { value: "updated", label: "Aktualisierung" }, @@ -101,11 +110,12 @@ const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [ { value: "title", label: "Titel" }, { value: "organization", label: "Organisation" }, { value: "status", label: "Status" }, + { value: "zammadStatus", label: "Zammad-Status" }, + { value: "zammadPriority", label: "Zammad-Priorität" }, { value: "consumption", label: "Verbrauch" }, { value: "consumptionPercent", label: "Verbrauch %" }, { value: "remaining", label: "Reststunden" }, - { value: "budget", label: "Kontingent" }, - { value: "todos", label: "ToDos" } + { value: "budget", label: "Kontingent" } ]; function projectEntryId() { @@ -121,6 +131,7 @@ function emptyProjectTeamspaceEntry(isDefault = false): ProjectTeamspaceEntryFor clientId: projectEntryId(), teamspaceProject: "", hourBudget: "", + budgetSyncDisabled: false, manualConsumption: "", isDefault }; @@ -143,9 +154,10 @@ function readStoredProjectViewSettings(): ProjectViewSettings { return { query: typeof parsed.query === "string" ? parsed.query : defaultProjectViewSettings.query, status: projectStatusOptions.find((option) => option.value === parsed.status)?.value ?? defaultProjectViewSettings.status, + zammadStatus: zammadStateFilterOptions.find((option) => option.value === parsed.zammadStatus)?.value ?? defaultProjectViewSettings.zammadStatus, + zammadPriority: zammadPriorityFilterOptions.find((option) => option.value === parsed.zammadPriority)?.value ?? defaultProjectViewSettings.zammadPriority, organization: typeof parsed.organization === "string" ? parsed.organization : defaultProjectViewSettings.organization, budget: projectBudgetOptions.find((option) => option.value === parsed.budget)?.value ?? defaultProjectViewSettings.budget, - todos: projectTodoOptions.find((option) => option.value === parsed.todos)?.value ?? defaultProjectViewSettings.todos, sortBy: projectSortValues.find((value) => value === parsed.sortBy) ?? defaultProjectViewSettings.sortBy, sortDirection: parsed.sortDirection === "asc" ? "asc" : defaultProjectViewSettings.sortDirection }; @@ -295,6 +307,8 @@ function projectMatchesQuery(project: ProjectSummary, query: string) { project.title ?? "", projectOrganizationLabel(project), project.teamspace_project ?? "", + zammadStateLabel(project.zammad_state_id), + zammadPriorityLabel(project.zammad_priority_id), ...projectTeamspaceEntries(project).map((entry) => entry.teamspace_project ?? ""), project.description, project.goals @@ -315,6 +329,10 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr comparison = compareText(projectOrganizationLabel(left), projectOrganizationLabel(right)); } else if (sortBy === "status") { comparison = Number(Boolean(left.closed_at)) - Number(Boolean(right.closed_at)); + } else if (sortBy === "zammadStatus") { + comparison = zammadStateSortRank(left.zammad_state_id) - zammadStateSortRank(right.zammad_state_id); + } else if (sortBy === "zammadPriority") { + comparison = zammadPrioritySortRank(left.zammad_priority_id) - zammadPrioritySortRank(right.zammad_priority_id); } else if (sortBy === "consumption") { comparison = projectConsumption(left) - projectConsumption(right); } else if (sortBy === "consumptionPercent") { @@ -330,8 +348,6 @@ function compareProjects(left: ProjectSummary, right: ProjectSummary, sortBy: Pr comparison = (projectRemaining(left) ?? Number.POSITIVE_INFINITY) - (projectRemaining(right) ?? Number.POSITIVE_INFINITY); } else if (sortBy === "budget") { comparison = (left.hour_budget_minutes ?? Number.POSITIVE_INFINITY) - (right.hour_budget_minutes ?? Number.POSITIVE_INFINITY); - } else if (sortBy === "todos") { - comparison = Number(left.open_todos ?? 0) - Number(right.open_todos ?? 0); } else { comparison = new Date(left.updated_at).getTime() - new Date(right.updated_at).getTime(); } @@ -344,6 +360,7 @@ function formFromProject(project: ProjectSummary): ProjectForm { clientId: entry.id || projectEntryId(), teamspaceProject: entry.teamspace_project ?? "", hourBudget: formatHoursInput(entry.hour_budget_minutes), + budgetSyncDisabled: entry.budget_sync_disabled ?? false, manualConsumption: formatHoursInput(entry.manual_consumption_minutes), isDefault: entry.is_default || index === 0 })); @@ -398,6 +415,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) { teamspaceProject: entry.teamspaceProject.trim() || null, isDefault: entry.isDefault, hourBudgetMinutes, + budgetSyncDisabled: entry.budgetSyncDisabled, manualConsumptionMinutes: manualConsumptionMinutes ?? 0 }; }); @@ -411,6 +429,7 @@ function parseProjectTeamspaceEntryForms(entries: ProjectTeamspaceEntryForm[]) { teamspaceProject: string | null; isDefault: boolean; hourBudgetMinutes: number | null; + budgetSyncDisabled: boolean; manualConsumptionMinutes: number; }>; } @@ -451,7 +470,7 @@ function ProjectTeamspaceEntriesFields({
{normalizedEntries.map((entry, index) => ( -
+
+
-
- Offene ToDos - {openTodos} -
@@ -1076,7 +1102,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
- +
+
+ + +
+
+ + +
-
- - -
updateEntry(entry.clientId, { hourBudget: event.currentTarget.value })} /> + updateEntry(entry.clientId, { hourBudget: event.currentTarget.value, budgetSyncDisabled: true })} />
+
updateEntry(entry.clientId, { manualConsumption: event.currentTarget.value })} /> @@ -418,6 +429,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO clientId: projectEntryId(), teamspaceProject: "", hourBudget: "", + budgetSyncDisabled: false, manualConsumption: "", isDefault: true } @@ -1108,6 +1120,8 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO

{ticket?.title || ticket?.ticket_number || "Ticket"}

{ticket?.ticket_number ? : null} {isProject ? Projekt : null} + + {(periodType === "month" || (isGlobalTicket && globalMonthFilter !== "all")) && data?.closed ? Monat geschlossen : null}

@@ -1260,7 +1274,9 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO Projekt - {project.closed_at ? geschlossen : offen} + {project.closed_at ? Geschlossen : Offen} + + {!canEditProject ? geteilt : null} {project.close_review_required ? TS/TT prüfen : null} {(project.unassigned_teamspace_session_count ?? 0) > 0 ? {project.unassigned_teamspace_session_count} ohne TS : null} @@ -1341,6 +1357,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO {label} {entry.is_default ? " · Standard" : ""} + {entry.budget_sync_disabled ? " · Budget geschützt" : ""} {formatMinutes(consumption)} / {entry.hour_budget_minutes ? formatMinutes(entry.hour_budget_minutes) : "offen"} diff --git a/frontend/src/views/ZammadSyncPage.tsx b/frontend/src/views/ZammadSyncPage.tsx index 105e87a..6ebe456 100644 --- a/frontend/src/views/ZammadSyncPage.tsx +++ b/frontend/src/views/ZammadSyncPage.tsx @@ -20,6 +20,7 @@ import { type ZammadSyncResult } from "../api"; import type { AuthUser } from "../types"; +import { zammadPriorityLabel, zammadStateLabel } from "../zammad-meta"; type ZammadSyncPageProps = { currentUser: AuthUser; @@ -32,10 +33,11 @@ const defaultFields: ZammadSyncExistingFields = { organization: true, type: true, projectName: true, - budget: true + budget: true, + overrideBudget: false }; -const fieldOptions: Array<{ key: keyof ZammadSyncExistingFields; label: string; description: string }> = [ +const fieldOptions: Array<{ key: Exclude; label: string; description: string }> = [ { key: "title", label: "Titel", description: "Ticket-Titel aus Zammad übernehmen." }, { key: "organization", label: "Organisation", description: "Lokale Organisation anhand der Zammad-ID setzen." }, { key: "type", label: "Typ", description: "Support/Consulting übernehmen, Projekte anlegen oder entfernen." }, @@ -164,16 +166,8 @@ function formatZammadGroup(value: unknown) { function formatZammadPriority(value: unknown) { const priorityId = numericValue(value); - if (priorityId === 1) { - return "1 (Niedrig)"; - } - - if (priorityId === 2) { - return "2 (Normal)"; - } - - if (priorityId === 3) { - return "3 (Hoch)"; + if (priorityId !== null) { + return `${priorityId} (${zammadPriorityLabel(priorityId)})`; } return formatValue(value); @@ -182,20 +176,8 @@ function formatZammadPriority(value: unknown) { function formatZammadState(value: unknown) { const stateId = numericValue(value); - if (stateId === 2) { - return "2 (offen)"; - } - - if (stateId === 3) { - return "3 (warten auf Erinnerung)"; - } - - if (stateId === 4) { - return "4 (geschlossen)"; - } - - if (stateId === 7) { - return "7 (warten auf schließen)"; + if (stateId !== null) { + return `${stateId} (${zammadStateLabel(stateId)})`; } return formatValue(value); @@ -222,6 +204,10 @@ function formatChangeValue(field: string, value: unknown) { return formatMinutes(value); } + if (field === "Teamspace-Projekte") { + return formatTeamspaceProjectList(value) || "-"; + } + if (field === "Typ") { return formatWorkType(value); } @@ -242,7 +228,7 @@ function formatChangeValue(field: string, value: unknown) { } function visibleTicketTrackerChanges(item: ZammadSyncItem) { - return item.changes.filter((change) => !change.field.startsWith("Zammad-")); + return item.changes.filter((change) => !change.field.startsWith("Zammad-") || change.field === "Zammad-Status" || change.field === "Zammad-Priorität"); } function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, fields: ZammadSyncExistingFields) { @@ -270,6 +256,10 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f return fields.projectName && consultingProjectCanBeTouched; } + if (change.field === "Teamspace-Projekte") { + return fields.projectName && consultingProjectCanBeTouched; + } + if (change.field === "Teamspace-Budget") { return fields.budget && consultingProjectCanBeTouched; } @@ -290,6 +280,23 @@ function cachedObject(source: Record | null | undefined, key: s return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; } +function cachedStringList(source: Record | null | undefined, key: string) { + const value = cachedValue(source, key); + return Array.isArray(value) ? value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean) : []; +} + +function formatTeamspaceProjectList(value: unknown) { + if (Array.isArray(value)) { + return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean).join(" + "); + } + + return typeof value === "string" ? value.trim() : ""; +} + +function splitTeamspaceProjects(value: unknown) { + return typeof value === "string" ? value.split("+").map((entry) => entry.trim()).filter(Boolean) : []; +} + function comparablePreviewValue(value: unknown) { if (typeof value === "string" && value.trim().length === 0) { return null; @@ -317,10 +324,14 @@ function comparablePreviewIdValue(value: unknown) { } function comparablePreviewValueForField(field: string, value: unknown) { - if (field === "Organisation") { + if (field === "Organisation" || field === "Zammad-Priorität" || field === "Zammad-Status") { return comparablePreviewIdValue(value); } + if (field === "Teamspace-Projekte" && Array.isArray(value)) { + return value.map((entry) => comparablePreviewValue(entry)); + } + return comparablePreviewValue(value); } @@ -349,8 +360,16 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn const localWorkType = cachedValue(local, "workType"); const zammadWorkType = cachedValue(zammad, "workType"); const zammadStateId = numericValue(cachedValue(zammad, "stateId")); + 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"); + const zammadTeamspaceProjectList = zammadTeamspaceProjects.length ? zammadTeamspaceProjects : splitTeamspaceProjects(cachedValue(zammad, "teamspaceProject")); + const canSyncBudget = fields.budget && (cachedValue(project, "defaultBudgetSyncDisabled") !== true || fields.overrideBudget); const changes: ZammadSyncChange[] = []; + addCachedChange(changes, "Zammad-Priorität", cachedValue(local, "zammadPriorityId"), cachedValue(zammad, "priorityId")); + addCachedChange(changes, "Zammad-Status", cachedValue(local, "zammadStateId"), cachedValue(zammad, "stateId")); + if (zammadWorkType === "support" && zammadStateId === 4) { return changes; } @@ -379,11 +398,11 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn } if (fields.projectName) { - addCachedChange(changes, "Teamspace-Projekt", cachedValue(project, "teamspaceProject"), cachedValue(zammad, "teamspaceProject")); + addCachedChange(changes, "Teamspace-Projekte", localTeamspaceProjectList, zammadTeamspaceProjectList); } - if (fields.budget) { - addCachedChange(changes, "Teamspace-Budget", cachedValue(project, "hourBudgetMinutes"), cachedValue(zammad, "teamspaceBudgetMinutes")); + if (canSyncBudget) { + addCachedChange(changes, "Teamspace-Budget", cachedValue(project, "defaultHourBudgetMinutes") ?? cachedValue(project, "hourBudgetMinutes"), cachedValue(zammad, "teamspaceBudgetMinutes")); } if (zammadStateId === 4 && cachedValue(project, "closedAt") === null && cachedValue(project, "closeReviewRequired") !== true) { @@ -516,7 +535,7 @@ function zammadDetails(item: ZammadSyncItem) { `Owner ${formatValue(zammad.ownerId)}`, `Org ${formatValue(zammad.organizationId)}`, `Titel ${formatValue(zammad.title)}`, - `Projekt ${formatValue(zammad.teamspaceProject)}`, + `Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`, `Budget ${formatMinutes(zammad.teamspaceBudgetMinutes)}` ]; } @@ -681,7 +700,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { function setField(key: keyof ZammadSyncExistingFields, checked: boolean) { setZammadSyncPageState((current) => ({ - fields: { ...current.fields, [key]: checked } + fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) } })); } @@ -894,6 +913,15 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) { ))} + {fields.budget ? ( + + ) : null}

{syncingExisting ? (