This commit is contained in:
michael committed 2026-09-15 11:49:47 +00:00
1 parent 235c19e51b
commit 227644cfeb
8 files changed
+1041 -309

No files matched your search

+360 -42
View File
@@ -1,4 +1,4 @@
import { useMemo, useSyncExternalStore } from "react";
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, RefreshCw } from "lucide-react";
import { toast } from "sonner";
@@ -14,11 +14,14 @@ import {
applyOwnZammadConsultingTicketsFromPreview,
previewExistingZammadTickets,
previewOwnZammadConsultingTickets,
waitForZammadSyncJob,
type ZammadSyncExistingFields,
type ZammadSyncChange,
type ZammadSyncJob,
type ZammadSyncItem,
type ZammadSyncResult
} from "../api";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
import type { AuthUser } from "../types";
import { zammadPriorityLabel, zammadStateLabel } from "../zammad-meta";
@@ -27,6 +30,7 @@ type ZammadSyncPageProps = {
};
type SelectionMap = Record<string, boolean>;
type RunningJobKind = "preview" | "apply";
const defaultFields: ZammadSyncExistingFields = {
title: true,
@@ -51,25 +55,39 @@ type ZammadSyncPageState = {
fields: ZammadSyncExistingFields;
existingResult: ZammadSyncResult | null;
existingPreviewJobId: string | null;
existingRunningJobId: string | null;
existingRunningJobKind: RunningJobKind | null;
importResult: ZammadSyncResult | null;
importPreviewJobId: string | null;
importRunningJobId: string | null;
importRunningJobKind: RunningJobKind | null;
existingSelection: SelectionMap;
importSelection: SelectionMap;
syncingExisting: boolean;
importingConsulting: boolean;
};
let zammadSyncPageState: ZammadSyncPageState = {
fields: defaultFields,
existingResult: null,
existingPreviewJobId: null,
importResult: null,
importPreviewJobId: null,
existingSelection: {},
importSelection: {},
syncingExisting: false,
importingConsulting: false
};
function emptyZammadSyncPageState(): ZammadSyncPageState {
return {
fields: defaultFields,
existingResult: null,
existingPreviewJobId: null,
existingRunningJobId: null,
existingRunningJobKind: null,
importResult: null,
importPreviewJobId: null,
importRunningJobId: null,
importRunningJobKind: null,
existingSelection: {},
importSelection: {},
syncingExisting: false,
importingConsulting: false
};
}
let zammadSyncPageState: ZammadSyncPageState = emptyZammadSyncPageState();
let hydratedZammadSyncUserId: string | null = null;
let activeZammadSyncStorageUserId: string | null = null;
const zammadSyncPageListeners = new Set<() => void>();
@@ -89,9 +107,104 @@ function setZammadSyncPageState(update: Partial<ZammadSyncPageState> | ((current
...zammadSyncPageState,
...(typeof update === "function" ? update(zammadSyncPageState) : update)
};
if (activeZammadSyncStorageUserId) {
saveZammadSyncPageState(activeZammadSyncStorageUserId, zammadSyncPageState);
}
zammadSyncPageListeners.forEach((listener) => listener());
}
function zammadSyncStorageKey(userId: string) {
return `tickettracker.zammad-sync.${userId}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
function selectionMap(value: unknown): SelectionMap {
if (!isRecord(value)) {
return {};
}
return Object.fromEntries(Object.entries(value).filter(([, selected]) => typeof selected === "boolean")) as SelectionMap;
}
function syncResult(value: unknown): ZammadSyncResult | null {
if (!isRecord(value) || (value.mode !== "preview" && value.mode !== "applied") || !Array.isArray(value.items)) {
return null;
}
return value as ZammadSyncResult;
}
function syncFields(value: unknown): ZammadSyncExistingFields {
const source = isRecord(value) ? value : {};
return {
...defaultFields,
title: typeof source.title === "boolean" ? source.title : defaultFields.title,
organization: typeof source.organization === "boolean" ? source.organization : defaultFields.organization,
type: typeof source.type === "boolean" ? source.type : defaultFields.type,
projectName: typeof source.projectName === "boolean" ? source.projectName : defaultFields.projectName,
budget: typeof source.budget === "boolean" ? source.budget : defaultFields.budget,
overrideBudget: typeof source.overrideBudget === "boolean" ? source.overrideBudget : defaultFields.overrideBudget,
pendingTodo: typeof source.pendingTodo === "boolean" ? source.pendingTodo : defaultFields.pendingTodo
};
}
function nullableString(value: unknown) {
return typeof value === "string" && value.trim() ? value : null;
}
function runningJobKind(value: unknown): RunningJobKind | null {
return value === "preview" || value === "apply" ? value : null;
}
function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
const raw = getLocalStorageValue(zammadSyncStorageKey(userId));
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as unknown;
if (!isRecord(parsed)) {
return null;
}
const existingRunningJobId = nullableString(parsed.existingRunningJobId);
const existingRunningJobKind = runningJobKind(parsed.existingRunningJobKind);
const importRunningJobId = nullableString(parsed.importRunningJobId);
const importRunningJobKind = runningJobKind(parsed.importRunningJobKind);
return {
fields: syncFields(parsed.fields),
existingResult: syncResult(parsed.existingResult),
existingPreviewJobId: nullableString(parsed.existingPreviewJobId),
existingRunningJobId,
existingRunningJobKind,
importResult: syncResult(parsed.importResult),
importPreviewJobId: nullableString(parsed.importPreviewJobId),
importRunningJobId,
importRunningJobKind,
existingSelection: selectionMap(parsed.existingSelection),
importSelection: selectionMap(parsed.importSelection),
syncingExisting: Boolean(existingRunningJobId && existingRunningJobKind),
importingConsulting: Boolean(importRunningJobId && importRunningJobKind)
};
} catch {
return null;
}
}
function saveZammadSyncPageState(userId: string, state: ZammadSyncPageState) {
setLocalStorageValue(zammadSyncStorageKey(userId), JSON.stringify(state));
}
function useZammadSyncPageState() {
return useSyncExternalStore(subscribeZammadSyncPageState, getZammadSyncPageState, getZammadSyncPageState);
}
@@ -206,7 +319,7 @@ function formatChangeValue(field: string, value: unknown) {
return formatMinutes(value);
}
if (field === "Teamspace-Projekte") {
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung") {
return formatTeamspaceProjectList(value) || "-";
}
@@ -262,6 +375,10 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
return fields.projectName && consultingProjectCanBeTouched;
}
if (change.field === "Teamspace-Prüfung") {
return fields.projectName && consultingProjectCanBeTouched;
}
if (change.field === "Teamspace-Budget") {
return fields.budget && consultingProjectCanBeTouched;
}
@@ -293,7 +410,7 @@ function cachedStringList(source: Record<string, unknown> | null | undefined, ke
function formatTeamspaceProjectList(value: unknown) {
if (Array.isArray(value)) {
return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean).join(" + ");
return normalizedTeamspaceProjectList(value).join(" + ");
}
return typeof value === "string" ? value.trim() : "";
@@ -303,6 +420,50 @@ function splitTeamspaceProjects(value: unknown) {
return typeof value === "string" ? value.match(/[^\s+]+/g) ?? [] : [];
}
function normalizedTeamspaceProjectList(value: unknown) {
const entries = Array.isArray(value) ? value : splitTeamspaceProjects(value);
const seen = new Set<string>();
const normalized: string[] = [];
for (const entry of entries) {
const teamspaceProject = typeof entry === "string" ? entry.trim() : "";
if (!teamspaceProject || seen.has(teamspaceProject)) {
continue;
}
seen.add(teamspaceProject);
normalized.push(teamspaceProject);
}
return normalized;
}
function teamspaceProjectDiff(localProjects: unknown, zammadProjects: unknown) {
const local = normalizedTeamspaceProjectList(localProjects);
const zammad = normalizedTeamspaceProjectList(zammadProjects);
const localSet = new Set(local);
const zammadSet = new Set(zammad);
return {
local,
additions: zammad.filter((project) => !localSet.has(project)),
missingInZammad: local.filter((project) => !zammadSet.has(project))
};
}
function addCachedTeamspaceProjectChanges(changes: ZammadSyncChange[], localProjects: unknown, zammadProjects: unknown) {
const diff = teamspaceProjectDiff(localProjects, zammadProjects);
if (diff.additions.length > 0) {
changes.push({ field: "Teamspace-Projekte", from: diff.local, to: [...diff.local, ...diff.additions] });
}
if (diff.missingInZammad.length > 0) {
changes.push({ field: "Teamspace-Prüfung", from: diff.missingInZammad, to: "nicht mehr aus Zammad; bitte manuell prüfen" });
}
}
function zammadPendingTodoPreview(zammad: Record<string, unknown>) {
const stateId = numericValue(cachedValue(zammad, "stateId"));
const pendingTime = cachedValue(zammad, "pendingTime");
@@ -355,8 +516,8 @@ function comparablePreviewValueForField(field: string, value: unknown) {
return comparablePreviewIdValue(value);
}
if (field === "Teamspace-Projekte" && Array.isArray(value)) {
return value.map((entry) => comparablePreviewValue(entry));
if (field === "Teamspace-Projekte") {
return normalizedTeamspaceProjectList(value).sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" }));
}
return comparablePreviewValue(value);
@@ -368,7 +529,7 @@ function cachedValueChanged(field: string, left: unknown, right: unknown) {
function addCachedChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) {
if (cachedValueChanged(field, from, to)) {
changes.push({ field, from: comparablePreviewValue(from), to: comparablePreviewValue(to) });
changes.push({ field, from: comparablePreviewValueForField(field, from), to: comparablePreviewValueForField(field, to) });
}
}
@@ -387,6 +548,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
const localWorkType = cachedValue(local, "workType");
const zammadWorkType = cachedValue(zammad, "workType");
const zammadStateId = numericValue(cachedValue(zammad, "stateId"));
const pendingTodoExists = cachedValue(local, "zammadPendingTodoExists");
const localTeamspaceProjects = cachedStringList(project, "teamspaceProjects");
const zammadTeamspaceProjects = cachedStringList(zammad, "teamspaceProjects");
const localTeamspaceProjectList = localTeamspaceProjects.length ? localTeamspaceProjects : [cachedValue(project, "teamspaceProject")].filter((entry): entry is string => typeof entry === "string");
@@ -398,7 +560,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
addCachedChange(changes, "Zammad-Status", cachedValue(local, "zammadStateId"), cachedValue(zammad, "stateId"));
const pendingTodo = zammadPendingTodoPreview(zammad);
if (fields.pendingTodo && pendingTodo) {
if (fields.pendingTodo && pendingTodo && pendingTodoExists === false) {
changes.push({ field: "ToDo", from: null, to: pendingTodo });
}
@@ -430,7 +592,7 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
}
if (fields.projectName) {
addCachedChange(changes, "Teamspace-Projekte", localTeamspaceProjectList, zammadTeamspaceProjectList);
addCachedTeamspaceProjectChanges(changes, localTeamspaceProjectList, zammadTeamspaceProjectList);
}
if (canSyncBudget) {
@@ -587,6 +749,7 @@ function ResultSummary({
const selectable = selectableItems(result);
const selectedCount = selection ? selectable.filter((item) => itemIsSelected(item, selection)).length : 0;
const allSelected = selectable.length > 0 && selectedCount === selectable.length;
const listedItems = result.items.filter((item) => item.action !== "skip" || visibleTicketTrackerChanges(item).length > 0);
const metrics = [
["Geprüft", result.checked],
[result.mode === "preview" ? "Würde ändern" : "Aktualisiert", result.updated],
@@ -642,14 +805,14 @@ function ResultSummary({
</TableRow>
</TableHeader>
<TableBody>
{result.items.length === 0 ? (
{listedItems.length === 0 ? (
<TableRow>
<TableCell colSpan={selection ? 5 : 4} className="py-8 text-center text-sm text-muted-foreground">
Keine Datensätze im Lauf.
Keine Änderungen für die aktuelle Auswahl.
</TableCell>
</TableRow>
) : (
result.items.map((item) => {
listedItems.map((item) => {
const action = formatAction(item);
const isSelectable = item.action === "update" || item.action === "create";
const visibleChanges = visibleTicketTrackerChanges(item);
@@ -717,20 +880,155 @@ function ResultSummary({
}
export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
const pageState = useZammadSyncPageState();
const {
fields,
existingResult,
existingRunningJobId,
existingRunningJobKind,
importResult,
importRunningJobId,
importRunningJobKind,
existingSelection,
importSelection,
syncingExisting,
importingConsulting
} = useZammadSyncPageState();
} = pageState;
const [storageReady, setStorageReady] = useState(false);
const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]);
const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]);
const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]);
const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id);
useEffect(() => {
setStorageReady(false);
activeZammadSyncStorageUserId = currentUser.id;
const restored = loadZammadSyncPageState(currentUser.id);
if (restored) {
setZammadSyncPageState(restored);
} else if (hydratedZammadSyncUserId !== currentUser.id) {
setZammadSyncPageState(emptyZammadSyncPageState());
}
hydratedZammadSyncUserId = currentUser.id;
setStorageReady(true);
}, [currentUser.id]);
useEffect(() => {
if (!storageReady) {
return;
}
saveZammadSyncPageState(currentUser.id, pageState);
}, [currentUser.id, pageState, storageReady]);
useEffect(() => {
if (!storageReady || !existingRunningJobId || !existingRunningJobKind) {
return;
}
let cancelled = false;
void waitForZammadSyncJob(existingRunningJobId)
.then((result) => {
if (cancelled) {
return;
}
if (existingRunningJobKind === "preview") {
setZammadSyncPageState((current) => ({
existingResult: result,
existingPreviewJobId: existingRunningJobId,
existingSelection: Object.keys(current.existingSelection).length ? current.existingSelection : selectionFromResult(result),
existingRunningJobId: null,
existingRunningJobKind: null,
syncingExisting: false
}));
} else {
setZammadSyncPageState({
existingResult: result,
existingPreviewJobId: null,
existingSelection: {},
existingRunningJobId: null,
existingRunningJobKind: null,
syncingExisting: false
});
}
})
.catch((error) => {
if (cancelled) {
return;
}
setZammadSyncPageState({
existingRunningJobId: null,
existingRunningJobKind: null,
syncingExisting: false
});
toast.error("Zammad-Lauf konnte nicht fortgesetzt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
});
return () => {
cancelled = true;
};
}, [existingRunningJobId, existingRunningJobKind, storageReady]);
useEffect(() => {
if (!storageReady || !importRunningJobId || !importRunningJobKind) {
return;
}
let cancelled = false;
void waitForZammadSyncJob(importRunningJobId)
.then((result) => {
if (cancelled) {
return;
}
if (importRunningJobKind === "preview") {
setZammadSyncPageState((current) => ({
importResult: result,
importPreviewJobId: importRunningJobId,
importSelection: Object.keys(current.importSelection).length ? current.importSelection : selectionFromResult(result),
importRunningJobId: null,
importRunningJobKind: null,
importingConsulting: false
}));
} else {
setZammadSyncPageState({
importResult: result,
importPreviewJobId: null,
importSelection: {},
importRunningJobId: null,
importRunningJobKind: null,
importingConsulting: false
});
}
})
.catch((error) => {
if (cancelled) {
return;
}
setZammadSyncPageState({
importRunningJobId: null,
importRunningJobKind: null,
importingConsulting: false
});
toast.error("Zammad-Importlauf konnte nicht fortgesetzt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
});
return () => {
cancelled = true;
};
}, [importRunningJobId, importRunningJobKind, storageReady]);
function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
setZammadSyncPageState((current) => ({
fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) }
@@ -740,7 +1038,11 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
function resetRunningState() {
setZammadSyncPageState({
syncingExisting: false,
importingConsulting: false
importingConsulting: false,
existingRunningJobId: null,
existingRunningJobKind: null,
importRunningJobId: null,
importRunningJobKind: null
});
toast.info("Laufstatus zurückgesetzt");
}
@@ -751,13 +1053,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
}
const syncFields = zammadSyncPageState.fields;
setZammadSyncPageState({ syncingExisting: true });
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null });
try {
const { jobId, result } = await previewExistingZammadTickets(syncFields);
const { jobId, result } = await previewExistingZammadTickets(syncFields, (job: ZammadSyncJob) => {
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "preview" });
});
setZammadSyncPageState({
existingResult: result,
existingPreviewJobId: jobId,
existingSelection: selectionFromResult(result)
existingSelection: selectionFromResult(result),
existingRunningJobId: null,
existingRunningJobKind: null
});
toast.success("Vorschau erstellt", {
description: `${result.updated + result.created} Datensatz/Datensätze können übernommen werden.`
@@ -767,7 +1073,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ syncingExisting: false });
setZammadSyncPageState({ syncingExisting: false, existingRunningJobId: null, existingRunningJobKind: null });
}
}
@@ -790,13 +1096,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
}
const syncFields = zammadSyncPageState.fields;
setZammadSyncPageState({ syncingExisting: true });
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null });
try {
const { result } = await applyExistingZammadTicketsFromPreview(zammadSyncPageState.existingPreviewJobId, syncFields, ticketNumbers);
const { result } = await applyExistingZammadTicketsFromPreview(zammadSyncPageState.existingPreviewJobId, syncFields, ticketNumbers, (job: ZammadSyncJob) => {
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "apply" });
});
setZammadSyncPageState({
existingResult: result,
existingPreviewJobId: null,
existingSelection: {}
existingSelection: {},
existingRunningJobId: null,
existingRunningJobKind: null
});
toast.success("Auswahl synchronisiert", {
description: `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.`
@@ -806,7 +1116,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ syncingExisting: false });
setZammadSyncPageState({ syncingExisting: false, existingRunningJobId: null, existingRunningJobKind: null });
}
}
@@ -815,13 +1125,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
return;
}
setZammadSyncPageState({ importingConsulting: true });
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null });
try {
const { jobId, result } = await previewOwnZammadConsultingTickets();
const { jobId, result } = await previewOwnZammadConsultingTickets((job: ZammadSyncJob) => {
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "preview" });
});
setZammadSyncPageState({
importResult: result,
importPreviewJobId: jobId,
importSelection: selectionFromResult(result)
importSelection: selectionFromResult(result),
importRunningJobId: null,
importRunningJobKind: null
});
toast.success("Import-Vorschau erstellt", {
description: `${result.created} Consulting-Ticket(s) können angelegt werden.`
@@ -831,7 +1145,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ importingConsulting: false });
setZammadSyncPageState({ importingConsulting: false, importRunningJobId: null, importRunningJobKind: null });
}
}
@@ -852,13 +1166,17 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
return;
}
setZammadSyncPageState({ importingConsulting: true });
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null });
try {
const { result } = await applyOwnZammadConsultingTicketsFromPreview(zammadSyncPageState.importPreviewJobId, ticketNumbers);
const { result } = await applyOwnZammadConsultingTicketsFromPreview(zammadSyncPageState.importPreviewJobId, ticketNumbers, (job: ZammadSyncJob) => {
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "apply" });
});
setZammadSyncPageState({
importResult: result,
importPreviewJobId: null,
importSelection: {}
importSelection: {},
importRunningJobId: null,
importRunningJobKind: null
});
toast.success("Auswahl importiert", {
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.`
@@ -868,7 +1186,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ importingConsulting: false });
setZammadSyncPageState({ importingConsulting: false, importRunningJobId: null, importRunningJobKind: null });
}
}
@@ -950,8 +1268,8 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
<label className={cn("flex min-w-0 gap-3 rounded-md border p-3", fields.overrideBudget ? "bg-muted/30" : "bg-background")}>
<Checkbox checked={fields.overrideBudget} onCheckedChange={(checked) => setField("overrideBudget", checked === true)} />
<span className="min-w-0">
<span className="block text-sm font-medium">Manuelle Budget-Änderungen überschreiben</span>
<span className="block text-xs text-muted-foreground">Ausgeschaltet: geschützte Budgets bleiben in TicketTracker unverändert.</span>
<span className="block text-sm font-medium">Budget-Schutz im Sync ignorieren</span>
<span className="block text-xs text-muted-foreground">Aus: nur ungeschützte Budgets aktualisieren. An: Zammad überschreibt auch geschützte Budgets.</span>
</span>
</label>
) : null}