This commit is contained in:
michael committed 2026-10-06 08:57:37 +02:00
1 parent e1456ed4ca
commit fc0dfd0556
3 files changed
+85 -23

No files matched your search

+74 -20
View File
@@ -4230,6 +4230,16 @@ function addSyncChange(changes: ZammadSyncChange[], field: string, from: unknown
} }
} }
const informationalZammadSyncChangeFields = new Set([
"Fremdes Ticket",
"Fremder Zammad-Owner",
"Hinweis"
]);
function zammadSyncResultChanges(changes: ZammadSyncChange[]) {
return changes.some((change) => !informationalZammadSyncChangeFields.has(change.field)) ? changes : [];
}
function zammadSyncSnapshot(zammadTicket: ZammadTicketImport) { function zammadSyncSnapshot(zammadTicket: ZammadTicketImport) {
return { return {
ticketId: zammadTicket.zammadTicketId, ticketId: zammadTicket.zammadTicketId,
@@ -5161,7 +5171,9 @@ async function planExistingZammadTicketSync(
} }
if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) { if (zammadTicket.workType === "support" && zammadTicket.zammadStateId === 4) {
return changes.length > 0 const resultChanges = zammadSyncResultChanges(changes);
return resultChanges.length > 0
? { ? {
ticketNumber: localTicket.ticket_number, ticketNumber: localTicket.ticket_number,
action: "update", action: "update",
@@ -5172,7 +5184,7 @@ async function planExistingZammadTicketSync(
: "Zammad-Metadaten werden aktualisiert.", : "Zammad-Metadaten werden aktualisiert.",
local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket), zammad: zammadSyncSnapshot(zammadTicket),
changes changes: resultChanges
} }
: null; : null;
} }
@@ -5208,17 +5220,19 @@ async function planExistingZammadTicketSync(
changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" }); changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" });
} }
const resultChanges = zammadSyncResultChanges(changes);
return { return {
ticketNumber: localTicket.ticket_number, ticketNumber: localTicket.ticket_number,
action: changes.length > 0 ? "update" : "skip", action: resultChanges.length > 0 ? "update" : "skip",
reason: zammadTicket.ticketNumber !== localTicket.ticket_number reason: zammadTicket.ticketNumber !== localTicket.ticket_number
? mergedTargetTicket ? mergedTargetTicket
? "Zammad-Merge wird lokal nachvollzogen; vorhandenes Ziel-Ticket wird verwendet." ? "Zammad-Merge wird lokal nachvollzogen; vorhandenes Ziel-Ticket wird verwendet."
: "Zammad-Merge wird lokal nachvollzogen; Ziel-Ticket wird synchronisiert und die Quelle umgehängt." : "Zammad-Merge wird lokal nachvollzogen; Ziel-Ticket wird synchronisiert und die Quelle umgehängt."
: changes.length > 0 ? "Ticket wird als Support synchronisiert." : "Keine Änderung nach aktueller Auswahl.", : resultChanges.length > 0 ? "Ticket wird als Support synchronisiert." : "Keine Änderung nach aktueller Auswahl.",
local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }), local: localSyncSnapshot(localTicket, null, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket), zammad: zammadSyncSnapshot(zammadTicket),
changes changes: resultChanges
}; };
} }
@@ -5263,13 +5277,15 @@ async function planExistingZammadTicketSync(
} }
const resultChanges = zammadSyncResultChanges(changes);
return { return {
ticketNumber: localTicket.ticket_number, ticketNumber: localTicket.ticket_number,
action: changes.length > 0 ? "update" : "skip", action: resultChanges.length > 0 ? "update" : "skip",
reason: changes.length > 0 ? "Ticket wird aktualisiert; Projektdaten bleiben wegen offener TS/TT-Prüfung lokal geschützt." : "Keine Änderung nach aktueller Auswahl.", reason: resultChanges.length > 0 ? "Ticket wird aktualisiert; Projektdaten bleiben wegen offener TS/TT-Prüfung lokal geschützt." : "Keine Änderung nach aktueller Auswahl.",
local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }), local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket), zammad: zammadSyncSnapshot(zammadTicket),
changes changes: resultChanges
}; };
} }
@@ -5277,7 +5293,7 @@ async function planExistingZammadTicketSync(
return null; return null;
} }
if (!project) { if (!project && fields.type && localTicket.work_type !== "consulting") {
changes.push({ field: "Projekt", from: null, to: "wird angelegt" }); changes.push({ field: "Projekt", from: null, to: "wird angelegt" });
} }
@@ -5327,17 +5343,19 @@ async function planExistingZammadTicketSync(
} }
} }
const resultChanges = zammadSyncResultChanges(changes);
return { return {
ticketNumber: localTicket.ticket_number, ticketNumber: localTicket.ticket_number,
action: changes.length > 0 ? "update" : "skip", action: resultChanges.length > 0 ? "update" : "skip",
reason: zammadTicket.ticketNumber !== localTicket.ticket_number reason: zammadTicket.ticketNumber !== localTicket.ticket_number
? mergedTargetTicket ? mergedTargetTicket
? "Zammad-Merge wird lokal nachvollzogen; vorhandenes Ziel-Ticket wird verwendet." ? "Zammad-Merge wird lokal nachvollzogen; vorhandenes Ziel-Ticket wird verwendet."
: "Zammad-Merge wird lokal nachvollzogen; Ziel-Ticket wird synchronisiert und die Quelle umgehängt." : "Zammad-Merge wird lokal nachvollzogen; Ziel-Ticket wird synchronisiert und die Quelle umgehängt."
: changes.length > 0 ? "Ticket wird aktualisiert." : "Keine Änderung nach aktueller Auswahl.", : resultChanges.length > 0 ? "Ticket wird aktualisiert." : "Keine Änderung nach aktueller Auswahl.",
local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }), local: localSyncSnapshot(localTicket, project, { zammadPendingTodoExists: pendingTodoExists }),
zammad: zammadSyncSnapshot(zammadTicket), zammad: zammadSyncSnapshot(zammadTicket),
changes changes: resultChanges
}; };
} }
@@ -6669,6 +6687,7 @@ async function applyZammadTicketSync(
const organization = shouldResolveOrganization ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null; const organization = shouldResolveOrganization ? await getOrganizationByZammadId(client, zammadTicket.organizationId) : null;
const ownerMapping = await zammadOwnerUserMapping(client, zammadTicket.zammadOwnerId); const ownerMapping = await zammadOwnerUserMapping(client, zammadTicket.zammadOwnerId);
const zammadOwnerUserId = ownerMapping.status === "ambiguous" ? localTicket.user_id : ownerMapping.userId; const zammadOwnerUserId = ownerMapping.status === "ambiguous" ? localTicket.user_id : ownerMapping.userId;
const zammadStateChanged = numberOrNull(localTicket.zammad_state_id) !== zammadTicket.zammadStateId;
if (shouldResolveOrganization && !organization) { if (shouldResolveOrganization && !organization) {
return "organization-missing" as const; return "organization-missing" as const;
@@ -6754,6 +6773,12 @@ async function applyZammadTicketSync(
); );
} }
if (zammadStateChanged) {
const completedAt = new Date();
await restartProjectFollowupPeriodForTicket(client, localTicket.id, completedAt);
await completeAutomaticProjectFollowupsForTicketStatusChange(client, localTicket.id, completedAt);
}
if (fields.pendingTodo) { if (fields.pendingTodo) {
await ensureZammadPendingTodo(client, localTicket.id, userId, zammadTicket); await ensureZammadPendingTodo(client, localTicket.id, userId, zammadTicket);
} }
@@ -7778,6 +7803,35 @@ async function completeAutomaticProjectFollowupsForSession(client: QueryClient,
); );
} }
async function completeAutomaticProjectFollowupsForTicketStatusChange(client: QueryClient, ticketId: string, completedAt: Date) {
await client.query(
`
UPDATE ticket_todos tt
SET status = 'done',
completed_at = COALESCE(tt.completed_at, $2::timestamptz),
updated_at = now()
FROM project_followup_reminders pfr
WHERE pfr.todo_id = tt.id
AND pfr.ticket_id = $1
AND tt.ticket_id = $1
AND tt.status = 'open';
`,
[ticketId, completedAt.toISOString()]
);
}
async function restartProjectFollowupPeriodForTicket(client: QueryClient, ticketId: string, restartedAt: Date) {
await client.query(
`
UPDATE projects
SET updated_at = GREATEST(updated_at, $2::timestamptz)
WHERE ticket_id = $1
AND closed_at IS NULL;
`,
[ticketId, restartedAt.toISOString()]
);
}
async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) { async function getPeriodTicket(config: PeriodConfig, period: ParsedPeriod, ticketId: number, userId: string) {
await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso); await ensureRecurringSessionsForUserPeriod(userId, period.startIso, period.endIso);
@@ -12442,12 +12496,12 @@ app.patch("/api/projects/:projectId/review", requireUser, async (req, res) => {
const updateResult = await client.query<{ id: string; ticket_id: string; close_review_required: boolean }>( const updateResult = await client.query<{ id: string; ticket_id: string; close_review_required: boolean }>(
` `
UPDATE projects UPDATE projects
SET close_review_required = NOT $3::boolean, SET close_review_required = NOT $2::boolean,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING id::text, ticket_id::text, close_review_required; RETURNING id::text, ticket_id::text, close_review_required;
`, `,
[projectId, userId, acknowledged] [projectId, acknowledged]
); );
return updateResult.rows[0] ?? null; return updateResult.rows[0] ?? null;
@@ -12493,13 +12547,13 @@ app.patch("/api/projects/:projectId/status", requireUser, async (req, res) => {
const updateResult = await client.query<{ id: string; ticket_id: string; closed_at: string | null }>( const updateResult = await client.query<{ id: string; ticket_id: string; closed_at: string | null }>(
` `
UPDATE projects UPDATE projects
SET closed_at = CASE WHEN $3::boolean THEN now() ELSE NULL END, SET closed_at = CASE WHEN $2::boolean THEN now() ELSE NULL END,
close_review_required = CASE WHEN $3::boolean THEN false ELSE close_review_required END, close_review_required = CASE WHEN $2::boolean THEN false ELSE close_review_required END,
updated_at = now() updated_at = now()
WHERE id = $1 WHERE id = $1
RETURNING id::text, ticket_id::text, closed_at; RETURNING id::text, ticket_id::text, closed_at;
`, `,
[projectId, userId, closed] [projectId, closed]
); );
return updateResult.rows[0] ?? null; return updateResult.rows[0] ?? null;
+1 -1
View File
@@ -1315,7 +1315,7 @@ export function App() {
setOpenTickets((items) => items.filter((item) => item.key !== ticket.key)); setOpenTickets((items) => items.filter((item) => item.key !== ticket.key));
if (pathWithoutSearch(path) === pathWithoutSearch(ticket.path)) { if (route.page === "ticket" && route.ticketId === ticket.ticketId) {
const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis"; const returnPath = current.returnPath && pathWithoutSearch(current.returnPath) !== pathWithoutSearch(ticket.path) ? current.returnPath : "/analysis";
routerNavigate(returnPath); routerNavigate(returnPath);
} }
+10 -2
View File
@@ -669,13 +669,21 @@ const preservedExistingPreviewChangeFields = new Set([
"TicketTracker-Zusammenführung" "TicketTracker-Zusammenführung"
]); ]);
const informationalExistingPreviewChangeFields = new Set([
"Fremdes Ticket",
"Fremder Zammad-Owner",
"Hinweis"
]);
function preserveExistingPreviewChanges(item: ZammadSyncItem, changes: ZammadSyncChange[], fields: ZammadSyncExistingFields) { function preserveExistingPreviewChanges(item: ZammadSyncItem, changes: ZammadSyncChange[], fields: ZammadSyncExistingFields) {
const existingFields = new Set(changes.map((change) => change.field)); const existingFields = new Set(changes.map((change) => change.field));
return [ const resultChanges = [
...item.changes.filter((change) => preservedExistingPreviewChangeFields.has(change.field) && !existingFields.has(change.field) && changeAllowedByFields(item, change, fields)), ...item.changes.filter((change) => preservedExistingPreviewChangeFields.has(change.field) && !existingFields.has(change.field) && changeAllowedByFields(item, change, fields)),
...changes ...changes
]; ];
return resultChanges.some((change) => !informationalExistingPreviewChangeFields.has(change.field)) ? resultChanges : [];
} }
function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyncExistingFields) { function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyncExistingFields) {
@@ -737,7 +745,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
const consultingProjectCanBeTouched = !mergeProtected && zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting"); const consultingProjectCanBeTouched = !mergeProtected && zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
if (consultingProjectCanBeTouched) { if (consultingProjectCanBeTouched) {
if (fields.type && !project) { if (fields.type && localWorkType !== "consulting" && !project) {
changes.push({ field: "Projekt", from: null, to: "wird angelegt" }); changes.push({ field: "Projekt", from: null, to: "wird angelegt" });
} }