1769 lines
62 KiB
TypeScript
1769 lines
62 KiB
TypeScript
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
|
import { AlertCircle, CheckCircle2, 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 {
|
|
applyExistingZammadTicketsFromPreview,
|
|
applyOwnZammadConsultingTicketsFromPreview,
|
|
applyZammadMailSessionsFromPreview,
|
|
previewExistingZammadTickets,
|
|
previewOwnZammadConsultingTickets,
|
|
previewZammadMailSessions,
|
|
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, zammadStateId, zammadStateLabel } from "../zammad-meta";
|
|
|
|
type ZammadSyncPageProps = {
|
|
currentUser: AuthUser;
|
|
};
|
|
|
|
type SelectionMap = Record<string, boolean>;
|
|
type RunningJobKind = "preview" | "apply";
|
|
|
|
const defaultFields: ZammadSyncExistingFields = {
|
|
title: true,
|
|
organization: true,
|
|
type: true,
|
|
projectName: true,
|
|
budget: true,
|
|
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: "budget", label: "Budget", description: "Teamspace-Stundenkontingent überschreiben." },
|
|
{ key: "pendingTodo", label: "Termin-ToDo", description: "Aus Wartend/Abschluss mit Datum ein ToDo erzeugen." }
|
|
];
|
|
|
|
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;
|
|
mailResult: ZammadSyncResult | null;
|
|
mailPreviewJobId: string | null;
|
|
mailRunningJobId: string | null;
|
|
mailRunningJobKind: RunningJobKind | null;
|
|
mailDaysBack: number;
|
|
mailPerPage: number;
|
|
existingSelection: SelectionMap;
|
|
importSelection: SelectionMap;
|
|
mailSelection: SelectionMap;
|
|
syncingExisting: boolean;
|
|
importingConsulting: boolean;
|
|
syncingMail: boolean;
|
|
};
|
|
|
|
function emptyZammadSyncPageState(): ZammadSyncPageState {
|
|
return {
|
|
fields: defaultFields,
|
|
existingResult: null,
|
|
existingPreviewJobId: null,
|
|
existingRunningJobId: null,
|
|
existingRunningJobKind: null,
|
|
importResult: null,
|
|
importPreviewJobId: null,
|
|
importRunningJobId: null,
|
|
importRunningJobKind: null,
|
|
mailResult: null,
|
|
mailPreviewJobId: null,
|
|
mailRunningJobId: null,
|
|
mailRunningJobKind: null,
|
|
mailDaysBack: 30,
|
|
mailPerPage: 300,
|
|
existingSelection: {},
|
|
importSelection: {},
|
|
mailSelection: {},
|
|
syncingExisting: false,
|
|
importingConsulting: false,
|
|
syncingMail: false
|
|
};
|
|
}
|
|
|
|
let zammadSyncPageState: ZammadSyncPageState = emptyZammadSyncPageState();
|
|
let hydratedZammadSyncUserId: string | null = null;
|
|
let activeZammadSyncStorageUserId: string | null = null;
|
|
|
|
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 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 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: syncResult(parsed.existingResult),
|
|
existingPreviewJobId: nullableString(parsed.existingPreviewJobId),
|
|
existingRunningJobId,
|
|
existingRunningJobKind,
|
|
importResult: syncResult(parsed.importResult),
|
|
importPreviewJobId: nullableString(parsed.importPreviewJobId),
|
|
importRunningJobId,
|
|
importRunningJobKind,
|
|
mailResult: syncResult(parsed.mailResult),
|
|
mailPreviewJobId: nullableString(parsed.mailPreviewJobId),
|
|
mailRunningJobId,
|
|
mailRunningJobKind,
|
|
mailDaysBack: parseMailDaysBack(parsed.mailDaysBack),
|
|
mailPerPage: parseMailPerPage(parsed.mailPerPage),
|
|
existingSelection: selectionMap(parsed.existingSelection),
|
|
importSelection: selectionMap(parsed.importSelection),
|
|
mailSelection: selectionMap(parsed.mailSelection),
|
|
syncingExisting: Boolean(existingRunningJobId && existingRunningJobKind),
|
|
importingConsulting: Boolean(importRunningJobId && importRunningJobKind),
|
|
syncingMail: Boolean(mailRunningJobId && mailRunningJobKind)
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function saveZammadSyncPageState(userId: string, state: ZammadSyncPageState) {
|
|
setLocalStorageValue(zammadSyncStorageKey(userId), JSON.stringify(state));
|
|
}
|
|
|
|
function useZammadSyncPageState() {
|
|
return useSyncExternalStore(subscribeZammadSyncPageState, getZammadSyncPageState, getZammadSyncPageState);
|
|
}
|
|
|
|
function selectableItems(result: ZammadSyncResult) {
|
|
return result.items.filter((item) => item.action === "update" || item.action === "create");
|
|
}
|
|
|
|
function selectionKey(item: ZammadSyncItem) {
|
|
const articleId = item.zammad?.articleId;
|
|
return typeof articleId === "number" || typeof articleId === "string" ? `article:${articleId}` : item.ticketNumber;
|
|
}
|
|
|
|
function itemIsSelected(item: ZammadSyncItem, selection: SelectionMap) {
|
|
return (item.action === "update" || item.action === "create") && selection[selectionKey(item)] !== false;
|
|
}
|
|
|
|
function selectionFromResult(result: ZammadSyncResult): SelectionMap {
|
|
return Object.fromEntries(selectableItems(result).map((item) => [selectionKey(item), true]));
|
|
}
|
|
|
|
function selectedTicketNumbers(result: ZammadSyncResult | null, selection: SelectionMap) {
|
|
if (!result) {
|
|
return [];
|
|
}
|
|
|
|
return selectableItems(result)
|
|
.filter((item) => itemIsSelected(item, selection))
|
|
.map((item) => item.ticketNumber);
|
|
}
|
|
|
|
function selectedArticleIds(result: ZammadSyncResult | null, selection: SelectionMap) {
|
|
if (!result) {
|
|
return [];
|
|
}
|
|
|
|
return selectableItems(result)
|
|
.filter((item) => itemIsSelected(item, selection))
|
|
.map((item) => Number(item.zammad?.articleId))
|
|
.filter((articleId) => Number.isInteger(articleId) && articleId > 0);
|
|
}
|
|
|
|
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-Budget") {
|
|
return formatMinutes(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 changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, fields: ZammadSyncExistingFields) {
|
|
const localWorkType = item.local?.workType;
|
|
const zammadWorkType = item.zammad?.workType;
|
|
const consultingProjectCanBeTouched = zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
|
|
|
|
if (change.field === "Titel") {
|
|
return fields.title;
|
|
}
|
|
|
|
if (change.field === "Organisation" || change.field === "Kunde") {
|
|
return fields.organization;
|
|
}
|
|
|
|
if (change.field === "Typ") {
|
|
return fields.type;
|
|
}
|
|
|
|
if (change.field === "Projekt" || change.field === "Projekt/Teamspace-Daten") {
|
|
return fields.type;
|
|
}
|
|
|
|
if (change.field === "Teamspace-Projekt") {
|
|
return fields.projectName && consultingProjectCanBeTouched;
|
|
}
|
|
|
|
if (change.field === "Teamspace-Projekte") {
|
|
return fields.projectName && consultingProjectCanBeTouched;
|
|
}
|
|
|
|
if (change.field === "Teamspace-Prüfung") {
|
|
return fields.projectName && consultingProjectCanBeTouched;
|
|
}
|
|
|
|
if (change.field === "Merge-Projektdaten" || change.field === "Merge-Prüfung") {
|
|
return true;
|
|
}
|
|
|
|
if (change.field === "Teamspace-Budget") {
|
|
return fields.budget && consultingProjectCanBeTouched;
|
|
}
|
|
|
|
if (change.field === "ToDo") {
|
|
return fields.pendingTodo;
|
|
}
|
|
|
|
if (change.field === "TS/TT-Prüfung") {
|
|
return consultingProjectCanBeTouched;
|
|
}
|
|
|
|
return !change.field.startsWith("Zammad-");
|
|
}
|
|
|
|
function cachedValue(source: Record<string, unknown> | null | undefined, key: string) {
|
|
return source && Object.prototype.hasOwnProperty.call(source, key) ? source[key] : null;
|
|
}
|
|
|
|
function cachedObject(source: Record<string, unknown> | null | undefined, key: string) {
|
|
const value = cachedValue(source, key);
|
|
return value && typeof value === "object" && !Array.isArray(value) ? (value as Record<string, unknown>) : null;
|
|
}
|
|
|
|
function cachedStringList(source: Record<string, unknown> | null | undefined, key: string) {
|
|
const value = cachedValue(source, key);
|
|
return Array.isArray(value) ? value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean) : [];
|
|
}
|
|
|
|
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 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 mergeReviewMatchesCachedZammad(localTeamspaceProjects: unknown, zammadTeamspaceProjects: unknown) {
|
|
return !cachedValueChanged("Teamspace-Projekte", localTeamspaceProjects, zammadTeamspaceProjects);
|
|
}
|
|
|
|
function zammadPendingTodoPreview(zammad: Record<string, unknown>) {
|
|
const stateId = numericValue(cachedValue(zammad, "stateId"));
|
|
const pendingTime = cachedValue(zammad, "pendingTime");
|
|
|
|
if ((stateId !== 3 && stateId !== 7) || typeof pendingTime !== "string") {
|
|
return null;
|
|
}
|
|
|
|
const date = new Date(pendingTime);
|
|
|
|
if (Number.isNaN(date.getTime())) {
|
|
return null;
|
|
}
|
|
|
|
const kind = stateId === 7 ? "Abschluss" : "Erinnerung";
|
|
const ticketNumber = cachedValue(zammad, "ticketNumber");
|
|
const ticketLabel = typeof ticketNumber === "string" ? ticketNumber.replace(/^Ticket#?/i, "").replace(/^#/, "") : "";
|
|
|
|
return `${kind} ${ticketLabel ? `Ticket#${ticketLabel}` : "Ticket"} (${date.toISOString().slice(0, 10)})`;
|
|
}
|
|
|
|
function comparablePreviewValue(value: unknown) {
|
|
if (typeof value === "string" && value.trim().length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return value ?? null;
|
|
}
|
|
|
|
function comparablePreviewIdValue(value: unknown) {
|
|
const comparable = comparablePreviewValue(value);
|
|
|
|
if (typeof comparable === "number" && Number.isInteger(comparable)) {
|
|
return String(comparable);
|
|
}
|
|
|
|
if (typeof comparable === "bigint") {
|
|
return comparable.toString();
|
|
}
|
|
|
|
if (typeof comparable === "string" && /^\d+$/.test(comparable.trim())) {
|
|
return comparable.trim().replace(/^0+(?=\d)/, "");
|
|
}
|
|
|
|
return comparable;
|
|
}
|
|
|
|
function comparablePreviewValueForField(field: string, value: unknown) {
|
|
if (field === "Zammad-Status") {
|
|
return zammadStateId(value);
|
|
}
|
|
|
|
if (field === "Organisation" || field === "Zammad-Priorität") {
|
|
return comparablePreviewIdValue(value);
|
|
}
|
|
|
|
if (field === "Teamspace-Projekte") {
|
|
return normalizedTeamspaceProjectList(value).sort((left, right) => left.localeCompare(right, "de", { numeric: true, sensitivity: "base" }));
|
|
}
|
|
|
|
return comparablePreviewValue(value);
|
|
}
|
|
|
|
function cachedValueChanged(field: string, left: unknown, right: unknown) {
|
|
return JSON.stringify(comparablePreviewValueForField(field, left)) !== JSON.stringify(comparablePreviewValueForField(field, right));
|
|
}
|
|
|
|
function addCachedChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) {
|
|
if (cachedValueChanged(field, from, to)) {
|
|
changes.push({ field, from: comparablePreviewValueForField(field, from), to: comparablePreviewValueForField(field, to) });
|
|
}
|
|
}
|
|
|
|
const preservedExistingPreviewChangeFields = new Set([
|
|
"Fremdes Ticket",
|
|
"Fremder Zammad-Owner",
|
|
"Owner",
|
|
"Ticket-Zuordnung",
|
|
"Hinweis",
|
|
"Ticketnummer",
|
|
"TicketTracker-Zusammenführung"
|
|
]);
|
|
|
|
function preserveExistingPreviewChanges(item: ZammadSyncItem, changes: ZammadSyncChange[], fields: ZammadSyncExistingFields) {
|
|
const existingFields = new Set(changes.map((change) => change.field));
|
|
|
|
return [
|
|
...item.changes.filter((change) => preservedExistingPreviewChangeFields.has(change.field) && !existingFields.has(change.field) && changeAllowedByFields(item, change, fields)),
|
|
...changes
|
|
];
|
|
}
|
|
|
|
function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyncExistingFields) {
|
|
if (!item.zammad) {
|
|
return item.changes.filter((change) => changeAllowedByFields(item, change, fields));
|
|
}
|
|
|
|
if (item.reason === "Zammad-Organisation ist lokal nicht synchronisiert." && fields.organization) {
|
|
return [];
|
|
}
|
|
|
|
const local = item.local;
|
|
const zammad = item.zammad;
|
|
const project = cachedObject(local, "project");
|
|
const localWorkType = cachedValue(local, "workType");
|
|
const zammadWorkType = cachedValue(zammad, "workType");
|
|
const zammadStateId = numericValue(cachedValue(zammad, "stateId"));
|
|
const mergeProtected = Boolean(
|
|
cachedValue(local, "zammadMergedFromTicketId")
|
|
|| cachedValue(zammad, "mergedFromTicketId")
|
|
|| (cachedValue(project, "closeReviewRequired") === true && zammadStateId !== 4)
|
|
);
|
|
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");
|
|
const zammadTeamspaceProjectList = zammadTeamspaceProjects.length ? zammadTeamspaceProjects : splitTeamspaceProjects(cachedValue(zammad, "teamspaceProject"));
|
|
const canSyncBudget = fields.budget && (cachedValue(project, "defaultBudgetSyncDisabled") !== true || fields.overrideBudget);
|
|
const changes: ZammadSyncChange[] = [];
|
|
|
|
addCachedChange(changes, "Zammad-Priorität", cachedValue(local, "zammadPriorityId"), cachedValue(zammad, "priorityId"));
|
|
addCachedChange(changes, "Zammad-Status", cachedValue(local, "zammadStateId"), cachedValue(zammad, "stateId"));
|
|
|
|
const pendingTodo = zammadPendingTodoPreview(zammad);
|
|
if (fields.pendingTodo && pendingTodo && pendingTodoExists === false) {
|
|
changes.push({ field: "ToDo", from: null, to: pendingTodo });
|
|
}
|
|
|
|
if (zammadWorkType === "support" && zammadStateId === 4) {
|
|
return changes;
|
|
}
|
|
|
|
if (fields.title) {
|
|
addCachedChange(changes, "Titel", cachedValue(local, "title"), cachedValue(zammad, "title"));
|
|
}
|
|
|
|
if (fields.organization) {
|
|
addCachedChange(changes, "Organisation", cachedValue(local, "organizationId"), cachedValue(zammad, "organizationId"));
|
|
}
|
|
|
|
if (fields.type && !mergeProtected) {
|
|
addCachedChange(changes, "Typ", localWorkType, zammadWorkType);
|
|
}
|
|
|
|
if (!mergeProtected && fields.type && zammadWorkType === "support" && project) {
|
|
changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" });
|
|
}
|
|
|
|
const consultingProjectCanBeTouched = !mergeProtected && zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
|
|
|
|
if (consultingProjectCanBeTouched) {
|
|
if (fields.type && !project) {
|
|
changes.push({ field: "Projekt", from: null, to: "wird angelegt" });
|
|
}
|
|
|
|
if (fields.projectName) {
|
|
addCachedTeamspaceProjectChanges(changes, localTeamspaceProjectList, zammadTeamspaceProjectList);
|
|
}
|
|
|
|
if (canSyncBudget) {
|
|
addCachedChange(changes, "Teamspace-Budget", cachedValue(project, "defaultHourBudgetMinutes") ?? cachedValue(project, "hourBudgetMinutes"), cachedValue(zammad, "teamspaceBudgetMinutes"));
|
|
}
|
|
|
|
if (zammadStateId === 4 && cachedValue(project, "closedAt") === null && cachedValue(project, "closeReviewRequired") !== true) {
|
|
changes.push({ field: "TS/TT-Prüfung", from: cachedValue(project, "closeReviewRequired") ?? false, to: true });
|
|
}
|
|
}
|
|
|
|
if (mergeProtected && project && cachedValue(project, "closeReviewRequired") === true && zammadStateId !== 4) {
|
|
if (mergeReviewMatchesCachedZammad(localTeamspaceProjectList, zammadTeamspaceProjectList)) {
|
|
changes.push({ field: "Merge-Prüfung", from: true, to: false });
|
|
} else {
|
|
if (localTeamspaceProjectList.length > 0 && zammadTeamspaceProjectList.length === 0) {
|
|
changes.push({
|
|
field: "Merge-Projektdaten",
|
|
from: normalizedTeamspaceProjectList(localTeamspaceProjectList),
|
|
to: "in Zammad fehlen; lokale Projektdaten bleiben erhalten und muessen dort nachgetragen werden"
|
|
});
|
|
} else if (cachedValueChanged("Teamspace-Projekte", localTeamspaceProjectList, zammadTeamspaceProjectList)) {
|
|
changes.push({
|
|
field: "Merge-Projektdaten",
|
|
from: normalizedTeamspaceProjectList(localTeamspaceProjectList),
|
|
to: normalizedTeamspaceProjectList(zammadTeamspaceProjectList)
|
|
});
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
return preserveExistingPreviewChanges(item, changes, fields);
|
|
}
|
|
|
|
function rebuildZammadSyncResultFromItems(mode: ZammadSyncResult["mode"], items: ZammadSyncItem[], checked = items.length): ZammadSyncResult {
|
|
const result: ZammadSyncResult = {
|
|
mode,
|
|
checked,
|
|
updated: 0,
|
|
created: 0,
|
|
skipped: 0,
|
|
missingInZammad: 0,
|
|
organizationMissing: 0,
|
|
markedForCloseReview: 0,
|
|
convertedToConsulting: 0,
|
|
convertedToSupport: 0,
|
|
errors: [],
|
|
items
|
|
};
|
|
|
|
for (const item of items) {
|
|
if (item.action === "create") {
|
|
result.created += 1;
|
|
} else if (item.action === "update") {
|
|
result.updated += 1;
|
|
} else {
|
|
result.skipped += 1;
|
|
}
|
|
|
|
if (item.action === "error") {
|
|
result.errors.push({ ticketNumber: item.ticketNumber, error: item.reason ?? "Unbekannter Fehler" });
|
|
}
|
|
|
|
if (!item.zammad && item.reason === "Ticket wurde in Zammad nicht gefunden.") {
|
|
result.missingInZammad += 1;
|
|
}
|
|
|
|
if (item.reason === "Zammad-Organisation ist lokal nicht synchronisiert.") {
|
|
result.organizationMissing += 1;
|
|
}
|
|
|
|
if (item.changes.some((change) => change.field === "TS/TT-Prüfung")) {
|
|
result.markedForCloseReview += 1;
|
|
}
|
|
|
|
const typeChange = item.changes.find((change) => change.field === "Typ");
|
|
if (typeChange?.to === "consulting" && item.local?.workType !== "consulting") {
|
|
result.convertedToConsulting += 1;
|
|
}
|
|
if (typeChange?.to === "support" && item.local?.workType === "consulting") {
|
|
result.convertedToSupport += 1;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function applyFieldsToExistingPreview(result: ZammadSyncResult | null, fields: ZammadSyncExistingFields) {
|
|
if (!result || result.mode !== "preview") {
|
|
return result;
|
|
}
|
|
|
|
const items = result.items.map((item) => {
|
|
if (item.action === "create" || item.action === "error") {
|
|
return item;
|
|
}
|
|
|
|
const changes = changesFromExistingPreviewCache(item, fields);
|
|
|
|
if (changes.length === 0) {
|
|
return {
|
|
...item,
|
|
action: "skip" as const,
|
|
reason: item.reason === "Zammad-Organisation ist lokal nicht synchronisiert." && fields.organization ? item.reason : "Kein Sync nach aktueller Auswahl.",
|
|
changes
|
|
};
|
|
}
|
|
|
|
return {
|
|
...item,
|
|
action: "update" as const,
|
|
reason:
|
|
item.reason === "Kein Sync nach aktueller Auswahl." ||
|
|
item.reason === "Keine Änderung nach aktueller Auswahl." ||
|
|
item.reason === "Zammad-Organisation ist lokal nicht synchronisiert."
|
|
? "Ticket wird aktualisiert."
|
|
: item.reason,
|
|
changes
|
|
};
|
|
});
|
|
|
|
return rebuildZammadSyncResultFromItems(result.mode, items, result.checked);
|
|
}
|
|
|
|
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)}`,
|
|
`Budget ${formatMinutes(zammad.teamspaceBudgetMinutes)}`
|
|
];
|
|
}
|
|
|
|
function ResultSummary({
|
|
result,
|
|
selection,
|
|
onToggle,
|
|
onToggleAll
|
|
}: {
|
|
result: ZammadSyncResult;
|
|
selection?: SelectionMap;
|
|
onToggle?: (ticketNumber: string, checked: boolean) => void;
|
|
onToggleAll?: (checked: boolean) => void;
|
|
}) {
|
|
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],
|
|
[result.mode === "preview" ? "Würde anlegen" : "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>
|
|
|
|
{selection ? (
|
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
|
<span className="text-sm text-muted-foreground">
|
|
{selectedCount} von {selectable.length} Datensatz/Datensätzen für den Transfer ausgewählt.
|
|
</span>
|
|
<div className="flex gap-2">
|
|
<Button type="button" variant="outline" size="sm" disabled={selectable.length === 0} onClick={() => onToggleAll?.(true)}>
|
|
Alle wählen
|
|
</Button>
|
|
<Button type="button" variant="outline" size="sm" disabled={selectable.length === 0} onClick={() => onToggleAll?.(false)}>
|
|
Alle abwählen
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="max-h-[520px] overflow-auto rounded-md border">
|
|
<Table className="w-full table-fixed">
|
|
<colgroup>
|
|
{selection ? <col className="w-12" /> : null}
|
|
<col className="w-40" />
|
|
<col className="w-64" />
|
|
<col className="w-[34rem]" />
|
|
<col className="w-[36rem]" />
|
|
</colgroup>
|
|
<TableHeader>
|
|
<TableRow>
|
|
{selection ? (
|
|
<TableHead>
|
|
<Checkbox checked={allSelected} disabled={selectable.length === 0} onCheckedChange={(checked) => onToggleAll?.(checked === true)} />
|
|
</TableHead>
|
|
) : null}
|
|
<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={selection ? 5 : 4} className="py-8 text-center text-sm text-muted-foreground">
|
|
Keine Änderungen für die aktuelle Auswahl.
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
listedItems.map((item) => {
|
|
const action = formatAction(item);
|
|
const isSelectable = item.action === "update" || item.action === "create";
|
|
const visibleChanges = visibleTicketTrackerChanges(item);
|
|
|
|
return (
|
|
<TableRow key={`${result.mode}-${selectionKey(item)}-${item.action}`}>
|
|
{selection ? (
|
|
<TableCell>
|
|
<Checkbox
|
|
checked={itemIsSelected(item, selection)}
|
|
disabled={!isSelectable}
|
|
onCheckedChange={(checked) => onToggle?.(selectionKey(item), checked === true)}
|
|
/>
|
|
</TableCell>
|
|
) : null}
|
|
<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"> -> </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,
|
|
existingSelection,
|
|
importSelection,
|
|
mailSelection,
|
|
syncingExisting,
|
|
importingConsulting,
|
|
syncingMail
|
|
} = 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 selectedMailCount = useMemo(() => selectedArticleIds(mailResult, mailSelection).length, [mailResult, mailSelection]);
|
|
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: null,
|
|
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: null,
|
|
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]);
|
|
|
|
useEffect(() => {
|
|
if (!storageReady || !mailRunningJobId || !mailRunningJobKind) {
|
|
return;
|
|
}
|
|
|
|
let cancelled = false;
|
|
|
|
void waitForZammadSyncJob(mailRunningJobId)
|
|
.then((result) => {
|
|
if (cancelled) {
|
|
return;
|
|
}
|
|
|
|
if (mailRunningJobKind === "preview") {
|
|
setZammadSyncPageState((current) => ({
|
|
mailResult: result,
|
|
mailPreviewJobId: mailRunningJobId,
|
|
mailSelection: Object.keys(current.mailSelection).length ? current.mailSelection : selectionFromResult(result),
|
|
mailRunningJobId: null,
|
|
mailRunningJobKind: null,
|
|
syncingMail: false
|
|
}));
|
|
} else {
|
|
setZammadSyncPageState({
|
|
mailResult: null,
|
|
mailPreviewJobId: null,
|
|
mailSelection: {},
|
|
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,
|
|
existingRunningJobId: null,
|
|
existingRunningJobKind: null,
|
|
importRunningJobId: null,
|
|
importRunningJobKind: null,
|
|
mailRunningJobId: null,
|
|
mailRunningJobKind: null
|
|
});
|
|
toast.info("Laufstatus zurückgesetzt");
|
|
}
|
|
|
|
async function previewExistingSync() {
|
|
if (zammadSyncPageState.syncingExisting) {
|
|
return;
|
|
}
|
|
|
|
const syncFields = zammadSyncPageState.fields;
|
|
setZammadSyncPageState({
|
|
existingResult: null,
|
|
existingPreviewJobId: null,
|
|
existingSelection: {},
|
|
syncingExisting: true,
|
|
existingRunningJobId: null,
|
|
existingRunningJobKind: null
|
|
});
|
|
try {
|
|
const { jobId, result } = await previewExistingZammadTickets(syncFields, (job: ZammadSyncJob) => {
|
|
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "preview" });
|
|
});
|
|
setZammadSyncPageState({
|
|
existingResult: result,
|
|
existingPreviewJobId: jobId,
|
|
existingSelection: selectionFromResult(result),
|
|
existingRunningJobId: null,
|
|
existingRunningJobKind: null
|
|
});
|
|
toast.success("Vorschau erstellt", {
|
|
description: `${result.updated + result.created} Datensatz/Datensätze können übernommen werden.`
|
|
});
|
|
} catch (error) {
|
|
toast.error("Zammad-Vorschau fehlgeschlagen", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setZammadSyncPageState({ syncingExisting: false, existingRunningJobId: null, existingRunningJobKind: null });
|
|
}
|
|
}
|
|
|
|
async function applyExistingSync() {
|
|
if (zammadSyncPageState.syncingExisting) {
|
|
return;
|
|
}
|
|
|
|
const currentResult = applyFieldsToExistingPreview(zammadSyncPageState.existingResult, zammadSyncPageState.fields);
|
|
const ticketNumbers = selectedTicketNumbers(currentResult, zammadSyncPageState.existingSelection);
|
|
|
|
if (ticketNumbers.length === 0) {
|
|
toast.info("Keine Datensätze ausgewählt");
|
|
return;
|
|
}
|
|
|
|
if (!zammadSyncPageState.existingPreviewJobId) {
|
|
toast.error("Keine Vorschau im Zwischenspeicher");
|
|
return;
|
|
}
|
|
|
|
const syncFields = zammadSyncPageState.fields;
|
|
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null });
|
|
try {
|
|
const { result } = await applyExistingZammadTicketsFromPreview(zammadSyncPageState.existingPreviewJobId, syncFields, ticketNumbers, (job: ZammadSyncJob) => {
|
|
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "apply" });
|
|
});
|
|
setZammadSyncPageState({
|
|
existingResult: null,
|
|
existingPreviewJobId: null,
|
|
existingSelection: {},
|
|
existingRunningJobId: null,
|
|
existingRunningJobKind: null
|
|
});
|
|
toast.success("Auswahl 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 previewConsultingImport() {
|
|
if (zammadSyncPageState.importingConsulting) {
|
|
return;
|
|
}
|
|
|
|
setZammadSyncPageState({
|
|
importResult: null,
|
|
importPreviewJobId: null,
|
|
importSelection: {},
|
|
importingConsulting: true,
|
|
importRunningJobId: null,
|
|
importRunningJobKind: null
|
|
});
|
|
try {
|
|
const { jobId, result } = await previewOwnZammadConsultingTickets((job: ZammadSyncJob) => {
|
|
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "preview" });
|
|
});
|
|
setZammadSyncPageState({
|
|
importResult: result,
|
|
importPreviewJobId: jobId,
|
|
importSelection: selectionFromResult(result),
|
|
importRunningJobId: null,
|
|
importRunningJobKind: null
|
|
});
|
|
toast.success("Import-Vorschau erstellt", {
|
|
description: `${result.created} Consulting-Ticket(s) können angelegt werden.`
|
|
});
|
|
} catch (error) {
|
|
toast.error("Zammad-Importvorschau fehlgeschlagen", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setZammadSyncPageState({ importingConsulting: false, importRunningJobId: null, importRunningJobKind: null });
|
|
}
|
|
}
|
|
|
|
async function applyConsultingImport() {
|
|
if (zammadSyncPageState.importingConsulting) {
|
|
return;
|
|
}
|
|
|
|
const ticketNumbers = selectedTicketNumbers(zammadSyncPageState.importResult, zammadSyncPageState.importSelection);
|
|
|
|
if (ticketNumbers.length === 0) {
|
|
toast.info("Keine Datensätze ausgewählt");
|
|
return;
|
|
}
|
|
|
|
if (!zammadSyncPageState.importPreviewJobId) {
|
|
toast.error("Keine Import-Vorschau im Zwischenspeicher");
|
|
return;
|
|
}
|
|
|
|
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null });
|
|
try {
|
|
const { result } = await applyOwnZammadConsultingTicketsFromPreview(zammadSyncPageState.importPreviewJobId, ticketNumbers, (job: ZammadSyncJob) => {
|
|
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "apply" });
|
|
});
|
|
setZammadSyncPageState({
|
|
importResult: null,
|
|
importPreviewJobId: null,
|
|
importSelection: {},
|
|
importRunningJobId: null,
|
|
importRunningJobKind: null
|
|
});
|
|
toast.success("Auswahl 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 previewMailSync() {
|
|
if (zammadSyncPageState.syncingMail) {
|
|
return;
|
|
}
|
|
|
|
const daysBack = parseMailDaysBack(zammadSyncPageState.mailDaysBack);
|
|
const perPage = parseMailPerPage(zammadSyncPageState.mailPerPage);
|
|
setZammadSyncPageState({
|
|
mailResult: null,
|
|
mailPreviewJobId: null,
|
|
mailSelection: {},
|
|
mailDaysBack: daysBack,
|
|
mailPerPage: perPage,
|
|
syncingMail: true,
|
|
mailRunningJobId: null,
|
|
mailRunningJobKind: null
|
|
});
|
|
try {
|
|
const { jobId, result } = await previewZammadMailSessions(daysBack, perPage, (job: ZammadSyncJob) => {
|
|
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: job.id, mailRunningJobKind: "preview" });
|
|
});
|
|
setZammadSyncPageState({
|
|
mailResult: result,
|
|
mailPreviewJobId: jobId,
|
|
mailSelection: selectionFromResult(result),
|
|
mailRunningJobId: null,
|
|
mailRunningJobKind: null
|
|
});
|
|
toast.success("Mail-Vorschau erstellt", {
|
|
description: `${result.created} Mail-Session(s) können angelegt werden.`
|
|
});
|
|
} catch (error) {
|
|
toast.error("Mail-Vorschau fehlgeschlagen", {
|
|
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
|
});
|
|
} finally {
|
|
setZammadSyncPageState({ syncingMail: false, mailRunningJobId: null, mailRunningJobKind: null });
|
|
}
|
|
}
|
|
|
|
async function applyMailSync() {
|
|
if (zammadSyncPageState.syncingMail) {
|
|
return;
|
|
}
|
|
|
|
const articleIds = selectedArticleIds(zammadSyncPageState.mailResult, zammadSyncPageState.mailSelection);
|
|
|
|
if (articleIds.length === 0) {
|
|
toast.info("Keine Mail-Sessions ausgewählt");
|
|
return;
|
|
}
|
|
|
|
if (!zammadSyncPageState.mailPreviewJobId) {
|
|
toast.error("Keine Mail-Vorschau im Zwischenspeicher");
|
|
return;
|
|
}
|
|
|
|
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: null, mailRunningJobKind: null });
|
|
try {
|
|
const { result } = await applyZammadMailSessionsFromPreview(zammadSyncPageState.mailPreviewJobId, articleIds, (job: ZammadSyncJob) => {
|
|
setZammadSyncPageState({ syncingMail: true, mailRunningJobId: job.id, mailRunningJobKind: "apply" });
|
|
});
|
|
setZammadSyncPageState({
|
|
mailResult: null,
|
|
mailPreviewJobId: null,
|
|
mailSelection: {},
|
|
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 });
|
|
}
|
|
}
|
|
|
|
function toggleExisting(ticketNumber: string, checked: boolean) {
|
|
setZammadSyncPageState((current) => ({
|
|
existingSelection: { ...current.existingSelection, [ticketNumber]: checked }
|
|
}));
|
|
}
|
|
|
|
function toggleExistingAll(checked: boolean) {
|
|
const currentResult = applyFieldsToExistingPreview(zammadSyncPageState.existingResult, zammadSyncPageState.fields);
|
|
|
|
if (!currentResult) {
|
|
return;
|
|
}
|
|
|
|
setZammadSyncPageState({
|
|
existingSelection: Object.fromEntries(selectableItems(currentResult).map((item) => [selectionKey(item), checked]))
|
|
});
|
|
}
|
|
|
|
function toggleImport(ticketNumber: string, checked: boolean) {
|
|
setZammadSyncPageState((current) => ({
|
|
importSelection: { ...current.importSelection, [ticketNumber]: checked }
|
|
}));
|
|
}
|
|
|
|
function toggleImportAll(checked: boolean) {
|
|
if (!zammadSyncPageState.importResult) {
|
|
return;
|
|
}
|
|
|
|
setZammadSyncPageState({
|
|
importSelection: Object.fromEntries(selectableItems(zammadSyncPageState.importResult).map((item) => [selectionKey(item), checked]))
|
|
});
|
|
}
|
|
|
|
function toggleMail(key: string, checked: boolean) {
|
|
setZammadSyncPageState((current) => ({
|
|
mailSelection: { ...current.mailSelection, [key]: checked }
|
|
}));
|
|
}
|
|
|
|
function toggleMailAll(checked: boolean) {
|
|
if (!zammadSyncPageState.mailResult) {
|
|
return;
|
|
}
|
|
|
|
setZammadSyncPageState({
|
|
mailSelection: Object.fromEntries(selectableItems(zammadSyncPageState.mailResult).map((item) => [selectionKey(item), checked]))
|
|
});
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Zammad-Sync</h2>
|
|
<p className="text-sm text-muted-foreground">Tickets und Consulting-Projekte aus Zammad in TicketTracker abgleichen.</p>
|
|
</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>
|
|
<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 zuerst vorbereitet und erst nach Auswahl bestätigt.</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]} 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>
|
|
{fields.budget ? (
|
|
<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">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}
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
{syncingExisting ? (
|
|
<Button type="button" variant="outline" onClick={resetRunningState}>
|
|
Laufstatus zurücksetzen
|
|
</Button>
|
|
) : null}
|
|
{displayedExistingResult?.mode === "preview" ? (
|
|
<Button type="button" disabled={!zammadReady || syncingExisting || selectedExistingCount === 0} onClick={() => void applyExistingSync()}>
|
|
<CheckCircle2 className="size-4" />
|
|
{syncingExisting ? "Synchronisiert..." : `Auswahl bestätigen (${selectedExistingCount})`}
|
|
</Button>
|
|
) : null}
|
|
<Button type="button" variant={displayedExistingResult?.mode === "preview" ? "outline" : "default"} disabled={!zammadReady || syncingExisting} onClick={() => void previewExistingSync()}>
|
|
<DatabaseZap className="size-4" />
|
|
{syncingExisting ? "Bereitet vor..." : "Vorschau erstellen"}
|
|
</Button>
|
|
</div>
|
|
{displayedExistingResult ? (
|
|
<ResultSummary
|
|
result={displayedExistingResult}
|
|
selection={displayedExistingResult.mode === "preview" ? existingSelection : undefined}
|
|
onToggle={toggleExisting}
|
|
onToggleAll={toggleExistingAll}
|
|
/>
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<CardHeader className="p-4">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<DownloadCloud className="size-5 text-muted-foreground" />
|
|
Meine Consulting-Tickets importieren
|
|
</CardTitle>
|
|
<CardDescription>Consulting-Tickets aus Zammad mit deiner Owner-ID werden erst nach Vorschau und Auswahl 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">
|
|
{importingConsulting ? (
|
|
<Button type="button" variant="outline" onClick={resetRunningState}>
|
|
Laufstatus zurücksetzen
|
|
</Button>
|
|
) : null}
|
|
{importResult?.mode === "preview" ? (
|
|
<Button type="button" disabled={!zammadReady || importingConsulting || selectedImportCount === 0} onClick={() => void applyConsultingImport()}>
|
|
<CheckCircle2 className="size-4" />
|
|
{importingConsulting ? "Importiert..." : `Auswahl bestätigen (${selectedImportCount})`}
|
|
</Button>
|
|
) : null}
|
|
<Button type="button" variant={importResult?.mode === "preview" ? "outline" : "default"} disabled={!zammadReady || importingConsulting} onClick={() => void previewConsultingImport()}>
|
|
<DownloadCloud className="size-4" />
|
|
{importingConsulting ? "Bereitet vor..." : "Vorschau erstellen"}
|
|
</Button>
|
|
</div>
|
|
{importResult ? (
|
|
<ResultSummary result={importResult} selection={importResult.mode === "preview" ? importSelection : undefined} onToggle={toggleImport} onToggleAll={toggleImportAll} />
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
<Card>
|
|
<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}
|
|
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}
|
|
onChange={(event) => setZammadSyncPageState({ mailPerPage: parseMailPerPage(event.currentTarget.value) })}
|
|
/>
|
|
</label>
|
|
</div>
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
{syncingMail ? (
|
|
<Button type="button" variant="outline" onClick={resetRunningState}>
|
|
Laufstatus zurücksetzen
|
|
</Button>
|
|
) : null}
|
|
{mailResult?.mode === "preview" ? (
|
|
<Button type="button" disabled={!zammadReady || syncingMail || selectedMailCount === 0} onClick={() => void applyMailSync()}>
|
|
<CheckCircle2 className="size-4" />
|
|
{syncingMail ? "Importiert..." : `Auswahl übernehmen (${selectedMailCount})`}
|
|
</Button>
|
|
) : null}
|
|
<Button type="button" variant={mailResult?.mode === "preview" ? "outline" : "default"} disabled={!zammadReady || syncingMail} onClick={() => void previewMailSync()}>
|
|
<MailPlus className="size-4" />
|
|
{syncingMail ? "Bereitet vor..." : "Vorschau erstellen"}
|
|
</Button>
|
|
</div>
|
|
{mailResult ? (
|
|
<ResultSummary result={mailResult} selection={mailResult.mode === "preview" ? mailSelection : undefined} onToggle={toggleMail} onToggleAll={toggleMailAll} />
|
|
) : null}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|