Files
tickettracker/frontend/src/views/ZammadSyncPage.tsx
T
2026-10-15 08:09:47 +02:00

1381 lines
52 KiB
TypeScript

import { useEffect, useState, useSyncExternalStore } from "react";
import { AlertCircle, DatabaseZap, DownloadCloud, MailPlus, RefreshCw } from "lucide-react";
import { toast } from "sonner";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { Input } from "@/components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { cn } from "@/lib/utils";
import {
importOwnZammadConsultingTicketsDirect,
syncExistingZammadTicketsDirect,
syncTeamspaceProjectIds,
syncZammadMailSessionsDirect,
waitForZammadSyncJob,
type TeamspaceProjectIdSyncResult,
type ZammadSyncExistingFields,
type ZammadSyncJob,
type ZammadSyncItem,
type ZammadSyncResult
} from "../api";
import { getLocalStorageValue, setLocalStorageValue } from "@/lib/local-storage.client";
import type { AuthUser } from "../types";
import { currentMonth } from "../format";
import { zammadPriorityLabel, zammadStateId, zammadStateLabel } from "../zammad-meta";
type ZammadSyncPageProps = {
currentUser: AuthUser;
};
type RunningJobKind = "apply";
type OverallSyncStep = "consulting" | "existing" | "mail" | "teamspace";
const defaultFields: ZammadSyncExistingFields = {
title: true,
organization: true,
type: true,
projectName: true,
budget: false,
overrideBudget: false,
pendingTodo: false
};
const fieldOptions: Array<{ key: Exclude<keyof ZammadSyncExistingFields, "overrideBudget">; label: string; description: string }> = [
{ key: "title", label: "Titel", description: "Ticket-Titel aus Zammad übernehmen." },
{ key: "organization", label: "Organisation", description: "Lokale Organisation anhand der Zammad-ID setzen." },
{ key: "type", label: "Typ", description: "Support/Consulting übernehmen, Projekte anlegen oder entfernen." },
{ key: "projectName", label: "Projektbezeichnung", description: "Teamspace-Projekt bei Consulting überschreiben." },
{ key: "pendingTodo", label: "Termin-ToDo", description: "Aus Wartend/Abschluss mit Datum ein ToDo erzeugen." }
];
type ZammadSyncPageState = {
fields: ZammadSyncExistingFields;
existingResult: ZammadSyncResult | null;
existingRunningJobId: string | null;
existingRunningJobKind: RunningJobKind | null;
importResult: ZammadSyncResult | null;
importRunningJobId: string | null;
importRunningJobKind: RunningJobKind | null;
mailResult: ZammadSyncResult | null;
mailRunningJobId: string | null;
mailRunningJobKind: RunningJobKind | null;
mailDaysBack: number;
mailPerPage: number;
syncingExisting: boolean;
importingConsulting: boolean;
syncingMail: boolean;
syncingTeamspaceProjectIds: boolean;
teamspaceProjectIdResult: TeamspaceProjectIdSyncResult | null;
teamspaceSyncMonth: string;
overallSyncing: boolean;
overallSyncStep: OverallSyncStep | null;
};
function emptyZammadSyncPageState(): ZammadSyncPageState {
return {
fields: defaultFields,
existingResult: null,
existingRunningJobId: null,
existingRunningJobKind: null,
importResult: null,
importRunningJobId: null,
importRunningJobKind: null,
mailResult: null,
mailRunningJobId: null,
mailRunningJobKind: null,
mailDaysBack: 30,
mailPerPage: 300,
syncingExisting: false,
importingConsulting: false,
syncingMail: false,
syncingTeamspaceProjectIds: false,
teamspaceProjectIdResult: null,
teamspaceSyncMonth: currentMonth(),
overallSyncing: false,
overallSyncStep: null
};
}
let zammadSyncPageState: ZammadSyncPageState = emptyZammadSyncPageState();
let hydratedZammadSyncUserId: string | null = null;
let activeZammadSyncStorageUserId: string | null = null;
let activeOverallSyncInMemory = false;
const zammadSyncPageListeners = new Set<() => void>();
function getZammadSyncPageState() {
return zammadSyncPageState;
}
function subscribeZammadSyncPageState(listener: () => void) {
zammadSyncPageListeners.add(listener);
return () => {
zammadSyncPageListeners.delete(listener);
};
}
function setZammadSyncPageState(update: Partial<ZammadSyncPageState> | ((current: ZammadSyncPageState) => Partial<ZammadSyncPageState>)) {
zammadSyncPageState = {
...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 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 appliedSyncResult(value: unknown): ZammadSyncResult | null {
const result = syncResult(value);
return result?.mode === "applied" ? result : null;
}
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 === "apply" ? value : null;
}
function overallSyncStep(value: unknown): OverallSyncStep | null {
return value === "consulting" || value === "existing" || value === "mail" || value === "teamspace" ? value : null;
}
function parseMailDaysBack(value: unknown) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 3650 ? parsed : 30;
}
function berlinDateParts(date: Date) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "Europe/Berlin",
year: "numeric",
month: "2-digit",
day: "2-digit"
}).formatToParts(date);
const value = (type: string) => Number(parts.find((part) => part.type === type)?.value);
return {
year: value("year"),
month: value("month"),
day: value("day")
};
}
function mailSyncCutoffDate(daysBack: number) {
const current = berlinDateParts(new Date());
const date = new Date(Date.UTC(current.year, current.month - 1, current.day));
date.setUTCDate(date.getUTCDate() - Math.max(0, daysBack - 1));
return date.toISOString().slice(0, 10);
}
function parseMailPerPage(value: unknown) {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 1000 ? parsed : 300;
}
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);
const mailRunningJobId = nullableString(parsed.mailRunningJobId);
const mailRunningJobKind = runningJobKind(parsed.mailRunningJobKind);
return {
fields: syncFields(parsed.fields),
existingResult: appliedSyncResult(parsed.existingResult),
existingRunningJobId: existingRunningJobKind ? existingRunningJobId : null,
existingRunningJobKind,
importResult: appliedSyncResult(parsed.importResult),
importRunningJobId: importRunningJobKind ? importRunningJobId : null,
importRunningJobKind,
mailResult: appliedSyncResult(parsed.mailResult),
mailRunningJobId: mailRunningJobKind ? mailRunningJobId : null,
mailRunningJobKind,
mailDaysBack: parseMailDaysBack(parsed.mailDaysBack),
mailPerPage: parseMailPerPage(parsed.mailPerPage),
syncingExisting: Boolean(existingRunningJobId && existingRunningJobKind),
importingConsulting: Boolean(importRunningJobId && importRunningJobKind),
syncingMail: Boolean(mailRunningJobId && mailRunningJobKind),
syncingTeamspaceProjectIds: Boolean(parsed.syncingTeamspaceProjectIds && parsed.overallSyncStep === "teamspace"),
teamspaceProjectIdResult: isRecord(parsed.teamspaceProjectIdResult) ? parsed.teamspaceProjectIdResult as TeamspaceProjectIdSyncResult : null,
teamspaceSyncMonth: typeof parsed.teamspaceSyncMonth === "string" && parsed.teamspaceSyncMonth.trim() ? parsed.teamspaceSyncMonth : currentMonth(),
overallSyncing: Boolean(parsed.overallSyncing),
overallSyncStep: overallSyncStep(parsed.overallSyncStep)
};
} catch {
return null;
}
}
function saveZammadSyncPageState(userId: string, state: ZammadSyncPageState) {
setLocalStorageValue(zammadSyncStorageKey(userId), JSON.stringify(state));
}
function useZammadSyncPageState() {
return useSyncExternalStore(subscribeZammadSyncPageState, getZammadSyncPageState, getZammadSyncPageState);
}
function selectionKey(item: ZammadSyncItem) {
const articleId = item.zammad?.articleId;
return typeof articleId === "number" || typeof articleId === "string" ? `article:${articleId}` : item.ticketNumber;
}
function existingApplyDescription(result: ZammadSyncResult) {
return `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.`;
}
function importApplyDescription(result: ZammadSyncResult) {
return `${result.created} angelegt, ${result.skipped} ohne Transfer.`;
}
function formatMinutes(value: unknown) {
if (typeof value !== "number") {
return formatValue(value);
}
if (value === 0) {
return "0 h";
}
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: value < 600 ? 1 : 0 }).format(value / 60)} h`;
}
function formatDurationSeconds(value: unknown) {
const seconds = Number(value);
if (!Number.isFinite(seconds)) {
return formatValue(value);
}
const minutes = Math.round(seconds / 60);
return `${new Intl.NumberFormat("de-DE").format(minutes)} min`;
}
function formatTimeUnit(value: unknown) {
const hours = Number(value);
if (!Number.isFinite(hours)) {
return formatValue(value);
}
const minutes = Math.round(hours * 60);
return `${new Intl.NumberFormat("de-DE", { maximumFractionDigits: 2 }).format(hours)} h / ${new Intl.NumberFormat("de-DE").format(minutes)} min`;
}
function numericValue(value: unknown) {
const number = Number(value);
return Number.isInteger(number) ? number : null;
}
function formatWorkType(value: unknown) {
if (value === "consulting") {
return "Consulting";
}
if (value === "support") {
return "Support";
}
return formatValue(value);
}
function formatZammadGroup(value: unknown) {
const groupId = numericValue(value);
if (groupId === 2) {
return "2 (Consulting)";
}
if (groupId === 3) {
return "3 (Support)";
}
if (groupId === 4) {
return "4 (Support)";
}
return formatValue(value);
}
function formatZammadPriority(value: unknown) {
const priorityId = numericValue(value);
if (priorityId !== null) {
return `${priorityId} (${zammadPriorityLabel(priorityId)})`;
}
return formatValue(value);
}
function formatZammadState(value: unknown) {
const normalizedStateId = zammadStateId(value);
const stateId = normalizedStateId ? numericValue(normalizedStateId) : null;
if (stateId !== null) {
return `${stateId} (${zammadStateLabel(stateId)})`;
}
return formatValue(value);
}
function formatValue(value: unknown): string {
if (value === null || value === undefined || value === "") {
return "-";
}
if (typeof value === "boolean") {
return value ? "ja" : "nein";
}
if (typeof value === "object") {
return JSON.stringify(value);
}
return String(value);
}
function formatChangeValue(field: string, value: unknown) {
if (field === "Dauer") {
return formatDurationSeconds(value);
}
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung" || field === "Merge-Projektdaten") {
return formatTeamspaceProjectList(value) || "-";
}
if (field === "Typ") {
return formatWorkType(value);
}
if (field === "Zammad-Gruppe") {
return formatZammadGroup(value);
}
if (field === "Zammad-Priorität") {
return formatZammadPriority(value);
}
if (field === "Zammad-Status") {
return formatZammadState(value);
}
return formatValue(value);
}
function visibleTicketTrackerChanges(item: ZammadSyncItem) {
return item.changes.filter((change) => !change.field.startsWith("Zammad-") || change.field === "Zammad-Status" || change.field === "Zammad-Priorität");
}
function formatTeamspaceProjectList(value: unknown) {
if (Array.isArray(value)) {
return normalizedTeamspaceProjectList(value).join(" + ");
}
return typeof value === "string" ? value.trim() : "";
}
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 formatAction(item: ZammadSyncItem) {
if (item.action === "create") {
return { label: "Anlegen", variant: "success" as const };
}
if (item.action === "update") {
return { label: "Ändern", variant: "warning" as const };
}
if (item.action === "error") {
return { label: "Fehler", variant: "destructive" as const };
}
return { label: "Kein Sync", variant: "info" as const };
}
function zammadDetails(item: ZammadSyncItem) {
const zammad = item.zammad;
if (!zammad) {
return ["kein Treffer"];
}
if (zammad.articleId) {
return [
`Article ${formatValue(zammad.articleId)}`,
`Ticket-ID ${formatValue(zammad.ticketId)}`,
`Typ eMail`,
`Erstellt ${formatValue(zammad.createdAt)}`,
`time_unit ${formatTimeUnit(zammad.timeUnit)}`,
`Betreff ${formatValue(zammad.subject)}`
];
}
return [
`ID ${formatValue(zammad.ticketId)}`,
`Gruppe ${formatZammadGroup(zammad.groupId)}`,
`Typ ${formatWorkType(zammad.workType)}`,
`Status ${formatZammadState(zammad.stateId)}`,
`Priorität ${formatZammadPriority(zammad.priorityId)}`,
`Warten ${formatValue(zammad.pendingTime)}`,
`Owner ${formatValue(zammad.ownerId)}`,
`Org ${formatValue(zammad.organizationId)}`,
`Titel ${formatValue(zammad.title)}`,
`Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`
];
}
function ResultSummary({ result }: { result: ZammadSyncResult }) {
const listedItems = result.items.filter((item) => item.action !== "skip" || visibleTicketTrackerChanges(item).length > 0);
const metrics = [
["Geprüft", result.checked],
["Aktualisiert", result.updated],
["Angelegt", result.created],
["Ohne Transfer", result.skipped],
["Nicht in Zammad", result.missingInZammad],
["Org fehlt", result.organizationMissing],
["TS/TT prüfen", result.markedForCloseReview],
["Zu Consulting", result.convertedToConsulting],
["Zu Support", result.convertedToSupport]
];
return (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3 xl:grid-cols-5">
{metrics.map(([label, value]) => (
<div key={label} className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-xs text-muted-foreground">{label}</span>
<span className="text-lg font-semibold">{value}</span>
</div>
))}
</div>
<div className="max-h-[520px] overflow-auto rounded-md border">
<Table className="w-full table-fixed">
<colgroup>
<col className="w-40" />
<col className="w-64" />
<col className="w-[34rem]" />
<col className="w-[36rem]" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>Ticket</TableHead>
<TableHead>Aktion</TableHead>
<TableHead>Daten aus Zammad</TableHead>
<TableHead>Änderungen in TicketTracker</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{listedItems.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="py-8 text-center text-sm text-muted-foreground">
Keine Änderungen in diesem Lauf.
</TableCell>
</TableRow>
) : (
listedItems.map((item) => {
const action = formatAction(item);
const visibleChanges = visibleTicketTrackerChanges(item);
return (
<TableRow key={`${result.mode}-${selectionKey(item)}-${item.action}`}>
<TableCell className="truncate font-medium" title={item.ticketNumber}>{item.ticketNumber}</TableCell>
<TableCell>
<div className="min-w-0 space-y-1">
<Badge variant={action.variant}>{action.label}</Badge>
{item.reason ? <p className="truncate text-xs text-muted-foreground" title={item.reason}>{item.reason}</p> : null}
</div>
</TableCell>
<TableCell>
<div className="min-w-0 space-y-1 text-xs text-muted-foreground">
{zammadDetails(item).map((detail) => (
<div key={detail} className="truncate" title={detail}>
{detail}
</div>
))}
</div>
</TableCell>
<TableCell>
{visibleChanges.length > 0 ? (
<div className="min-w-0 space-y-1 text-xs">
{visibleChanges.map((change) => {
const fromValue = formatChangeValue(change.field, change.from);
const toValue = formatChangeValue(change.field, change.to);
const title = `${change.field}: ${fromValue} -> ${toValue}`;
return (
<div key={`${item.ticketNumber}-${change.field}`} className="truncate" title={title}>
<span className="font-medium">{change.field}:</span>{" "}
<span className="text-muted-foreground">{fromValue}</span>
<span className="text-muted-foreground"> -&gt; </span>
<span>{toValue}</span>
</div>
);
})}
</div>
) : (
<span className="text-xs text-muted-foreground">Keine Änderung.</span>
)}
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
{result.errors.length > 0 ? (
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Fehler im Lauf</AlertTitle>
<AlertDescription>{result.errors.length} Datensatz/Datensätze konnten nicht vorbereitet oder verarbeitet werden.</AlertDescription>
</Alert>
) : null}
</div>
);
}
export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
const pageState = useZammadSyncPageState();
const {
fields,
existingResult,
existingRunningJobId,
existingRunningJobKind,
importResult,
importRunningJobId,
importRunningJobKind,
mailResult,
mailRunningJobId,
mailRunningJobKind,
mailDaysBack,
mailPerPage,
syncingExisting,
importingConsulting,
syncingMail,
syncingTeamspaceProjectIds,
teamspaceProjectIdResult,
teamspaceSyncMonth,
overallSyncing,
overallSyncStep
} = pageState;
const [storageReady, setStorageReady] = useState(false);
const displayedExistingResult = existingResult;
const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id);
const teamspaceReady = currentUser.has_teamspace_credentials && Boolean(currentUser.teamspace_user_id);
const anySyncRunning = syncingExisting || importingConsulting || syncingMail || syncingTeamspaceProjectIds || overallSyncing;
const individualSyncDisabled = overallSyncing || syncingExisting || importingConsulting || syncingMail || syncingTeamspaceProjectIds;
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;
}
setZammadSyncPageState({
existingResult: result,
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;
}
setZammadSyncPageState({
importResult: result,
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]);
useEffect(() => {
if (!storageReady || !mailRunningJobId || !mailRunningJobKind) {
return;
}
let cancelled = false;
void waitForZammadSyncJob(mailRunningJobId)
.then((result) => {
if (cancelled) {
return;
}
setZammadSyncPageState({
mailResult: result,
mailRunningJobId: null,
mailRunningJobKind: null,
syncingMail: false
});
})
.catch((error) => {
if (cancelled) {
return;
}
setZammadSyncPageState({
mailRunningJobId: null,
mailRunningJobKind: null,
syncingMail: false
});
toast.error("Zammad-Mail-Sync konnte nicht fortgesetzt werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
});
return () => {
cancelled = true;
};
}, [mailRunningJobId, mailRunningJobKind, storageReady]);
function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
setZammadSyncPageState((current) => ({
fields: { ...current.fields, [key]: checked, ...(key === "budget" && !checked ? { overrideBudget: false } : {}) }
}));
}
function resetRunningState() {
setZammadSyncPageState({
syncingExisting: false,
importingConsulting: false,
syncingMail: false,
syncingTeamspaceProjectIds: false,
overallSyncing: false,
overallSyncStep: null,
existingRunningJobId: null,
existingRunningJobKind: null,
importRunningJobId: null,
importRunningJobKind: null,
mailRunningJobId: null,
mailRunningJobKind: null
});
toast.info("Laufstatus zurückgesetzt");
}
async function runExistingSync() {
if (zammadSyncPageState.syncingExisting || zammadSyncPageState.overallSyncing) {
return;
}
const syncFields = zammadSyncPageState.fields;
setZammadSyncPageState({
existingResult: null,
syncingExisting: true,
existingRunningJobId: null,
existingRunningJobKind: null
});
try {
const { result } = await syncExistingZammadTicketsDirect(syncFields, (job: ZammadSyncJob) => {
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "apply" });
});
setZammadSyncPageState({
existingResult: result,
existingRunningJobId: null,
existingRunningJobKind: null
});
toast.success("Bestehende Tickets synchronisiert", {
description: existingApplyDescription(result)
});
} catch (error) {
toast.error("Zammad-Sync fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ syncingExisting: false, existingRunningJobId: null, existingRunningJobKind: null });
}
}
async function runConsultingImport() {
if (zammadSyncPageState.importingConsulting || zammadSyncPageState.overallSyncing) {
return;
}
setZammadSyncPageState({
importResult: null,
importingConsulting: true,
importRunningJobId: null,
importRunningJobKind: null
});
try {
const { result } = await importOwnZammadConsultingTicketsDirect((job: ZammadSyncJob) => {
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "apply" });
});
setZammadSyncPageState({
importResult: result,
importRunningJobId: null,
importRunningJobKind: null
});
toast.success("Consulting-Tickets importiert", {
description: importApplyDescription(result)
});
} catch (error) {
toast.error("Zammad-Import fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ importingConsulting: false, importRunningJobId: null, importRunningJobKind: null });
}
}
async function runMailSync() {
if (zammadSyncPageState.syncingMail || zammadSyncPageState.overallSyncing) {
return;
}
const daysBack = parseMailDaysBack(zammadSyncPageState.mailDaysBack);
const perPage = parseMailPerPage(zammadSyncPageState.mailPerPage);
setZammadSyncPageState({
mailResult: null,
mailDaysBack: daysBack,
mailPerPage: perPage,
syncingMail: true,
mailRunningJobId: null,
mailRunningJobKind: null
});
try {
const { result } = await syncZammadMailSessionsDirect(daysBack, perPage, (job: ZammadSyncJob) => {
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: job.id, mailRunningJobKind: "apply" });
});
setZammadSyncPageState({
mailResult: result,
mailRunningJobId: null,
mailRunningJobKind: null
});
toast.success("Mail-Sessions importiert", {
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.`
});
} catch (error) {
toast.error("Mail-Sync fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ syncingMail: false, mailRunningJobId: null, mailRunningJobKind: null });
}
}
async function runTeamspaceProjectIdSync() {
if (zammadSyncPageState.syncingTeamspaceProjectIds || zammadSyncPageState.overallSyncing) {
return;
}
setZammadSyncPageState({
teamspaceProjectIdResult: null,
syncingTeamspaceProjectIds: true
});
try {
const { result } = await syncTeamspaceProjectIds(zammadSyncPageState.teamspaceSyncMonth);
setZammadSyncPageState({ teamspaceProjectIdResult: result });
toast.success("Teamspace-Projekt-IDs synchronisiert", {
description: `${result.updated} aktualisiert, ${result.alreadyCurrent} bereits aktuell, TS ${formatMinutes(result.teamspaceTimeBillableMinutes)} im Monat ${result.month}.`
});
} catch (error) {
toast.error("Teamspace-Projekt-IDs konnten nicht synchronisiert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setZammadSyncPageState({ syncingTeamspaceProjectIds: false });
}
}
async function runFullSyncSequence({ resetResults }: { resetResults: boolean }) {
activeOverallSyncInMemory = true;
try {
const syncFields = zammadSyncPageState.fields;
const daysBack = parseMailDaysBack(zammadSyncPageState.mailDaysBack);
const perPage = parseMailPerPage(zammadSyncPageState.mailPerPage);
const month = zammadSyncPageState.teamspaceSyncMonth;
setZammadSyncPageState({
overallSyncing: true,
overallSyncStep: zammadSyncPageState.overallSyncStep ?? "consulting",
mailDaysBack: daysBack,
mailPerPage: perPage,
...(resetResults ? {
importResult: null,
existingResult: null,
mailResult: null,
teamspaceProjectIdResult: null,
importingConsulting: false,
syncingExisting: false,
syncingMail: false,
syncingTeamspaceProjectIds: false,
importRunningJobId: null,
importRunningJobKind: null,
existingRunningJobId: null,
existingRunningJobKind: null,
mailRunningJobId: null,
mailRunningJobKind: null
} : {})
});
let consultingResult = resetResults ? null : zammadSyncPageState.importResult;
if (!consultingResult) {
setZammadSyncPageState({ overallSyncStep: "consulting", importingConsulting: true });
if (zammadSyncPageState.importRunningJobId) {
consultingResult = await waitForZammadSyncJob(zammadSyncPageState.importRunningJobId);
} else {
const consulting = await importOwnZammadConsultingTicketsDirect((job) => {
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "apply" });
});
consultingResult = consulting.result;
}
setZammadSyncPageState({ importResult: consultingResult, importingConsulting: false, importRunningJobId: null, importRunningJobKind: null });
}
let existingResult = resetResults ? null : zammadSyncPageState.existingResult;
if (!existingResult) {
setZammadSyncPageState({ overallSyncStep: "existing", syncingExisting: true });
if (zammadSyncPageState.existingRunningJobId) {
existingResult = await waitForZammadSyncJob(zammadSyncPageState.existingRunningJobId);
} else {
const existing = await syncExistingZammadTicketsDirect(syncFields, (job) => {
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "apply" });
});
existingResult = existing.result;
}
setZammadSyncPageState({ existingResult, syncingExisting: false, existingRunningJobId: null, existingRunningJobKind: null });
}
let mailResultValue = resetResults ? null : zammadSyncPageState.mailResult;
if (!mailResultValue) {
setZammadSyncPageState({ overallSyncStep: "mail", syncingMail: true });
if (zammadSyncPageState.mailRunningJobId) {
mailResultValue = await waitForZammadSyncJob(zammadSyncPageState.mailRunningJobId);
} else {
const mail = await syncZammadMailSessionsDirect(daysBack, perPage, (job) => {
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: job.id, mailRunningJobKind: "apply" });
});
mailResultValue = mail.result;
}
setZammadSyncPageState({ mailResult: mailResultValue, syncingMail: false, mailRunningJobId: null, mailRunningJobKind: null });
}
let teamspaceResult = resetResults ? null : zammadSyncPageState.teamspaceProjectIdResult;
if (!teamspaceResult) {
setZammadSyncPageState({ overallSyncStep: "teamspace", syncingTeamspaceProjectIds: true });
const { result } = await syncTeamspaceProjectIds(month);
teamspaceResult = result;
setZammadSyncPageState({ teamspaceProjectIdResult: teamspaceResult, syncingTeamspaceProjectIds: false });
}
toast.success("Gesamtsync abgeschlossen", {
description: `${consultingResult.created} importiert, ${existingResult.updated} aktualisiert, ${mailResultValue.created} Mail-Session(s), ${teamspaceResult.updated + teamspaceResult.alreadyCurrent} Teamspace-Eintrag/Einträge.`
});
} catch (error) {
toast.error("Gesamtsync fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
activeOverallSyncInMemory = false;
setZammadSyncPageState({
overallSyncing: false,
overallSyncStep: null,
syncingExisting: false,
importingConsulting: false,
syncingMail: false,
syncingTeamspaceProjectIds: false,
existingRunningJobId: null,
existingRunningJobKind: null,
importRunningJobId: null,
importRunningJobKind: null,
mailRunningJobId: null,
mailRunningJobKind: null
});
}
}
async function runFullSync() {
if (zammadSyncPageState.overallSyncing || syncingExisting || importingConsulting || syncingMail || syncingTeamspaceProjectIds) {
return;
}
if (!zammadReady || !teamspaceReady) {
toast.error("Gesamtsync nicht bereit", {
description: !zammadReady ? "Zammad-Zugang fehlt." : "Teamspace-Zugang fehlt."
});
return;
}
await runFullSyncSequence({ resetResults: true });
}
useEffect(() => {
if (!storageReady || !overallSyncing || activeOverallSyncInMemory) {
return;
}
if (!zammadReady || !teamspaceReady) {
return;
}
void runFullSyncSequence({ resetResults: false });
}, [overallSyncing, storageReady, teamspaceReady, zammadReady]);
const overallStepLabel =
overallSyncStep === "consulting"
? "Consulting-Tickets importieren"
: overallSyncStep === "existing"
? "Bestehende Tickets aktualisieren"
: overallSyncStep === "mail"
? "Mail-Sync"
: overallSyncStep === "teamspace"
? "Teamspace-Projekt-IDs"
: null;
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Zammad-Sync</h2>
<p className="text-sm text-muted-foreground">Tickets, Sessions und Teamspace-Daten in der richtigen Reihenfolge abgleichen.</p>
</div>
<div className="flex flex-wrap items-center gap-2">
{overallStepLabel ? <Badge variant="info">{overallStepLabel}</Badge> : null}
<Button type="button" disabled={!zammadReady || !teamspaceReady || anySyncRunning} onClick={() => void runFullSync()}>
<RefreshCw className={cn("size-4", overallSyncing ? "animate-spin" : "")} />
{overallSyncing ? "Gesamtsync läuft..." : "Gesamtsync starten"}
</Button>
</div>
</div>
{!zammadReady ? (
<Alert variant="warning">
<AlertCircle className="size-4" />
<AlertTitle>Zammad-Token fehlt</AlertTitle>
<AlertDescription>Bitte im Profil den persönlichen Zammad API-Token speichern, damit die User-ID bekannt ist.</AlertDescription>
</Alert>
) : null}
<Card className="order-4">
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<DatabaseZap className="size-5 text-muted-foreground" />
Teamspace-Projekt-IDs
</CardTitle>
<CardDescription>Offene Consulting-Projekte gegen die Teamspace-API auflösen und Hauptprojekt-ID sowie Gesamtbudget aus Teamspace speichern.</CardDescription>
</div>
<Badge variant={teamspaceReady ? "success" : "secondary"}>{teamspaceReady ? "bereit" : "nicht bereit"}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{!teamspaceReady ? (
<Alert variant="warning">
<AlertCircle className="size-4" />
<AlertTitle>Teamspace-Zugang fehlt</AlertTitle>
<AlertDescription>Bitte im Profil Geräte-ID und Gerätepasswort speichern, damit die Teamspace User-ID bekannt ist.</AlertDescription>
</Alert>
) : null}
<div className="flex flex-wrap items-end justify-between gap-3">
<div className="space-y-1.5">
<label className="text-sm font-medium" htmlFor="teamspace-sync-month">Monat für TS-Zeitabgleich</label>
<Input
id="teamspace-sync-month"
type="month"
className="w-44"
value={teamspaceSyncMonth}
disabled={anySyncRunning}
onChange={(event) => setZammadSyncPageState({ teamspaceSyncMonth: event.currentTarget.value || currentMonth() })}
/>
</div>
<Button type="button" disabled={!teamspaceReady || individualSyncDisabled} onClick={() => void runTeamspaceProjectIdSync()}>
<RefreshCw className="size-4" />
{syncingTeamspaceProjectIds ? "Synchronisiert..." : "Teamspace-IDs und Budgets synchronisieren"}
</Button>
</div>
{teamspaceProjectIdResult ? (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3 xl:grid-cols-12">
{[
["Monat", teamspaceProjectIdResult.month],
["TT-Einträge", teamspaceProjectIdResult.checkedEntries],
["TS gelesen", teamspaceProjectIdResult.remoteProjectsScanned],
["TS Zeiten gelesen", teamspaceProjectIdResult.teamspaceTimeEntriesScanned],
["TS Zeiten zugeordnet", teamspaceProjectIdResult.teamspaceTimeEntriesMapped],
["TS Monat", formatMinutes(teamspaceProjectIdResult.teamspaceTimeBillableMinutes)],
["Delta", formatMinutes(teamspaceProjectIdResult.teamspaceTimeDeltaMinutes)],
["Treffer", teamspaceProjectIdResult.matched],
["IDs aktualisiert", teamspaceProjectIdResult.updated],
["Aktuell", teamspaceProjectIdResult.alreadyCurrent],
["Offen", teamspaceProjectIdResult.missing + teamspaceProjectIdResult.ambiguous]
].map(([label, value]) => (
<div key={label} className="rounded-md border bg-muted/20 px-3 py-2">
<span className="block text-xs text-muted-foreground">{label}</span>
<span className="text-lg font-semibold">{value}</span>
</div>
))}
</div>
{teamspaceProjectIdResult.duplicateRemoteProjectIds.length > 0 ? (
<Alert variant="warning">
<AlertCircle className="size-4" />
<AlertTitle>Nicht eindeutige Teamspace projectIds</AlertTitle>
<AlertDescription>{teamspaceProjectIdResult.duplicateRemoteProjectIds.slice(0, 8).join(", ")}</AlertDescription>
</Alert>
) : null}
<div className="max-h-80 overflow-auto rounded-md border">
<Table className="w-full table-fixed">
<colgroup>
<col className="w-36" />
<col className="w-44" />
<col className="w-28" />
<col className="w-32" />
<col className="w-28" />
<col className="w-28" />
<col className="w-28" />
<col />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>Ticket</TableHead>
<TableHead>Teamspace-ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>API-ID</TableHead>
<TableHead>TT Monat</TableHead>
<TableHead>TS Monat</TableHead>
<TableHead>Differenz</TableHead>
<TableHead>Hinweis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teamspaceProjectIdResult.items.length === 0 ? (
<TableRow>
<TableCell colSpan={8} className="py-8 text-center text-sm text-muted-foreground">
Keine Teamspace-Projekt-ID-Einträge in diesem Sync-Lauf.
</TableCell>
</TableRow>
) : (
teamspaceProjectIdResult.items.slice(0, 100).map((item) => (
<TableRow key={item.entryId}>
<TableCell className="truncate font-medium" title={item.ticketNumber}>{item.ticketNumber}</TableCell>
<TableCell className="truncate" title={item.teamspaceProject}>{item.teamspaceProject}</TableCell>
<TableCell>
<Badge variant={item.status === "updated" ? "success" : item.status === "current" ? "info" : item.status === "excluded" ? "secondary" : "warning"}>
{item.status === "updated"
? "Aktualisiert"
: item.status === "current"
? "Aktuell"
: item.status === "missing"
? "Fehlt"
: item.status === "excluded"
? "Ausgenommen"
: "Mehrdeutig"}
</Badge>
</TableCell>
<TableCell className="truncate" title={item.apiProjectId ?? ""}>{item.apiProjectId ?? "-"}</TableCell>
<TableCell className="whitespace-nowrap">{formatMinutes(item.ttMonthBillableMinutes)}</TableCell>
<TableCell className="whitespace-nowrap">
{formatMinutes(item.teamspaceMonthBillableMinutes)}
{item.teamspaceMonthEntryCount > 0 ? <span className="ml-1 text-xs text-muted-foreground">({item.teamspaceMonthEntryCount})</span> : null}
{item.teamspaceMonthPositions.length > 0 ? (
<div className="mt-1 max-w-80 space-y-0.5 whitespace-normal text-[11px] text-muted-foreground">
{item.teamspaceMonthPositions.slice(0, 3).map((position) => (
<div key={position.apiProjectId} className="truncate" title={`${position.label} · API ${position.apiProjectId}`}>
{position.isSubentry ? `Pos. ${position.projectId ?? position.apiProjectId}` : "Hauptprojekt"}: {position.label} · {formatMinutes(position.billableMinutes)}
</div>
))}
{item.teamspaceMonthPositions.length > 3 ? <div>+{item.teamspaceMonthPositions.length - 3} weitere Position(en)</div> : null}
</div>
) : null}
</TableCell>
<TableCell className={cn(
"whitespace-nowrap font-medium",
item.teamspaceMonthDeltaMinutes > 0
? "text-amber-600 dark:text-amber-300"
: item.teamspaceMonthDeltaMinutes < 0
? "text-destructive"
: "text-emerald-600 dark:text-emerald-300"
)}>
{formatMinutes(item.teamspaceMonthDeltaMinutes)}
</TableCell>
<TableCell className="truncate text-xs text-muted-foreground" title={item.message ?? item.apiProjectUrl ?? ""}>
{item.message ?? item.apiProjectUrl ?? "-"}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{teamspaceProjectIdResult.items.length > 100 ? (
<p className="text-xs text-muted-foreground">Weitere {teamspaceProjectIdResult.items.length - 100} Einträge wurden synchronisiert und sind hier nicht gelistet.</p>
) : null}
</div>
) : null}
</CardContent>
</Card>
<Card className="order-2">
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<RefreshCw className="size-5 text-muted-foreground" />
Bestehende Tickets aktualisieren
</CardTitle>
<CardDescription>Vorhandene TicketTracker-Tickets werden direkt aus Zammad aktualisiert.</CardDescription>
</div>
<Badge variant={zammadReady ? "success" : "secondary"}>{zammadReady ? "bereit" : "nicht bereit"}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<div className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{fieldOptions.map((option) => (
<label key={option.key} className={cn("flex min-w-0 gap-3 rounded-md border p-3", fields[option.key] ? "bg-muted/30" : "bg-background")}>
<Checkbox checked={fields[option.key]} disabled={anySyncRunning} onCheckedChange={(checked) => setField(option.key, checked === true)} />
<span className="min-w-0">
<span className="block text-sm font-medium">{option.label}</span>
<span className="block text-xs text-muted-foreground">{option.description}</span>
</span>
</label>
))}
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" disabled={!zammadReady || individualSyncDisabled} onClick={() => void runExistingSync()}>
<DatabaseZap className="size-4" />
{syncingExisting ? "Synchronisiert..." : "Jetzt aktualisieren"}
</Button>
</div>
{displayedExistingResult ? (
<ResultSummary
result={displayedExistingResult}
/>
) : null}
</CardContent>
</Card>
<Card className="order-1">
<CardHeader className="p-4">
<CardTitle className="flex items-center gap-2">
<DownloadCloud className="size-5 text-muted-foreground" />
Meine Consulting-Tickets importieren
</CardTitle>
<CardDescription>Neue Consulting-Tickets aus Zammad mit deiner Owner-ID werden direkt angelegt.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<Alert>
<AlertDescription>Neue Consulting-Tickets übernehmen Zammad-Daten immer vollständig. Geschlossene Zammad-Tickets werden in diesem Lauf nicht importiert.</AlertDescription>
</Alert>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" disabled={!zammadReady || individualSyncDisabled} onClick={() => void runConsultingImport()}>
<DownloadCloud className="size-4" />
{importingConsulting ? "Importiert..." : "Jetzt importieren"}
</Button>
</div>
{importResult ? (
<ResultSummary result={importResult} />
) : null}
</CardContent>
</Card>
<Card className="order-3">
<CardHeader className="p-4">
<CardTitle className="flex items-center gap-2">
<MailPlus className="size-5 text-muted-foreground" />
Mail-Sync
</CardTitle>
<CardDescription>Legt fest, wie weit und mit welcher Seitengröße Mail-Session-Importe suchen.</CardDescription>
</CardHeader>
<CardContent className="space-y-4 px-4 pb-4">
<div className="flex flex-wrap items-end gap-3">
<label className="grid w-48 gap-1 text-sm font-medium">
Tage rückwirkend
<Input
type="number"
min={1}
max={3650}
value={mailDaysBack}
disabled={anySyncRunning}
onChange={(event) => setZammadSyncPageState({ mailDaysBack: parseMailDaysBack(event.currentTarget.value) })}
/>
</label>
<label className="grid w-48 gap-1 text-sm font-medium">
Abgleich ab
<Input type="date" value={mailSyncCutoffDate(mailDaysBack)} readOnly tabIndex={-1} />
</label>
<label className="grid w-48 gap-1 text-sm font-medium">
PER_PAGE
<Input
type="number"
min={1}
max={1000}
value={mailPerPage}
disabled={anySyncRunning}
onChange={(event) => setZammadSyncPageState({ mailPerPage: parseMailPerPage(event.currentTarget.value) })}
/>
</label>
</div>
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" disabled={!zammadReady || individualSyncDisabled} onClick={() => void runMailSync()}>
<MailPlus className="size-4" />
{syncingMail ? "Importiert..." : "Jetzt synchronisieren"}
</Button>
</div>
{mailResult ? (
<ResultSummary result={mailResult} />
) : null}
</CardContent>
</Card>
</div>
);
}