diff --git a/backend/src/index.ts b/backend/src/index.ts index 7be002e..23ed756 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,6 +1,7 @@ import "dotenv/config"; import cors from "cors"; import express from "express"; +import { randomUUID } from "node:crypto"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type pg from "pg"; @@ -191,6 +192,7 @@ type ProjectRow = { teamspace_entry_count?: number; teamspace_entries?: ProjectTeamspaceEntryRow[]; closed_at: string | null; + close_review_required?: boolean; created_at: string; updated_at: string; }; @@ -234,6 +236,44 @@ type ZammadTicketImport = { teamspaceProject: string | null; }; +type ZammadSyncExistingFields = { + title: boolean; + organization: boolean; + type: boolean; + projectName: boolean; + budget: boolean; +}; + +type ZammadSyncChange = { + field: string; + from: unknown; + to: unknown; +}; + +type ZammadSyncItem = { + ticketNumber: string; + action: "update" | "create" | "skip" | "error"; + reason: string | null; + local: Record | null; + zammad: Record | null; + changes: ZammadSyncChange[]; +}; + +type ZammadSyncResult = { + mode: "preview" | "applied"; + checked: number; + updated: number; + created: number; + skipped: number; + missingInZammad: number; + organizationMissing: number; + markedForCloseReview: number; + convertedToConsulting: number; + convertedToSupport: number; + errors: Array<{ ticketNumber: string; error: string }>; + items: ZammadSyncItem[]; +}; + async function getAppSetting(key: string) { const result = await query<{ value: string }>("SELECT value FROM app_settings WHERE key = $1;", [key]); return result.rows[0]?.value ?? null; @@ -290,12 +330,19 @@ function numberOrNull(value: unknown) { return Number.isInteger(number) && number > 0 ? number : null; } +const zammadConsultingGroupId = 2; +const zammadSupportGroupIds = [3, 4]; + +function isSupportedZammadGroupId(groupId: number | null) { + return groupId === zammadConsultingGroupId || (groupId !== null && zammadSupportGroupIds.includes(groupId)); +} + function zammadWorkType(groupId: number | null) { - if (groupId === 2) { + if (groupId === zammadConsultingGroupId) { return "consulting" as const; } - if (groupId === 3) { + if (groupId !== null && zammadSupportGroupIds.includes(groupId)) { return "support" as const; } @@ -366,38 +413,183 @@ function zammadTicketCandidates(payload: unknown): Record[] { } if (isPlainObject(payload)) { + const nestedCandidates: Record[] = []; + if (Array.isArray(payload.tickets)) { - return payload.tickets.filter(isPlainObject); + nestedCandidates.push(...payload.tickets.filter(isPlainObject)); } if (Array.isArray(payload.items)) { - return payload.items.filter(isPlainObject); + nestedCandidates.push(...payload.items.filter(isPlainObject)); } - return [payload]; + for (const key of ["assets", "ticket", "Ticket", "tickets"]) { + const source = payload[key]; + + if (!isPlainObject(source) || Array.isArray(source)) { + continue; + } + + for (const value of Object.values(source)) { + if (isPlainObject(value) && (Object.prototype.hasOwnProperty.call(value, "number") || Object.prototype.hasOwnProperty.call(value, "group_id"))) { + nestedCandidates.push(value); + } else if (isPlainObject(value)) { + for (const nestedValue of Object.values(value)) { + if (isPlainObject(nestedValue) && (Object.prototype.hasOwnProperty.call(nestedValue, "number") || Object.prototype.hasOwnProperty.call(nestedValue, "group_id"))) { + nestedCandidates.push(nestedValue); + } + } + } + } + } + + if (nestedCandidates.length > 0) { + return nestedCandidates; + } + + return Object.prototype.hasOwnProperty.call(payload, "number") || Object.prototype.hasOwnProperty.call(payload, "group_id") ? [payload] : []; } return []; } +function zammadTicketCandidateIds(payload: unknown) { + const ids = new Set(); + const addId = (value: unknown) => { + const id = numberOrNull(value); + + if (id) { + ids.add(id); + } + }; + + if (Array.isArray(payload)) { + payload.forEach((item) => { + if (isPlainObject(item)) { + addId(item.id); + } else { + addId(item); + } + }); + } + + if (isPlainObject(payload)) { + addId(payload.id); + + for (const key of ["tickets", "ticket_ids", "ids", "items", "result", "results"]) { + const source = payload[key]; + + if (Array.isArray(source)) { + source.forEach((item) => { + if (isPlainObject(item)) { + addId(item.id); + } else { + addId(item); + } + }); + } + } + + const assets = payload.assets; + if (isPlainObject(assets)) { + for (const [assetKey, value] of Object.entries(assets)) { + if (!assetKey.toLowerCase().includes("ticket")) { + continue; + } + + if (isPlainObject(value)) { + Object.keys(value).forEach(addId); + } + } + } + } + + return [...ids]; +} + +function normalizedTicketNumberDigits(value: unknown) { + if (typeof value !== "string" && typeof value !== "number") { + return null; + } + + const digits = String(value).replace(/^Ticket#/i, "").replace(/\D/g, ""); + return digits.length > 0 ? digits : null; +} + function ticketNumberMatchesZammadNumber(localTicketNumber: string, zammadNumber: unknown) { if (typeof zammadNumber !== "string" && typeof zammadNumber !== "number") { return false; } - const local = localTicketNumber.replace(/^Ticket#/i, ""); - const remote = String(zammadNumber).trim(); + const local = localTicketNumber.replace(/^Ticket#/i, "").trim(); + const remote = String(zammadNumber).replace(/^Ticket#/i, "").trim(); + const localDigits = normalizedTicketNumberDigits(local); + const remoteDigits = normalizedTicketNumberDigits(remote); + + if (localDigits && remoteDigits) { + return remoteDigits === localDigits || remoteDigits.replace(/^0+/, "") === localDigits.replace(/^0+/, ""); + } return remote === local || remote.replace(/^0+/, "") === local.replace(/^0+/, ""); } +function zammadTicketImportFromRaw(rawTicket: Record, fallbackTicketNumber: string | null = null): ZammadTicketImport | null { + const zammadTicketId = numberOrNull(rawTicket.id); + const zammadNumber = rawTicket.number; + const ticketNumber = + fallbackTicketNumber ?? + (typeof zammadNumber === "string" || typeof zammadNumber === "number" ? `Ticket#${String(zammadNumber).trim()}` : null); + const organizationId = numberOrNull(rawTicket.organization_id); + + if (!zammadTicketId || !ticketNumber || !organizationId || !ticketNumberMatchesZammadNumber(ticketNumber, zammadNumber)) { + return null; + } + + const groupId = numberOrNull(rawTicket.group_id); + const workType = zammadWorkType(groupId); + + if (!workType) { + return null; + } + + const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {}; + + return { + zammadTicketId, + ticketNumber, + zammadGroupId: groupId, + zammadPriorityId: numberOrNull(rawTicket.priority_id), + zammadStateId: numberOrNull(rawTicket.state_id), + organizationId, + title: typeof rawTicket.title === "string" && rawTicket.title.trim() ? rawTicket.title.trim() : null, + zammadOwnerId: numberOrNull(rawTicket.owner_id), + workType, + preferences, + teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")), + teamspaceProject: + typeof zammadPreferenceValue(rawTicket, "project") === "string" && String(zammadPreferenceValue(rawTicket, "project")).trim() + ? String(zammadPreferenceValue(rawTicket, "project")).trim() + : null + }; +} + async function fetchZammadTicketByNumber(userId: string, ticketNumber: string): Promise { const { baseUrl, apiToken } = await getZammadCredentialsForUser(userId); - const zammadNumber = ticketNumber.replace(/^Ticket#/i, ""); + const zammadNumber = normalizedTicketNumberDigits(ticketNumber) ?? ticketNumber.replace(/^Ticket#/i, "").trim(); + const supportGroupQueries = zammadSupportGroupIds.map((groupId) => `group_id:${groupId}`); for (const path of [ + `/api/v1/ticket_by_number/${encodeURIComponent(zammadNumber)}`, + `/api/v1/tickets/search?query=${encodeURIComponent(`number:${zammadNumber} group_id:${zammadConsultingGroupId}`)}`, + ...supportGroupQueries.map((groupQuery) => `/api/v1/tickets/search?query=${encodeURIComponent(`number:${zammadNumber} ${groupQuery}`)}`), `/api/v1/tickets/search?query=${encodeURIComponent(`number:${zammadNumber}`)}`, + `/api/v1/tickets/search?query=${encodeURIComponent(zammadNumber)}`, + `/api/v1/search?query=${encodeURIComponent(`number:${zammadNumber}`)}&objects=Ticket`, + `/api/v1/search?query=${encodeURIComponent(zammadNumber)}&objects=Ticket`, + `/api/v1/tickets?query=${encodeURIComponent(`number:${zammadNumber} group_id:${zammadConsultingGroupId}`)}`, + ...supportGroupQueries.map((groupQuery) => `/api/v1/tickets?query=${encodeURIComponent(`number:${zammadNumber} ${groupQuery}`)}`), `/api/v1/tickets?query=${encodeURIComponent(`number:${zammadNumber}`)}`, + `/api/v1/tickets?query=${encodeURIComponent(zammadNumber)}`, "/api/v1/tickets" ]) { let payload: unknown; @@ -412,7 +604,11 @@ async function fetchZammadTicketByNumber(userId: string, ticketNumber: string): continue; } - const candidate = zammadTicketCandidates(payload).find((ticket) => ticketNumberMatchesZammadNumber(ticketNumber, ticket.number)); + const candidate = zammadTicketCandidates(payload).find((ticket) => { + const candidateGroupId = numberOrNull(ticket.group_id); + + return ticketNumberMatchesZammadNumber(ticketNumber, ticket.number) && (!candidateGroupId || isSupportedZammadGroupId(candidateGroupId)); + }); const zammadTicketId = numberOrNull(candidate?.id); if (zammadTicketId) { @@ -423,37 +619,83 @@ async function fetchZammadTicketByNumber(userId: string, ticketNumber: string): return null; } - const groupId = numberOrNull(rawTicket.group_id); - const organizationId = numberOrNull(rawTicket.organization_id); - const preferences = isPlainObject(rawTicket.preferences) ? rawTicket.preferences : {}; + return zammadTicketImportFromRaw(rawTicket, ticketNumber); + } - if (!organizationId || !ticketNumberMatchesZammadNumber(ticketNumber, rawTicket.number)) { - return null; + for (const candidateId of zammadTicketCandidateIds(payload).slice(0, 25)) { + const detail = await fetchZammadJson(baseUrl, apiToken, `/api/v1/tickets/${candidateId}`); + const rawTicket = isPlainObject(detail) ? detail : null; + const zammadTicket = rawTicket ? zammadTicketImportFromRaw(rawTicket, ticketNumber) : null; + + if (zammadTicket) { + return zammadTicket; } - - return { - zammadTicketId, - ticketNumber, - zammadGroupId: groupId, - zammadPriorityId: numberOrNull(rawTicket.priority_id), - zammadStateId: numberOrNull(rawTicket.state_id), - organizationId, - title: typeof rawTicket.title === "string" && rawTicket.title.trim() ? rawTicket.title.trim() : null, - zammadOwnerId: numberOrNull(rawTicket.owner_id), - workType: zammadWorkType(groupId), - preferences, - teamspaceBudgetMinutes: parseZammadBudgetMinutes(zammadPreferenceValue(rawTicket, "gesamtbudget")), - teamspaceProject: - typeof zammadPreferenceValue(rawTicket, "project") === "string" && String(zammadPreferenceValue(rawTicket, "project")).trim() - ? String(zammadPreferenceValue(rawTicket, "project")).trim() - : null - }; } } return null; } +async function fetchZammadConsultingTicketsForOwner(userId: string): Promise { + const userResult = await query<{ zammad_user_id: string | null }>("SELECT zammad_user_id::text AS zammad_user_id FROM users WHERE id = $1;", [userId]); + const zammadUserId = userResult.rows[0]?.zammad_user_id; + + if (!zammadUserId) { + throw badRequest("Zammad User-ID fehlt. Bitte den persönlichen Zammad-Token im Profil speichern."); + } + + const { baseUrl, apiToken } = await getZammadCredentialsForUser(userId); + const candidates = new Map>(); + + for (const path of [ + `/api/v1/tickets/search?query=${encodeURIComponent(`owner_id:${zammadUserId} group_id:${zammadConsultingGroupId}`)}`, + `/api/v1/tickets?query=${encodeURIComponent(`owner_id:${zammadUserId} group_id:${zammadConsultingGroupId}`)}`, + "/api/v1/tickets" + ]) { + let payload: unknown; + + try { + payload = await fetchZammadJson(baseUrl, apiToken, path); + } catch (error) { + if (path === "/api/v1/tickets") { + throw error; + } + + continue; + } + + for (const ticket of zammadTicketCandidates(payload)) { + const id = numberOrNull(ticket.id); + + if ( + id && + (path !== "/api/v1/tickets" || + (numberOrNull(ticket.owner_id) === Number(zammadUserId) && numberOrNull(ticket.group_id) === zammadConsultingGroupId)) + ) { + candidates.set(id, ticket); + } + } + + if (candidates.size > 0 && path !== "/api/v1/tickets") { + break; + } + } + + const tickets: ZammadTicketImport[] = []; + + for (const [id, candidate] of candidates.entries()) { + const detail = await fetchZammadJson(baseUrl, apiToken, `/api/v1/tickets/${id}`); + const rawTicket = isPlainObject(detail) ? detail : candidate; + const zammadTicket = zammadTicketImportFromRaw(rawTicket); + + if (zammadTicket && zammadTicket.workType === "consulting" && zammadTicket.zammadOwnerId === Number(zammadUserId) && zammadTicket.zammadStateId !== 4) { + tickets.push(zammadTicket); + } + } + + return tickets; +} + async function projectOwnerUserIdForZammadOwner(client: QueryClient, zammadOwnerId: number | null, fallbackUserId: string) { if (!zammadOwnerId) { return fallbackUserId; @@ -537,7 +779,8 @@ async function importZammadTicketForUser(client: QueryClient, zammadTicket: Zamm await ensureProjectForTicket(client, result.rows[0].id, ownerUserId, { teamspaceProject: zammadTicket.teamspaceProject, - hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes + hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, + closeReviewRequired: zammadTicket.zammadStateId === 4 }); } @@ -1108,18 +1351,20 @@ async function ensureProjectForTicket( client: QueryClient, ticketId: string, userId: string, - defaults: { teamspaceProject?: string | null; hourBudgetMinutes?: number | null } = {} + defaults: { teamspaceProject?: string | null; hourBudgetMinutes?: number | null; closeReviewRequired?: boolean } = {} ) { + const hasTeamspaceProjectDefault = Object.prototype.hasOwnProperty.call(defaults, "teamspaceProject"); + const teamspaceProjectDefault = hasTeamspaceProjectDefault ? 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) - SELECT $2, t.id, COALESCE($3, t.title, ''), '', '' + INSERT INTO projects (user_id, ticket_id, teamspace_project, description, goals, close_review_required) + SELECT $2, t.id, COALESCE($3, t.title, ''), '', '', $4::boolean FROM tickets t WHERE t.id = $1 ON CONFLICT (user_id, ticket_id) DO NOTHING RETURNING id::text, teamspace_project, hour_budget_minutes, manual_consumption_minutes; `, - [ticketId, userId, defaults.teamspaceProject ?? null] + [ticketId, userId, teamspaceProjectDefault, Boolean(defaults.closeReviewRequired)] ); if ("rows" in result && result.rows[0]) { @@ -1150,6 +1395,19 @@ async function ensureProjectForTicket( ] ); } + + if (defaults.closeReviewRequired) { + await client.query( + ` + UPDATE projects + SET close_review_required = true, + updated_at = now() + WHERE ticket_id = $1 + AND user_id = $2; + `, + [ticketId, userId] + ); + } } async function getProjectRowForTicket(client: QueryClient, ticketId: string, projectOwnerUserId: string, viewerUserId: string) { @@ -1193,6 +1451,7 @@ async function getProjectRowForTicket(client: QueryClient, ticketId: string, pro COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos, COALESCE(todo_totals.done_todos, 0)::int AS done_todos, p.closed_at, + p.close_review_required, p.created_at, p.updated_at FROM projects p @@ -1372,6 +1631,32 @@ async function autoAssignSingleProjectTeamspaceEntry(client: QueryClient, ticket ); } +async function reopenProjectsForTicket(client: QueryClient, ticketId: string) { + await client.query( + ` + UPDATE projects + SET closed_at = NULL, + updated_at = now() + WHERE ticket_id = $1 + AND closed_at IS NOT NULL; + `, + [ticketId] + ); +} + +async function showProjectsForUserTicket(client: QueryClient, ticketId: string, userId: string) { + await client.query( + ` + DELETE FROM hidden_user_projects hup + USING projects p + WHERE p.id = hup.project_id + AND p.ticket_id = $1 + AND hup.user_id = $2; + `, + [ticketId, userId] + ); +} + async function deleteProjectDataForTicket( client: { query: (text: string, params?: unknown[]) => Promise }, ticketId: string, @@ -1405,6 +1690,12 @@ async function deleteProjectDataForTicket( ); } +async function deleteAllProjectDataForTicket(client: QueryClient, ticketId: string) { + await client.query("DELETE FROM ticket_day_billing_acknowledgements WHERE ticket_id = $1;", [ticketId]); + await client.query("DELETE FROM ticket_day_billings WHERE ticket_id = $1;", [ticketId]); + await client.query("DELETE FROM projects WHERE ticket_id = $1;", [ticketId]); +} + async function ticketHasRecurringBilling( client: { query: (text: string, params?: unknown[]) => Promise<{ rows: { exists: boolean }[] }> }, ticketNumber: string, @@ -1425,6 +1716,647 @@ async function ticketHasRecurringBilling( return Boolean(result.rows[0]?.exists); } +function zammadSyncExistingFields(value: unknown): ZammadSyncExistingFields { + const source = isPlainObject(value) ? value : {}; + + return { + title: optionalBoolean(source.title, false), + organization: optionalBoolean(source.organization, false), + type: optionalBoolean(source.type, false), + projectName: optionalBoolean(source.projectName, false), + budget: optionalBoolean(source.budget, false) + }; +} + +function zammadSyncSelectedTicketNumbers(value: unknown) { + if (value === undefined || value === null) { + return null; + } + + if (!Array.isArray(value)) { + throw badRequest("ticketNumbers must be an array"); + } + + return new Set(value.map((item) => requireString(item, "ticketNumber"))); +} + +function emptyZammadSyncResult(mode: "preview" | "applied" = "applied"): ZammadSyncResult { + return { + mode, + checked: 0, + updated: 0, + created: 0, + skipped: 0, + missingInZammad: 0, + organizationMissing: 0, + markedForCloseReview: 0, + convertedToConsulting: 0, + convertedToSupport: 0, + errors: [], + items: [] + }; +} + +type LocalZammadSyncTicket = { + id: string; + ticket_number: string; + title: string | null; + organization_id: string | null; + organization_name: string | null; + customer_name: string | null; + work_type: "support" | "consulting" | null; + zammad_ticket_id: number | null; + zammad_group_id: number | null; + zammad_priority_id: number | null; + zammad_state_id: number | null; + zammad_owner_id: number | null; + zammad_preferences: Record | null; +}; + +type SyncProjectSnapshot = { + id: string; + user_id: string; + teamspace_project: string | null; + hour_budget_minutes: number | null; + close_review_required: boolean; + closed_at: string | null; + default_teamspace_project: string | null; + default_hour_budget_minutes: number | null; +}; + +function syncComparableValue(value: unknown) { + if (typeof value === "string" && value.trim().length === 0) { + return null; + } + + return value ?? null; +} + +function syncValueChanged(left: unknown, right: unknown) { + return JSON.stringify(syncComparableValue(left)) !== JSON.stringify(syncComparableValue(right)); +} + +function addSyncChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) { + if (syncValueChanged(from, to)) { + changes.push({ field, from: syncComparableValue(from), to: syncComparableValue(to) }); + } +} + +function zammadSyncSnapshot(zammadTicket: ZammadTicketImport) { + return { + ticketId: zammadTicket.zammadTicketId, + ticketNumber: zammadTicket.ticketNumber, + groupId: zammadTicket.zammadGroupId, + priorityId: zammadTicket.zammadPriorityId, + stateId: zammadTicket.zammadStateId, + organizationId: zammadTicket.organizationId, + title: zammadTicket.title, + ownerId: zammadTicket.zammadOwnerId, + workType: zammadTicket.workType, + preferences: zammadTicket.preferences, + teamspaceProject: zammadTicket.teamspaceProject, + teamspaceBudgetMinutes: zammadTicket.teamspaceBudgetMinutes + }; +} + +function zammadTicketFromSyncSnapshot(snapshot: Record | null): ZammadTicketImport | null { + if (!snapshot) { + return null; + } + + const zammadTicketId = numberOrNull(snapshot.ticketId); + const ticketNumber = typeof snapshot.ticketNumber === "string" ? snapshot.ticketNumber : null; + const zammadGroupId = numberOrNull(snapshot.groupId); + const zammadPriorityId = numberOrNull(snapshot.priorityId); + const zammadStateId = numberOrNull(snapshot.stateId); + const organizationId = numberOrNull(snapshot.organizationId); + const zammadOwnerId = numberOrNull(snapshot.ownerId); + const workType = zammadWorkType(zammadGroupId); + + if (!zammadTicketId || !ticketNumber || !organizationId || !workType) { + return null; + } + + return { + zammadTicketId, + ticketNumber, + zammadGroupId, + zammadPriorityId, + zammadStateId, + organizationId, + title: typeof snapshot.title === "string" && snapshot.title.trim() ? snapshot.title.trim() : null, + zammadOwnerId, + 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 + }; +} + +function localSyncSnapshot(localTicket: LocalZammadSyncTicket, project: SyncProjectSnapshot | null = null) { + return { + ticketId: localTicket.id, + ticketNumber: localTicket.ticket_number, + title: localTicket.title, + organizationId: localTicket.organization_id, + organizationName: localTicket.organization_name ?? localTicket.customer_name, + workType: localTicket.work_type, + zammadTicketId: localTicket.zammad_ticket_id, + zammadGroupId: localTicket.zammad_group_id, + zammadPriorityId: localTicket.zammad_priority_id, + zammadStateId: localTicket.zammad_state_id, + zammadOwnerId: localTicket.zammad_owner_id, + project: project + ? { + id: project.id, + userId: project.user_id, + teamspaceProject: project.teamspace_project, + hourBudgetMinutes: project.hour_budget_minutes, + closeReviewRequired: project.close_review_required, + closedAt: project.closed_at, + defaultTeamspaceProject: project.default_teamspace_project, + defaultHourBudgetMinutes: project.default_hour_budget_minutes + } + : null + }; +} + +async function getProjectSnapshotForTicketOwner(client: QueryClient, ticketId: string, ownerUserId: string) { + const result = await client.query( + ` + SELECT + p.id::text AS id, + p.user_id::text AS user_id, + p.teamspace_project, + p.hour_budget_minutes, + p.close_review_required, + p.closed_at, + pte.teamspace_project AS default_teamspace_project, + pte.hour_budget_minutes AS default_hour_budget_minutes + FROM projects p + LEFT JOIN LATERAL ( + SELECT teamspace_project, hour_budget_minutes + FROM project_teamspace_entries + WHERE project_id = p.id + AND is_default + ORDER BY id ASC + LIMIT 1 + ) pte ON true + WHERE p.ticket_id = $1 + AND p.user_id = $2 + LIMIT 1; + `, + [ticketId, ownerUserId] + ); + + return result.rows[0] ?? null; +} + +async function ticketHasAnyProject(client: QueryClient, ticketId: string) { + const result = await client.query<{ exists: boolean }>( + ` + SELECT EXISTS ( + SELECT 1 + FROM projects + WHERE ticket_id = $1 + ) AS exists; + `, + [ticketId] + ); + + return Boolean(result.rows[0]?.exists); +} + +async function planExistingZammadTicketSync( + client: QueryClient, + localTicket: LocalZammadSyncTicket, + zammadTicket: ZammadTicketImport, + userId: string, + fields: ZammadSyncExistingFields +): Promise { + if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) { + return null; + } + + const organization = fields.organization || !localTicket.work_type ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null; + + if ((fields.organization || !localTicket.work_type) && !organization) { + return { + ticketNumber: localTicket.ticket_number, + action: "skip", + reason: "Zammad-Organisation ist lokal nicht synchronisiert.", + local: localSyncSnapshot(localTicket), + zammad: zammadSyncSnapshot(zammadTicket), + changes: [] + }; + } + + const changes: ZammadSyncChange[] = []; + + if (fields.title) { + addSyncChange(changes, "Titel", localTicket.title, zammadTicket.title); + } + + if (fields.organization && organization) { + addSyncChange(changes, "Organisation", localTicket.organization_id, organization.id); + addSyncChange(changes, "Kunde", localTicket.customer_name ?? localTicket.organization_name, organization.name); + } + + if (fields.type) { + addSyncChange(changes, "Typ", localTicket.work_type, zammadTicket.workType); + } + + if (fields.type && zammadTicket.workType === "support") { + if (await ticketHasAnyProject(client, localTicket.id)) { + changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" }); + } + + return { + 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), + zammad: zammadSyncSnapshot(zammadTicket), + changes + }; + } + + const shouldManageConsultingProject = + zammadTicket.workType === "consulting" && ((fields.type && zammadTicket.workType === "consulting") || localTicket.work_type === "consulting"); + let project: SyncProjectSnapshot | null = null; + + if (shouldManageConsultingProject) { + const projectOwnerUserId = await projectOwnerUserIdForZammadOwner(client, zammadTicket.zammadOwnerId, userId); + project = await getProjectSnapshotForTicketOwner(client, localTicket.id, projectOwnerUserId); + + if (zammadTicket.zammadStateId === 4 && project?.closed_at) { + return null; + } + + if (!project) { + changes.push({ field: "Projekt", from: null, to: "wird angelegt" }); + } + + if (fields.projectName) { + addSyncChange(changes, "Teamspace-Projekt", project?.teamspace_project ?? null, zammadTicket.teamspaceProject); + } + + if (fields.budget) { + addSyncChange(changes, "Teamspace-Budget", project?.hour_budget_minutes ?? null, zammadTicket.teamspaceBudgetMinutes); + } + + if (zammadTicket.zammadStateId === 4 && project?.close_review_required !== true) { + changes.push({ field: "TS/TT-Prüfung", from: project?.close_review_required ?? false, to: true }); + } + } else if (zammadTicket.workType === "consulting" && zammadTicket.zammadStateId === 4) { + const hasProject = await ticketHasAnyProject(client, localTicket.id); + + if (hasProject) { + changes.push({ field: "TS/TT-Prüfung", from: false, to: true }); + } + } + + return { + 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), + zammad: zammadSyncSnapshot(zammadTicket), + changes + }; +} + +async function planConsultingImport(client: QueryClient, zammadTicket: ZammadTicketImport): Promise { + if (await ticketExists(zammadTicket.ticketNumber)) { + return { + ticketNumber: zammadTicket.ticketNumber, + action: "skip", + reason: "Ticket existiert bereits in TicketTracker.", + local: null, + zammad: zammadSyncSnapshot(zammadTicket), + changes: [] + }; + } + + const organization = await getOrganizationByZammadId(client, zammadTicket.organizationId); + + if (!organization) { + return { + ticketNumber: zammadTicket.ticketNumber, + action: "skip", + reason: "Zammad-Organisation ist lokal nicht synchronisiert.", + local: null, + zammad: zammadSyncSnapshot(zammadTicket), + changes: [] + }; + } + + return { + ticketNumber: zammadTicket.ticketNumber, + action: "create", + reason: zammadTicket.zammadStateId === 4 ? "Consulting-Ticket wird angelegt und zur TS/TT-Prüfung markiert." : "Consulting-Ticket wird angelegt.", + local: null, + zammad: zammadSyncSnapshot(zammadTicket), + changes: [ + { field: "Ticket", from: null, to: zammadTicket.ticketNumber }, + { 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: "Teamspace-Budget", from: null, to: zammadTicket.teamspaceBudgetMinutes }, + ...(zammadTicket.zammadStateId === 4 ? [{ field: "TS/TT-Prüfung", from: false, to: true }] : []) + ] + }; +} + +function updateZammadSyncResultFromItem(result: ZammadSyncResult, item: ZammadSyncItem, localWorkType: "support" | "consulting" | null = null) { + result.items.push(item); + + if (item.action === "create") { + result.created += 1; + } else if (item.action === "update") { + result.updated += 1; + } else if (item.action === "skip") { + result.skipped += 1; + } else if (item.action === "error") { + result.skipped += 1; + result.errors.push({ ticketNumber: item.ticketNumber, error: item.reason ?? "Unbekannter Fehler" }); + } + + if (item.reason === "Zammad-Organisation ist lokal nicht synchronisiert.") { + result.organizationMissing += 1; + } + + if (item.changes.some((change) => change.field === "TS/TT-Prüfung")) { + result.markedForCloseReview += 1; + } + + const targetTypeChange = item.changes.find((change) => change.field === "Typ"); + + if (targetTypeChange?.to === "consulting" && localWorkType !== "consulting") { + result.convertedToConsulting += 1; + } + + if (targetTypeChange?.to === "support" && localWorkType === "consulting") { + result.convertedToSupport += 1; + } +} + +async function updateDefaultProjectTeamspaceEntry( + client: QueryClient, + projectId: string, + updates: { projectName?: string | null; budgetMinutes?: number | null } +) { + const setParts: string[] = []; + const params: unknown[] = []; + + if (Object.prototype.hasOwnProperty.call(updates, "projectName")) { + params.push(updates.projectName ?? null); + setParts.push(`teamspace_project = $${params.length}`); + } + + if (Object.prototype.hasOwnProperty.call(updates, "budgetMinutes")) { + params.push(updates.budgetMinutes ?? null); + setParts.push(`hour_budget_minutes = $${params.length}`); + } + + if (setParts.length === 0) { + return; + } + + params.push(projectId); + await client.query( + ` + UPDATE project_teamspace_entries + SET ${setParts.join(", ")}, + updated_at = now() + WHERE project_id = $${params.length} + AND is_default; + `, + params + ); +} + +async function applyZammadTicketSync( + client: QueryClient, + localTicket: { id: string; ticket_number: string; work_type: "support" | "consulting" | null }, + zammadTicket: ZammadTicketImport, + userId: string, + fields: ZammadSyncExistingFields +) { + const organization = fields.organization || !localTicket.work_type ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null; + + if ((fields.organization || !localTicket.work_type) && !organization) { + return "organization-missing" as const; + } + + await client.query( + ` + UPDATE tickets + SET title = CASE WHEN $2::boolean THEN $3 ELSE title END, + organization_id = CASE WHEN $4::boolean THEN $5 ELSE organization_id END, + customer_name = CASE WHEN $4::boolean THEN $6 ELSE customer_name END, + work_type = CASE WHEN $7::boolean THEN $8 ELSE work_type END, + zammad_ticket_id = $9, + zammad_group_id = $10, + zammad_priority_id = $11, + zammad_state_id = $12, + zammad_owner_id = $13, + zammad_preferences = $14::jsonb + WHERE id = $1; + `, + [ + localTicket.id, + fields.title, + zammadTicket.title, + fields.organization, + organization?.id ?? null, + organization?.name ?? null, + fields.type, + zammadTicket.workType, + zammadTicket.zammadTicketId, + zammadTicket.zammadGroupId, + zammadTicket.zammadPriorityId, + zammadTicket.zammadStateId, + zammadTicket.zammadOwnerId, + JSON.stringify(zammadTicket.preferences) + ] + ); + + if (fields.organization && organization) { + await client.query( + ` + UPDATE sessions + SET organization_id = $1, + customer_name = $2 + WHERE ticket_id = $3; + `, + [organization.id, organization.name, localTicket.id] + ); + } + + if (fields.type && zammadTicket.workType === "support") { + await deleteAllProjectDataForTicket(client, localTicket.id); + await client.query( + ` + UPDATE sessions + SET work_type = CASE WHEN recurring_billing_id IS NULL THEN 'support' ELSE work_type END, + project_teamspace_entry_id = NULL + WHERE ticket_id = $1; + `, + [localTicket.id] + ); + return "support" as const; + } + + 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 client.query( + ` + UPDATE sessions + SET work_type = CASE WHEN recurring_billing_id IS NULL THEN 'consulting' ELSE work_type END + WHERE ticket_id = $1; + `, + [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; + } + + 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 autoAssignSingleProjectTeamspaceEntry(client, localTicket.id); + return zammadTicket.zammadStateId === 4 ? "close-review" as const : "updated" as const; + } + + if (zammadTicket.workType === "consulting" && zammadTicket.zammadStateId === 4) { + await client.query( + ` + UPDATE projects + SET close_review_required = true, + updated_at = now() + WHERE ticket_id = $1; + `, + [localTicket.id] + ); + return "close-review" as const; + } + + return "updated" as const; +} + async function reopenPeriodsForSession( client: { query: (text: string, params?: unknown[]) => Promise }, ticketId: string, @@ -2276,6 +3208,7 @@ async function getTicketDetails(ticketId: number, userId: string, filters: { mon entry_totals.teamspace_entries, CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, p.closed_at, + p.close_review_required, p.created_at, p.updated_at FROM projects p @@ -3974,7 +4907,8 @@ app.post("/api/sessions", requireUser, async (req, res) => { ); await ensureProjectForTicket(client, ticket.id, projectOwnerUserId, { teamspaceProject: zammadTicket?.teamspaceProject ?? null, - hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null + hourBudgetMinutes: zammadTicket?.teamspaceBudgetMinutes ?? null, + closeReviewRequired: zammadTicket?.zammadStateId === 4 }); projectTeamspaceEntryId = await resolveProjectTeamspaceEntryForSession( client, @@ -4020,6 +4954,10 @@ app.post("/api/sessions", requireUser, async (req, res) => { ); await reopenPeriodsForSession(client, ticket.id, startedAt, user.id); + if (workType === "consulting") { + await reopenProjectsForTicket(client, ticket.id); + await showProjectsForUserTicket(client, ticket.id, user.id); + } return { ticket, @@ -4112,10 +5050,53 @@ app.get("/api/tickets/lookup", requireUser, async (req, res) => { const zammadTicket = await fetchZammadTicketByNumber(userId, ticketNumber); if (zammadTicket) { - await withTransaction(async (client) => { - await importZammadTicketForUser(client, zammadTicket, userId); + const organization = await getOrganizationByZammadId({ query }, zammadTicket.organizationId); + + if (!organization) { + throw badRequest("Zammad Organisation ist lokal nicht synchronisiert"); + } + + 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, + manual_consumption_minutes: 0, + display_order: 0 + } + ] + : []; + + res.json({ + ticket: { + id: `zammad-${zammadTicket.zammadTicketId}`, + ticket_number: zammadTicket.ticketNumber, + title: zammadTicket.title, + organization_id: organization.id, + organization_name: organization.name, + customer_name: organization.name, + work_type: zammadTicket.workType, + zammad_ticket_id: String(zammadTicket.zammadTicketId), + zammad_group_id: zammadTicket.zammadGroupId === null ? null : String(zammadTicket.zammadGroupId), + zammad_priority_id: zammadTicket.zammadPriorityId === null ? null : String(zammadTicket.zammadPriorityId), + zammad_state_id: zammadTicket.zammadStateId === null ? null : String(zammadTicket.zammadStateId), + zammad_owner_id: zammadTicket.zammadOwnerId === null ? null : String(zammadTicket.zammadOwnerId), + project_id: null, + is_project: zammadTicket.workType === "consulting", + project_name: zammadTicket.teamspaceProject, + project_hour_budget_minutes: zammadTicket.teamspaceBudgetMinutes, + project_teamspace_entry_count: previewEntries.length, + project_teamspace_entries: previewEntries, + project_owner_user_id: null, + project_owner_display_name: null, + can_edit_project: true, + has_recurring_billing: false + } }); - result = await query(lookupSql, [ticketNumber, userId]); + return; } } @@ -4204,9 +5185,512 @@ app.get("/api/tickets/search", requireUser, async (req, res) => { res.json({ tickets: result.rows }); }); +async function runExistingZammadSync( + userId: string, + fields: ZammadSyncExistingFields, + dryRun: boolean, + selectedTicketNumbers: Set | null +) { + const result = emptyZammadSyncResult(dryRun ? "preview" : "applied"); + const localTickets = selectedTicketNumbers + ? await query( + ` + SELECT + t.id::text AS id, + t.ticket_number, + t.title, + t.organization_id::text AS organization_id, + o.name AS organization_name, + t.customer_name, + t.work_type, + t.zammad_ticket_id, + t.zammad_group_id, + t.zammad_priority_id, + t.zammad_state_id, + t.zammad_owner_id, + t.zammad_preferences + FROM tickets t + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + WHERE t.ticket_number = ANY($1::text[]) + ORDER BY t.ticket_number ASC; + `, + [Array.from(selectedTicketNumbers)] + ) + : await query( + ` + SELECT + t.id::text AS id, + t.ticket_number, + t.title, + t.organization_id::text AS organization_id, + o.name AS organization_name, + t.customer_name, + t.work_type, + t.zammad_ticket_id, + t.zammad_group_id, + t.zammad_priority_id, + t.zammad_state_id, + t.zammad_owner_id, + t.zammad_preferences + FROM tickets t + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + WHERE t.ticket_number LIKE 'Ticket#%' + ORDER BY t.ticket_number ASC; + ` + ); + + for (const localTicket of localTickets.rows) { + result.checked += 1; + + try { + const zammadTicket = await fetchZammadTicketByNumber(userId, localTicket.ticket_number); + + if (!zammadTicket) { + result.missingInZammad += 1; + updateZammadSyncResultFromItem(result, { + ticketNumber: localTicket.ticket_number, + action: "skip", + reason: "Ticket wurde in Zammad nicht gefunden.", + local: localSyncSnapshot(localTicket), + zammad: null, + changes: [] + }, localTicket.work_type); + continue; + } + + const plan = await planExistingZammadTicketSync({ query }, localTicket, zammadTicket, userId, fields); + + if (!plan) { + continue; + } + + if (plan.action !== "update") { + updateZammadSyncResultFromItem(result, plan, localTicket.work_type); + continue; + } + + if (!dryRun) { + const status = await withTransaction(async (client) => applyZammadTicketSync(client, localTicket, zammadTicket, userId, fields)); + + if (status === "organization-missing") { + const item = { + ...plan, + action: "skip" as const, + reason: "Zammad-Organisation ist lokal nicht synchronisiert.", + changes: [] + }; + updateZammadSyncResultFromItem(result, item, localTicket.work_type); + continue; + } + } + + updateZammadSyncResultFromItem(result, plan, localTicket.work_type); + } catch (error) { + updateZammadSyncResultFromItem(result, { + ticketNumber: localTicket.ticket_number, + action: "error", + reason: error instanceof Error ? error.message : "Unbekannter Fehler", + local: localSyncSnapshot(localTicket), + zammad: null, + changes: [] + }, localTicket.work_type); + } + } + + return result; +} + +async function runConsultingZammadImport(userId: string, dryRun: boolean, selectedTicketNumbers: Set | null) { + const result = emptyZammadSyncResult(dryRun ? "preview" : "applied"); + const zammadTickets = await fetchZammadConsultingTicketsForOwner(userId); + + for (const zammadTicket of zammadTickets) { + if (selectedTicketNumbers && !selectedTicketNumbers.has(zammadTicket.ticketNumber)) { + continue; + } + + result.checked += 1; + + try { + const plan = await planConsultingImport({ query }, zammadTicket); + + if (plan.action !== "create") { + updateZammadSyncResultFromItem(result, plan); + continue; + } + + if (!dryRun) { + await withTransaction(async (client) => { + await importZammadTicketForUser(client, zammadTicket, userId); + }); + } + + updateZammadSyncResultFromItem(result, plan); + + if (zammadTicket.zammadStateId === 4 && !plan.changes.some((change) => change.field === "TS/TT-Prüfung")) { + result.markedForCloseReview += 1; + } + } catch (error: any) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + + if (error?.status === 400 || message === "Zammad Organisation ist lokal nicht synchronisiert") { + updateZammadSyncResultFromItem(result, { + ticketNumber: zammadTicket.ticketNumber, + action: "skip", + reason: "Zammad-Organisation ist lokal nicht synchronisiert.", + local: null, + zammad: zammadSyncSnapshot(zammadTicket), + changes: [] + }); + } else { + updateZammadSyncResultFromItem(result, { + ticketNumber: zammadTicket.ticketNumber, + action: "error", + reason: message, + local: null, + zammad: zammadSyncSnapshot(zammadTicket), + changes: [] + }); + } + } + } + + return result; +} + +async function runExistingZammadSyncFromPreview( + userId: string, + previewResult: ZammadSyncResult, + fields: ZammadSyncExistingFields, + selectedTicketNumbers: Set +) { + const result = emptyZammadSyncResult("applied"); + const cachedTickets = new Map(); + + for (const item of previewResult.items) { + if (!selectedTicketNumbers.has(item.ticketNumber)) { + continue; + } + + const zammadTicket = zammadTicketFromSyncSnapshot(item.zammad); + + if (zammadTicket) { + cachedTickets.set(item.ticketNumber, zammadTicket); + } + } + + if (cachedTickets.size === 0) { + return result; + } + + const localTickets = await query( + ` + SELECT + t.id::text AS id, + t.ticket_number, + t.title, + t.organization_id::text AS organization_id, + o.name AS organization_name, + t.customer_name, + t.work_type, + t.zammad_ticket_id, + t.zammad_group_id, + t.zammad_priority_id, + t.zammad_state_id, + t.zammad_owner_id, + t.zammad_preferences + FROM tickets t + LEFT JOIN organizations o ON o.zammad_id = t.organization_id + WHERE t.ticket_number = ANY($1::text[]) + ORDER BY t.ticket_number ASC; + `, + [Array.from(cachedTickets.keys())] + ); + + for (const localTicket of localTickets.rows) { + result.checked += 1; + + const zammadTicket = cachedTickets.get(localTicket.ticket_number); + + if (!zammadTicket) { + continue; + } + + try { + const plan = await planExistingZammadTicketSync({ query }, localTicket, zammadTicket, userId, fields); + + if (!plan) { + continue; + } + + if (plan.action !== "update") { + updateZammadSyncResultFromItem(result, plan, localTicket.work_type); + continue; + } + + const status = await withTransaction(async (client) => applyZammadTicketSync(client, localTicket, zammadTicket, userId, fields)); + + if (status === "organization-missing") { + updateZammadSyncResultFromItem(result, { + ...plan, + action: "skip", + reason: "Zammad-Organisation ist lokal nicht synchronisiert.", + changes: [] + }, localTicket.work_type); + continue; + } + + updateZammadSyncResultFromItem(result, plan, localTicket.work_type); + } catch (error) { + updateZammadSyncResultFromItem(result, { + ticketNumber: localTicket.ticket_number, + action: "error", + reason: error instanceof Error ? error.message : "Unbekannter Fehler", + local: localSyncSnapshot(localTicket), + zammad: zammadSyncSnapshot(zammadTicket), + changes: [] + }, localTicket.work_type); + } + } + + return result; +} + +async function runConsultingZammadImportFromPreview(userId: string, previewResult: ZammadSyncResult, selectedTicketNumbers: Set) { + const result = emptyZammadSyncResult("applied"); + + for (const item of previewResult.items) { + if (!selectedTicketNumbers.has(item.ticketNumber)) { + continue; + } + + const zammadTicket = zammadTicketFromSyncSnapshot(item.zammad); + + if (!zammadTicket) { + continue; + } + + result.checked += 1; + + try { + const plan = await planConsultingImport({ query }, zammadTicket); + + if (plan.action !== "create") { + updateZammadSyncResultFromItem(result, plan); + continue; + } + + await withTransaction(async (client) => { + await importZammadTicketForUser(client, zammadTicket, userId); + }); + updateZammadSyncResultFromItem(result, plan); + } catch (error: any) { + const message = error instanceof Error ? error.message : "Unbekannter Fehler"; + + if (error?.status === 400 || message === "Zammad Organisation ist lokal nicht synchronisiert") { + updateZammadSyncResultFromItem(result, { + ticketNumber: zammadTicket.ticketNumber, + action: "skip", + reason: "Zammad-Organisation ist lokal nicht synchronisiert.", + local: null, + zammad: zammadSyncSnapshot(zammadTicket), + changes: [] + }); + } else { + updateZammadSyncResultFromItem(result, { + ticketNumber: zammadTicket.ticketNumber, + action: "error", + reason: message, + local: null, + zammad: zammadSyncSnapshot(zammadTicket), + changes: [] + }); + } + } + } + + return result; +} + +type ZammadSyncJob = { + id: string; + userId: string; + status: "running" | "complete" | "error"; + result: ZammadSyncResult | null; + error: string | null; + startedAt: string; + finishedAt: string | null; +}; + +const zammadSyncJobs = new Map(); + +function cleanupZammadSyncJobs() { + const cutoff = Date.now() - 6 * 60 * 60 * 1000; + + for (const [jobId, job] of zammadSyncJobs.entries()) { + const finishedAt = job.finishedAt ? Date.parse(job.finishedAt) : null; + + if (finishedAt && finishedAt < cutoff) { + zammadSyncJobs.delete(jobId); + } + } +} + +function createZammadSyncJob(userId: string, run: () => Promise) { + cleanupZammadSyncJobs(); + + const job: ZammadSyncJob = { + id: randomUUID(), + userId, + status: "running", + result: null, + error: null, + startedAt: new Date().toISOString(), + finishedAt: null + }; + + zammadSyncJobs.set(job.id, job); + + void run() + .then((result) => { + job.status = "complete"; + job.result = result; + job.finishedAt = new Date().toISOString(); + }) + .catch((error) => { + job.status = "error"; + job.error = error instanceof Error ? error.message : "Unbekannter Fehler"; + job.finishedAt = new Date().toISOString(); + }); + + return job; +} + +app.post("/api/zammad-sync/existing", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const fields = zammadSyncExistingFields(req.body.fields); + const dryRun = optionalBoolean(req.body.dryRun, true); + const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); + + res.json(await runExistingZammadSync(userId, fields, dryRun, selectedTicketNumbers)); +}); + +app.post("/api/zammad-sync/consulting-import", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const dryRun = optionalBoolean(req.body.dryRun, true); + const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); + + res.json(await runConsultingZammadImport(userId, dryRun, selectedTicketNumbers)); +}); + +app.post("/api/zammad-sync/jobs/existing", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const fields = zammadSyncExistingFields(req.body.fields); + const dryRun = optionalBoolean(req.body.dryRun, true); + const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); + const job = createZammadSyncJob(userId, () => runExistingZammadSync(userId, fields, dryRun, selectedTicketNumbers)); + + res.status(202).json({ job }); +}); + +app.post("/api/zammad-sync/jobs/consulting-import", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const dryRun = optionalBoolean(req.body.dryRun, true); + const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); + const job = createZammadSyncJob(userId, () => runConsultingZammadImport(userId, dryRun, selectedTicketNumbers)); + + res.status(202).json({ job }); +}); + +app.post("/api/zammad-sync/jobs/:jobId/apply-existing", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const previewJob = zammadSyncJobs.get(String(req.params.jobId)); + + if (!previewJob || previewJob.userId !== userId || previewJob.status !== "complete" || !previewJob.result || previewJob.result.mode !== "preview") { + res.status(404).json({ error: "Vorschau-Lauf nicht gefunden" }); + return; + } + + const fields = zammadSyncExistingFields(req.body.fields); + const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); + + if (!selectedTicketNumbers || selectedTicketNumbers.size === 0) { + throw badRequest("ticketNumbers must contain at least one ticket"); + } + + const job = createZammadSyncJob(userId, () => runExistingZammadSyncFromPreview(userId, previewJob.result!, fields, selectedTicketNumbers)); + + res.status(202).json({ job }); +}); + +app.post("/api/zammad-sync/jobs/:jobId/apply-consulting-import", requireUser, async (req, res) => { + const userId = currentUser(req).id; + const previewJob = zammadSyncJobs.get(String(req.params.jobId)); + + if (!previewJob || previewJob.userId !== userId || previewJob.status !== "complete" || !previewJob.result || previewJob.result.mode !== "preview") { + res.status(404).json({ error: "Vorschau-Lauf nicht gefunden" }); + return; + } + + const selectedTicketNumbers = zammadSyncSelectedTicketNumbers(req.body.ticketNumbers); + + if (!selectedTicketNumbers || selectedTicketNumbers.size === 0) { + throw badRequest("ticketNumbers must contain at least one ticket"); + } + + const job = createZammadSyncJob(userId, () => runConsultingZammadImportFromPreview(userId, previewJob.result!, selectedTicketNumbers)); + + res.status(202).json({ job }); +}); + +app.get("/api/zammad-sync/jobs/:jobId", requireUser, async (req, res) => { + const job = zammadSyncJobs.get(String(req.params.jobId)); + + if (!job || job.userId !== currentUser(req).id) { + res.status(404).json({ error: "Sync-Lauf nicht gefunden" }); + return; + } + + res.json({ job }); +}); + app.get("/api/projects", requireUser, async (req, res) => { const userId = currentUser(req).id; const includeClosed = req.query.includeClosed === "true"; + const hiddenOnly = req.query.hidden === "true"; + const hiddenProjectWhere = ` + ( + p.user_id = $1 + OR EXISTS ( + SELECT 1 + FROM sessions viewer_sessions + WHERE viewer_sessions.ticket_id = p.ticket_id + AND viewer_sessions.user_id = $1 + ) + ) + `; + const hiddenCountResult = await query<{ count: string }>( + ` + SELECT COUNT(*) AS count + FROM projects p + WHERE ${hiddenProjectWhere} + AND EXISTS ( + SELECT 1 + FROM hidden_user_projects hup + WHERE hup.user_id = $1 + AND hup.project_id = p.id + ); + `, + [userId] + ); + const totalCountResult = await query<{ count: string }>( + ` + SELECT COUNT(*) AS count + FROM projects p + WHERE ${hiddenProjectWhere}; + `, + [userId] + ); const result = await query( ` SELECT @@ -4230,6 +5714,7 @@ app.get("/api/projects", requireUser, async (req, res) => { entry_totals.total_hour_budget_minutes AS hour_budget_minutes, entry_totals.total_manual_consumption_minutes AS manual_consumption_minutes, p.closed_at, + p.close_review_required, p.created_at, p.updated_at, COALESCE(session_totals.session_count, 0)::int AS session_count, @@ -4246,6 +5731,12 @@ app.get("/api/projects", requireUser, async (req, res) => { entry_totals.teamspace_entry_count, entry_totals.teamspace_entries, CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, + EXISTS ( + SELECT 1 + FROM hidden_user_projects hup + WHERE hup.user_id = $1 + AND hup.project_id = p.id + ) AS hidden_for_user, COALESCE(todo_totals.open_todos, 0)::int AS open_todos, COALESCE(todo_totals.overdue_todos, 0)::int AS overdue_todos, COALESCE(todo_totals.done_todos, 0)::int AS done_todos @@ -4318,16 +5809,40 @@ app.get("/api/projects", requireUser, async (req, res) => { AND viewer_sessions.user_id = $1 ) ) - AND ($2::boolean OR p.closed_at IS NULL) + AND ( + ( + $3::boolean + AND EXISTS ( + SELECT 1 + FROM hidden_user_projects hup + WHERE hup.user_id = $1 + AND hup.project_id = p.id + ) + ) + OR ( + NOT $3::boolean + AND NOT EXISTS ( + SELECT 1 + FROM hidden_user_projects hup + WHERE hup.user_id = $1 + AND hup.project_id = p.id + ) + ) + ) + AND ($2::boolean OR $3::boolean OR p.closed_at IS NULL) ORDER BY CASE WHEN p.closed_at IS NULL THEN 0 ELSE 1 END, p.updated_at DESC, t.ticket_number ASC; `, - [userId, includeClosed] + [userId, includeClosed, hiddenOnly] ); - res.json({ projects: result.rows }); + res.json({ + projects: result.rows, + hiddenCount: Number(hiddenCountResult.rows[0]?.count ?? 0), + totalCount: Number(totalCountResult.rows[0]?.count ?? 0) + }); }); app.get("/api/projects/:projectId", requireUser, async (req, res) => { @@ -4367,6 +5882,7 @@ app.get("/api/projects/:projectId", requireUser, async (req, res) => { entry_totals.teamspace_entries, CASE WHEN COALESCE(entry_totals.teamspace_entry_count, 0) > 1 THEN COALESCE(unassigned_sessions.session_count, 0) ELSE 0 END::int AS unassigned_teamspace_session_count, p.closed_at, + p.close_review_required, p.created_at, p.updated_at FROM projects p @@ -4549,7 +6065,8 @@ app.post("/api/projects", requireUser, async (req, res) => { if (zammadTicket && projectOwnerUserId !== userId) { await ensureProjectForTicket(client, ticketId, projectOwnerUserId, { teamspaceProject: zammadTicket.teamspaceProject, - hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes + hourBudgetMinutes: zammadTicket.teamspaceBudgetMinutes, + closeReviewRequired: zammadTicket.zammadStateId === 4 }); return getProjectRowForTicket(client, ticketId, projectOwnerUserId, userId); @@ -4566,9 +6083,10 @@ app.post("/api/projects", requireUser, async (req, res) => { starts_on, ends_on, hour_budget_minutes, - manual_consumption_minutes + manual_consumption_minutes, + close_review_required ) - VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, $8, $9) + VALUES ($1, $2, $3, $4, $5, $6::date, $7::date, $8, $9, $15) ${zammadTicket ? ` ON CONFLICT (user_id, ticket_id) DO UPDATE SET @@ -4579,6 +6097,7 @@ app.post("/api/projects", requireUser, async (req, res) => { ends_on = EXCLUDED.ends_on, hour_budget_minutes = EXCLUDED.hour_budget_minutes, manual_consumption_minutes = EXCLUDED.manual_consumption_minutes, + close_review_required = projects.close_review_required OR EXCLUDED.close_review_required, updated_at = now() ` : ""} RETURNING @@ -4605,6 +6124,7 @@ app.post("/api/projects", requireUser, async (req, res) => { manual_consumption_minutes AS consumption_minutes, 0::int AS unassigned_teamspace_session_count, NULL::timestamptz AS closed_at, + close_review_required, created_at, updated_at; `, @@ -4622,7 +6142,8 @@ app.post("/api/projects", requireUser, async (req, res) => { organization.id, organization.name, title, - existingTicket.rows[0]?.title ?? null + existingTicket.rows[0]?.title ?? null, + Boolean(zammadTicket?.zammadStateId === 4) ] ); @@ -4790,6 +6311,7 @@ app.patch("/api/projects/:projectId", requireUser, async (req, res) => { ) + manual_consumption_minutes)::int AS consumption_minutes, 0::int AS unassigned_teamspace_session_count, closed_at, + close_review_required, created_at, updated_at; `, @@ -4912,6 +6434,7 @@ app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => { ` UPDATE projects SET closed_at = CASE WHEN $3::boolean THEN now() ELSE NULL END, + close_review_required = CASE WHEN $3::boolean THEN false ELSE close_review_required END, updated_at = now() WHERE id = $1 AND user_id = $2 @@ -4928,6 +6451,68 @@ app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => { res.json({ project: result.rows[0] }); }); +app.post("/api/projects/:projectId/hide", requireUser, async (req, res) => { + const projectId = parsePositiveInteger(req.params.projectId, "projectId"); + const userId = currentUser(req).id; + + const result = await query<{ id: string }>( + ` + SELECT p.id::text AS id + FROM projects p + WHERE p.id = $1 + AND ( + p.user_id = $2 + OR EXISTS ( + SELECT 1 + FROM sessions s + WHERE s.ticket_id = p.ticket_id + AND s.user_id = $2 + ) + ); + `, + [projectId, userId] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: "Projekt nicht gefunden" }); + return; + } + + await query( + ` + INSERT INTO hidden_user_projects (user_id, project_id) + VALUES ($1, $2) + ON CONFLICT (user_id, project_id) + DO UPDATE SET hidden_at = now(); + `, + [userId, projectId] + ); + + res.json({ hidden: result.rows[0] }); +}); + +app.delete("/api/projects/:projectId/hide", requireUser, async (req, res) => { + const projectId = parsePositiveInteger(req.params.projectId, "projectId"); + const userId = currentUser(req).id; + + const result = await query<{ id: string }>( + ` + DELETE FROM hidden_user_projects + WHERE user_id = $1 + AND project_id = $2 + RETURNING project_id::text AS id; + `, + [userId, projectId] + ); + + if (result.rowCount === 0) { + res.status(404).json({ error: "Ausblendung nicht gefunden" }); + return; + } + + res.json({ shown: result.rows[0] }); +}); + app.delete("/api/projects/:projectId", requireUser, async (req, res) => { const projectId = parsePositiveInteger(req.params.projectId, "projectId"); const userId = currentUser(req).id; diff --git a/backend/src/migrations.ts b/backend/src/migrations.ts index a6d4ed8..db51281 100644 --- a/backend/src/migrations.ts +++ b/backend/src/migrations.ts @@ -469,6 +469,7 @@ export async function migrate() { hour_budget_minutes INTEGER, manual_consumption_minutes INTEGER NOT NULL DEFAULT 0, closed_at TIMESTAMPTZ, + close_review_required BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (user_id, ticket_id), @@ -479,6 +480,7 @@ export async function migrate() { `); await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;"); + await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS close_review_required BOOLEAN NOT NULL DEFAULT false;"); await query("ALTER TABLE projects ADD COLUMN IF NOT EXISTS manual_consumption_minutes INTEGER NOT NULL DEFAULT 0;"); await query(` DO $$ @@ -512,6 +514,15 @@ export async function migrate() { 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(` + CREATE TABLE IF NOT EXISTS hidden_user_projects ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + project_id BIGINT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + hidden_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (user_id, project_id) + ); + `); + await query("CREATE INDEX IF NOT EXISTS idx_hidden_user_projects_project ON hidden_user_projects(project_id, user_id);"); await query(` DO $$ BEGIN diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3a6683a..cdfe581 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -6,6 +6,7 @@ import { Command, ChartNoAxesCombined, CircleHelp, + DatabaseZap, ExternalLink, FileText, FolderKanban, @@ -84,6 +85,7 @@ import { SetupPage } from "./views/SetupPage"; import { TicketDetailPage } from "./views/TicketDetailPage"; import { TimerPage } from "./views/TimerPage"; import { TodosPage } from "./views/TodosPage"; +import { ZammadSyncPage } from "./views/ZammadSyncPage"; import type { AuthUser, PeriodType, TicketDetailPeriodType, TicketSearchResult, TicketTodo } from "./types"; import type { Dispatch, DragEvent, SetStateAction } from "react"; @@ -217,6 +219,10 @@ function routeFromPath(pathWithSearch: string) { return { page: "recurring" as const }; } + if (pathname.startsWith("/zammad-sync")) { + return { page: "zammad-sync" as const }; + } + if (pathname.startsWith("/admin/users")) { return { page: "admin-users" as const }; } @@ -1207,6 +1213,7 @@ export function App() { { type: "item" as const, href: "/statistics", label: "Statistiken", icon: ChartNoAxesCombined, active: path.startsWith("/statistics") }, { type: "item" as const, href: "/recurring", label: "Fixe Abrechnung", icon: Repeat, active: path.startsWith("/recurring") }, { type: "separator" as const, key: "before-profile" }, + { type: "item" as const, href: "/zammad-sync", label: "Zammad-Sync", icon: DatabaseZap, active: path.startsWith("/zammad-sync") }, { type: "item" as const, href: "/profile", label: "Profil", icon: UserCog, active: path.startsWith("/profile") }, { type: "separator" as const, key: "before-faq" }, { type: "item" as const, href: "/faq", label: "FAQ", icon: CircleHelp, active: path.startsWith("/faq") } @@ -1471,6 +1478,7 @@ export function App() { {!isAdmin && route.page === "projects" ? : null} {!isAdmin && route.page === "todos" ? : null} {!isAdmin && route.page === "recurring" ? : null} + {!isAdmin && route.page === "zammad-sync" ? : null} {route.page === "faq" ? : null} {route.page === "profile" ? : null} {route.page === "admin-users" && isAdmin ? : null} diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 0b5449f..5cbb568 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -42,6 +42,54 @@ type DeleteSessionResult = { }; }; +export type ZammadSyncExistingFields = { + title: boolean; + organization: boolean; + type: boolean; + projectName: boolean; + budget: boolean; +}; + +export type ZammadSyncChange = { + field: string; + from: unknown; + to: unknown; +}; + +export type ZammadSyncItem = { + ticketNumber: string; + action: "update" | "create" | "skip" | "error"; + reason: string | null; + local: Record | null; + zammad: Record | null; + changes: ZammadSyncChange[]; +}; + +export type ZammadSyncResult = { + mode: "preview" | "applied"; + checked: number; + updated: number; + created: number; + skipped: number; + missingInZammad: number; + organizationMissing: number; + markedForCloseReview: number; + convertedToConsulting: number; + convertedToSupport: number; + errors: Array<{ ticketNumber: string; error: string }>; + items: ZammadSyncItem[]; +}; + +export type ZammadSyncJob = { + id: string; + userId: string; + status: "running" | "complete" | "error"; + result: ZammadSyncResult | null; + error: string | null; + startedAt: string; + finishedAt: string | null; +}; + export type RestoreValidationResult = { valid: boolean; checkedTables: number; @@ -58,15 +106,46 @@ export class ApiError extends Error { } } -async function request(url: string, options: RequestInit = {}): Promise { - const response = await fetch(url, { - credentials: "same-origin", - headers: { - "Content-Type": "application/json", - ...options.headers - }, - ...options - }); +type ApiRequestInit = RequestInit & { + timeoutMs?: number; +}; + +async function request(url: string, options: ApiRequestInit = {}): Promise { + const { timeoutMs, signal, ...fetchOptions } = options; + const controller = timeoutMs ? new AbortController() : null; + const timeout = controller + ? window.setTimeout(() => { + controller.abort(); + }, timeoutMs) + : null; + + if (signal && controller) { + signal.addEventListener("abort", () => controller.abort(), { once: true }); + } + + let response: Response; + + try { + response = await fetch(url, { + signal: controller?.signal ?? signal, + ...fetchOptions, + credentials: "same-origin", + headers: { + "Content-Type": "application/json", + ...fetchOptions.headers + } + }); + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw new ApiError("Anfrage hat zu lange gedauert", 408); + } + + throw error; + } finally { + if (timeout) { + window.clearTimeout(timeout); + } + } if (!response.ok) { const body = await response.json().catch(() => ({})); @@ -266,6 +345,89 @@ export function syncZammadOrganizations(payload: { baseUrl?: string; apiKey?: st }); } +function wait(milliseconds: number) { + return new Promise((resolve) => window.setTimeout(resolve, milliseconds)); +} + +async function waitForZammadSyncJob(jobId: string) { + const startedAt = Date.now(); + const timeoutMs = 10 * 60 * 1000; + + for (;;) { + if (Date.now() - startedAt > timeoutMs) { + throw new ApiError("Zammad-Sync läuft länger als erwartet. Bitte Vorschau neu starten.", 408); + } + + const { job } = await request<{ job: ZammadSyncJob }>(`/api/zammad-sync/jobs/${encodeURIComponent(jobId)}`, { timeoutMs: 20_000 }); + + if (job.status === "complete" && job.result) { + return job.result; + } + + if (job.status === "error") { + throw new ApiError(job.error ?? "Zammad-Sync fehlgeschlagen", 500); + } + + await wait(1200); + } +} + +async function runZammadSyncJob(url: string, body: Record) { + const { job } = await request<{ job: ZammadSyncJob }>(url, { + method: "POST", + timeoutMs: 20_000, + body: JSON.stringify(body) + }); + const result = await waitForZammadSyncJob(job.id); + + return { jobId: job.id, result }; +} + +export async function syncExistingZammadTickets(fields: ZammadSyncExistingFields, options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) { + const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/existing", { + fields, + dryRun: options.dryRun ?? true, + ticketNumbers: options.ticketNumbers + }); + + return result; +} + +export async function importOwnZammadConsultingTickets(options: { dryRun?: boolean; ticketNumbers?: string[] } = {}) { + const { result } = await runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", { + dryRun: options.dryRun ?? true, + ticketNumbers: options.ticketNumbers + }); + + return result; +} + +export function previewExistingZammadTickets(fields: ZammadSyncExistingFields) { + return runZammadSyncJob("/api/zammad-sync/jobs/existing", { + fields, + dryRun: true + }); +} + +export function applyExistingZammadTicketsFromPreview(previewJobId: string, fields: ZammadSyncExistingFields, ticketNumbers: string[]) { + return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-existing`, { + fields, + ticketNumbers + }); +} + +export function previewOwnZammadConsultingTickets() { + return runZammadSyncJob("/api/zammad-sync/jobs/consulting-import", { + dryRun: true + }); +} + +export function applyOwnZammadConsultingTicketsFromPreview(previewJobId: string, ticketNumbers: string[]) { + return runZammadSyncJob(`/api/zammad-sync/jobs/${encodeURIComponent(previewJobId)}/apply-consulting-import`, { + ticketNumbers + }); +} + export function getRecurringBillings() { return request<{ recurringBillings: RecurringBilling[] }>("/api/recurring-billings"); } @@ -324,14 +486,18 @@ export function deleteRecurringBilling(billingId: string) { }); } -export function getProjects(includeClosed = false) { +export function getProjects(includeClosed = false, hidden = false) { const params = new URLSearchParams(); if (includeClosed) { params.set("includeClosed", "true"); } - return request<{ projects: ProjectSummary[] }>(`/api/projects${params.size ? `?${params}` : ""}`); + if (hidden) { + params.set("hidden", "true"); + } + + return request<{ projects: ProjectSummary[]; hiddenCount: number; totalCount: number }>(`/api/projects${params.size ? `?${params}` : ""}`); } export function getProject(projectId: string) { @@ -405,6 +571,18 @@ export function deleteProject(projectId: string) { }); } +export function hideProject(projectId: string) { + return request<{ hidden: { id: string } }>(`/api/projects/${projectId}/hide`, { + method: "POST" + }); +} + +export function unhideProject(projectId: string) { + return request<{ shown: { id: string } }>(`/api/projects/${projectId}/hide`, { + method: "DELETE" + }); +} + export function getTodos(status?: TodoStatus | "all") { const params = new URLSearchParams(); diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 098c73d..0f50de5 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -159,6 +159,8 @@ export type ProjectSummary = { teamspace_entry_count?: number; teamspace_entries?: ProjectTeamspaceEntry[]; unassigned_teamspace_session_count?: number; + close_review_required?: boolean; + hidden_for_user?: boolean; }; export type ProjectTeamspaceEntry = { diff --git a/frontend/src/views/AnalysisPage.tsx b/frontend/src/views/AnalysisPage.tsx index 4b97326..f684372 100644 --- a/frontend/src/views/AnalysisPage.tsx +++ b/frontend/src/views/AnalysisPage.tsx @@ -484,7 +484,7 @@ export function AnalysisPage({ onNavigate }: AnalysisPageProps) { -
+
= [ { value: "open", label: "Offen" }, { value: "all", label: "Alle" }, - { value: "closed", label: "Geschlossen" } + { value: "closed", label: "Geschlossen" }, + { value: "hidden", label: "Ausgeblendet" } ]; const projectBudgetOptions: Array<{ value: ProjectBudgetFilter; label: string }> = [ @@ -94,6 +95,19 @@ const projectTodoOptions: Array<{ value: ProjectTodoFilter; label: string }> = [ const projectSortValues: ProjectSortBy[] = ["updated", "ticket", "title", "organization", "status", "consumption", "consumptionPercent", "remaining", "budget", "todos"]; +const projectSortOptions: Array<{ value: ProjectSortBy; label: string }> = [ + { value: "updated", label: "Aktualisierung" }, + { value: "ticket", label: "Ticket" }, + { value: "title", label: "Titel" }, + { value: "organization", label: "Organisation" }, + { value: "status", label: "Status" }, + { value: "consumption", label: "Verbrauch" }, + { value: "consumptionPercent", label: "Verbrauch %" }, + { value: "remaining", label: "Reststunden" }, + { value: "budget", label: "Kontingent" }, + { value: "todos", label: "ToDos" } +]; + function projectEntryId() { if (typeof crypto !== "undefined" && "randomUUID" in crypto) { return crypto.randomUUID(); @@ -582,6 +596,8 @@ function ProjectFormFields({ form, setForm, organizationLocked, lookupStatus, za export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { const [projects, setProjects] = useState([]); + const [hiddenProjectCount, setHiddenProjectCount] = useState(0); + const [totalProjectCount, setTotalProjectCount] = useState(0); const [loading, setLoading] = useState(true); const [form, setForm] = useState(() => emptyProjectForm()); const [lookupStatus, setLookupStatus] = useState<"idle" | "loading" | "found" | "not-found">("idle"); @@ -599,8 +615,10 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { async function load() { setLoading(true); try { - const result = await getProjects(viewSettings.status !== "open"); + const result = await getProjects(viewSettings.status !== "open", viewSettings.status === "hidden"); setProjects(result.projects); + setHiddenProjectCount(result.hiddenCount ?? 0); + setTotalProjectCount(result.totalCount ?? result.projects.length); } catch (error) { toast.error("Projekte konnten nicht geladen werden", { description: error instanceof Error ? error.message : "Unbekannter Fehler" @@ -686,6 +704,10 @@ 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; } @@ -975,6 +997,34 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { } } + async function hideProjectForUser(project: ProjectSummary) { + try { + await hideProject(project.id); + toast.success("Projekt ausgeblendet", { + description: project.ticket_number + }); + await load(); + } catch (error) { + toast.error("Projekt konnte nicht ausgeblendet werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } + } + + async function unhideProjectForUser(project: ProjectSummary) { + try { + await unhideProject(project.id); + toast.success("Projekt eingeblendet", { + description: project.ticket_number + }); + await load(); + } catch (error) { + toast.error("Projekt konnte nicht eingeblendet werden", { + description: error instanceof Error ? error.message : "Unbekannter Fehler" + }); + } + } + return (
@@ -1005,31 +1055,6 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { {openTodos}
- - - - - - - Projekt anlegen - Consulting-Projekt mit einem oder mehreren Teamspace-Einträgen anlegen. - - void lookupProjectTicketInZammad()} - onSubmit={submitProject} - /> - -
@@ -1043,7 +1068,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { Filter und Sortierung {activeFilterCount > 0 ? {activeFilterCount} : null} - {filteredProjects.length} von {projects.length} Projekt(en) sichtbar. + {filteredProjects.length} von {totalProjectCount} Projekt(en) sichtbar.
- +
- +
@@ -1167,8 +1185,57 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
- + {hiddenProjectCount > 0 && viewSettings.status !== "hidden" ? ( +
+
+ + + {hiddenProjectCount} Projekt(e) sind ausgeblendet. + +
+ +
+ ) : null} +
+
+

Projektliste

+

{filteredProjects.length} Projekt(e)

+
+ + + + + + + Projekt anlegen + Consulting-Projekt mit einem oder mehreren Teamspace-Einträgen anlegen. + + void lookupProjectTicketInZammad()} + onSubmit={submitProject} + /> + + +
{sortHeader("Nummer/Titel", "title")} {sortHeader("Organisation", "organization")} @@ -1207,6 +1274,7 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) {
{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}
@@ -1244,6 +1312,17 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { Als Favorit speichern + {project.hidden_for_user ? ( + void unhideProjectForUser(project)}> + + Einblenden + + ) : ( + void hideProjectForUser(project)}> + + Ausblenden + + )} onNavigate(`/tickets/${project.ticket_id}?month=${currentMonth()}`)}> Projekt öffnen @@ -1286,6 +1365,11 @@ export function ProjectsPage({ onNavigate, onStartTimer }: ProjectsPageProps) { {project.unassigned_teamspace_session_count} Session(s) haben keinen Teamspace-Eintrag.

) : null} + {project.close_review_required ? ( +

+ Zammad meldet das Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen. +

+ ) : null} {hasMultipleTeamspaceEntries ? (
{teamspaceEntries.map((entry) => { diff --git a/frontend/src/views/TicketDetailPage.tsx b/frontend/src/views/TicketDetailPage.tsx index 8046eee..2559859 100644 --- a/frontend/src/views/TicketDetailPage.tsx +++ b/frontend/src/views/TicketDetailPage.tsx @@ -1,4 +1,4 @@ -import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, FolderKanban, Pencil, Plus, RotateCcw, Save, Star, Trash2 } from "lucide-react"; +import { ArrowLeft, CheckCircle2, ChevronDown, ChevronUp, ClipboardList, EyeOff, FolderKanban, Pencil, Plus, RotateCcw, Save, Star, Trash2 } from "lucide-react"; import { FormEvent, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { Alert } from "@/components/ui/alert"; @@ -16,6 +16,7 @@ import { createSession, deleteProject, deleteSession, + hideProject, addFavoriteTicket, getDashboardPreference, getTicketDetails, @@ -1261,6 +1262,7 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO Projekt {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} @@ -1299,6 +1301,14 @@ export function TicketDetailPage({ periodType, period, ticketId, onNavigate, onO
) : null} + {project.close_review_required ? ( + +
Zeiten prüfen
+
+ Zammad meldet dieses Consulting-Ticket als geschlossen. Bitte Zeiten in Teamspace und TicketTracker prüfen und das Projekt danach schließen. +
+
+ ) : null}
Zeitraum {project.starts_on || "offen"} - {project.ends_on || "offen"} Kontingent {projectBudgetMinutes ? formatMinutes(projectBudgetMinutes) : "offen"} diff --git a/frontend/src/views/TodosPage.tsx b/frontend/src/views/TodosPage.tsx index 45ceed7..87e0749 100644 --- a/frontend/src/views/TodosPage.tsx +++ b/frontend/src/views/TodosPage.tsx @@ -784,7 +784,7 @@ export function TodosPage({ onNavigate }: TodosPageProps) {
-
+