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; 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 | ((current: ZammadSyncPageState) => Partial)) { 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 { 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(); 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 (
{metrics.map(([label, value]) => (
{label} {value}
))}
Ticket Aktion Daten aus Zammad Änderungen in TicketTracker {listedItems.length === 0 ? ( Keine Änderungen in diesem Lauf. ) : ( listedItems.map((item) => { const action = formatAction(item); const visibleChanges = visibleTicketTrackerChanges(item); return ( {item.ticketNumber}
{action.label} {item.reason ?

{item.reason}

: null}
{zammadDetails(item).map((detail) => (
{detail}
))}
{visibleChanges.length > 0 ? (
{visibleChanges.map((change) => { const fromValue = formatChangeValue(change.field, change.from); const toValue = formatChangeValue(change.field, change.to); const title = `${change.field}: ${fromValue} -> ${toValue}`; return (
{change.field}:{" "} {fromValue} -> {toValue}
); })}
) : ( Keine Änderung. )}
); }) )}
{result.errors.length > 0 ? ( Fehler im Lauf {result.errors.length} Datensatz/Datensätze konnten nicht vorbereitet oder verarbeitet werden. ) : null}
); } 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 (

Zammad-Sync

Tickets, Sessions und Teamspace-Daten in der richtigen Reihenfolge abgleichen.

{overallStepLabel ? {overallStepLabel} : null}
{!zammadReady ? ( Zammad-Token fehlt Bitte im Profil den persönlichen Zammad API-Token speichern, damit die User-ID bekannt ist. ) : null}
Teamspace-Projekt-IDs Offene Consulting-Projekte gegen die Teamspace-API auflösen und Hauptprojekt-ID sowie Gesamtbudget aus Teamspace speichern.
{teamspaceReady ? "bereit" : "nicht bereit"}
{!teamspaceReady ? ( Teamspace-Zugang fehlt Bitte im Profil Geräte-ID und Gerätepasswort speichern, damit die Teamspace User-ID bekannt ist. ) : null}
setZammadSyncPageState({ teamspaceSyncMonth: event.currentTarget.value || currentMonth() })} />
{teamspaceProjectIdResult ? (
{[ ["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]) => (
{label} {value}
))}
{teamspaceProjectIdResult.duplicateRemoteProjectIds.length > 0 ? ( Nicht eindeutige Teamspace projectIds {teamspaceProjectIdResult.duplicateRemoteProjectIds.slice(0, 8).join(", ")} ) : null}
Ticket Teamspace-ID Status API-ID TT Monat TS Monat Differenz Hinweis {teamspaceProjectIdResult.items.length === 0 ? ( Keine Teamspace-Projekt-ID-Einträge in diesem Sync-Lauf. ) : ( teamspaceProjectIdResult.items.slice(0, 100).map((item) => ( {item.ticketNumber} {item.teamspaceProject} {item.status === "updated" ? "Aktualisiert" : item.status === "current" ? "Aktuell" : item.status === "missing" ? "Fehlt" : item.status === "excluded" ? "Ausgenommen" : "Mehrdeutig"} {item.apiProjectId ?? "-"} {formatMinutes(item.ttMonthBillableMinutes)} {formatMinutes(item.teamspaceMonthBillableMinutes)} {item.teamspaceMonthEntryCount > 0 ? ({item.teamspaceMonthEntryCount}) : null} {item.teamspaceMonthPositions.length > 0 ? (
{item.teamspaceMonthPositions.slice(0, 3).map((position) => (
{position.isSubentry ? `Pos. ${position.projectId ?? position.apiProjectId}` : "Hauptprojekt"}: {position.label} · {formatMinutes(position.billableMinutes)}
))} {item.teamspaceMonthPositions.length > 3 ?
+{item.teamspaceMonthPositions.length - 3} weitere Position(en)
: null}
) : null}
0 ? "text-amber-600 dark:text-amber-300" : item.teamspaceMonthDeltaMinutes < 0 ? "text-destructive" : "text-emerald-600 dark:text-emerald-300" )}> {formatMinutes(item.teamspaceMonthDeltaMinutes)} {item.message ?? item.apiProjectUrl ?? "-"}
)) )}
{teamspaceProjectIdResult.items.length > 100 ? (

Weitere {teamspaceProjectIdResult.items.length - 100} Einträge wurden synchronisiert und sind hier nicht gelistet.

) : null}
) : null}
Bestehende Tickets aktualisieren Vorhandene TicketTracker-Tickets werden direkt aus Zammad aktualisiert.
{zammadReady ? "bereit" : "nicht bereit"}
{fieldOptions.map((option) => ( ))}
{displayedExistingResult ? ( ) : null}
Meine Consulting-Tickets importieren Neue Consulting-Tickets aus Zammad mit deiner Owner-ID werden direkt angelegt. Neue Consulting-Tickets übernehmen Zammad-Daten immer vollständig. Geschlossene Zammad-Tickets werden in diesem Lauf nicht importiert.
{importResult ? ( ) : null}
Mail-Sync Legt fest, wie weit und mit welcher Seitengröße Mail-Session-Importe suchen.
{mailResult ? ( ) : null}
); }