Update
This commit is contained in:
1 parent
07ef8ef025
commit
87d6c9b3ce
10 files changed
+2925
-112
No files matched your search
@@ -0,0 +1,935 @@
|
||||
import { useMemo, useSyncExternalStore } from "react";
|
||||
import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, 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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
applyExistingZammadTicketsFromPreview,
|
||||
applyOwnZammadConsultingTicketsFromPreview,
|
||||
previewExistingZammadTickets,
|
||||
previewOwnZammadConsultingTickets,
|
||||
type ZammadSyncExistingFields,
|
||||
type ZammadSyncChange,
|
||||
type ZammadSyncItem,
|
||||
type ZammadSyncResult
|
||||
} from "../api";
|
||||
import type { AuthUser } from "../types";
|
||||
|
||||
type ZammadSyncPageProps = {
|
||||
currentUser: AuthUser;
|
||||
};
|
||||
|
||||
type SelectionMap = Record<string, boolean>;
|
||||
|
||||
const defaultFields: ZammadSyncExistingFields = {
|
||||
title: true,
|
||||
organization: true,
|
||||
type: true,
|
||||
projectName: true,
|
||||
budget: true
|
||||
};
|
||||
|
||||
const fieldOptions: Array<{ key: keyof ZammadSyncExistingFields; 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." }
|
||||
];
|
||||
|
||||
type ZammadSyncPageState = {
|
||||
fields: ZammadSyncExistingFields;
|
||||
existingResult: ZammadSyncResult | null;
|
||||
existingPreviewJobId: string | null;
|
||||
importResult: ZammadSyncResult | null;
|
||||
importPreviewJobId: string | null;
|
||||
existingSelection: SelectionMap;
|
||||
importSelection: SelectionMap;
|
||||
syncingExisting: boolean;
|
||||
importingConsulting: boolean;
|
||||
};
|
||||
|
||||
let zammadSyncPageState: ZammadSyncPageState = {
|
||||
fields: defaultFields,
|
||||
existingResult: null,
|
||||
existingPreviewJobId: null,
|
||||
importResult: null,
|
||||
importPreviewJobId: null,
|
||||
existingSelection: {},
|
||||
importSelection: {},
|
||||
syncingExisting: false,
|
||||
importingConsulting: false
|
||||
};
|
||||
|
||||
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)
|
||||
};
|
||||
zammadSyncPageListeners.forEach((listener) => listener());
|
||||
}
|
||||
|
||||
function useZammadSyncPageState() {
|
||||
return useSyncExternalStore(subscribeZammadSyncPageState, getZammadSyncPageState, getZammadSyncPageState);
|
||||
}
|
||||
|
||||
function selectableItems(result: ZammadSyncResult) {
|
||||
return result.items.filter((item) => item.action === "update" || item.action === "create");
|
||||
}
|
||||
|
||||
function itemIsSelected(item: ZammadSyncItem, selection: SelectionMap) {
|
||||
return (item.action === "update" || item.action === "create") && selection[item.ticketNumber] !== false;
|
||||
}
|
||||
|
||||
function selectionFromResult(result: ZammadSyncResult): SelectionMap {
|
||||
return Object.fromEntries(selectableItems(result).map((item) => [item.ticketNumber, true]));
|
||||
}
|
||||
|
||||
function selectedTicketNumbers(result: ZammadSyncResult | null, selection: SelectionMap) {
|
||||
if (!result) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return selectableItems(result)
|
||||
.filter((item) => itemIsSelected(item, selection))
|
||||
.map((item) => item.ticketNumber);
|
||||
}
|
||||
|
||||
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 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 === 1) {
|
||||
return "1 (Niedrig)";
|
||||
}
|
||||
|
||||
if (priorityId === 2) {
|
||||
return "2 (Normal)";
|
||||
}
|
||||
|
||||
if (priorityId === 3) {
|
||||
return "3 (Hoch)";
|
||||
}
|
||||
|
||||
return formatValue(value);
|
||||
}
|
||||
|
||||
function formatZammadState(value: unknown) {
|
||||
const stateId = numericValue(value);
|
||||
|
||||
if (stateId === 2) {
|
||||
return "2 (offen)";
|
||||
}
|
||||
|
||||
if (stateId === 3) {
|
||||
return "3 (warten auf Erinnerung)";
|
||||
}
|
||||
|
||||
if (stateId === 4) {
|
||||
return "4 (geschlossen)";
|
||||
}
|
||||
|
||||
if (stateId === 7) {
|
||||
return "7 (warten auf schließen)";
|
||||
}
|
||||
|
||||
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 === "Teamspace-Budget") {
|
||||
return formatMinutes(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-"));
|
||||
}
|
||||
|
||||
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-Budget") {
|
||||
return fields.budget && consultingProjectCanBeTouched;
|
||||
}
|
||||
|
||||
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 comparablePreviewValue(value: unknown) {
|
||||
if (typeof value === "string" && value.trim().length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
function cachedValueChanged(left: unknown, right: unknown) {
|
||||
return JSON.stringify(comparablePreviewValue(left)) !== JSON.stringify(comparablePreviewValue(right));
|
||||
}
|
||||
|
||||
function addCachedChange(changes: ZammadSyncChange[], field: string, from: unknown, to: unknown) {
|
||||
if (cachedValueChanged(from, to)) {
|
||||
changes.push({ field, from: comparablePreviewValue(from), to: comparablePreviewValue(to) });
|
||||
}
|
||||
}
|
||||
|
||||
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 changes: ZammadSyncChange[] = [];
|
||||
|
||||
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) {
|
||||
addCachedChange(changes, "Typ", localWorkType, zammadWorkType);
|
||||
}
|
||||
|
||||
if (fields.type && zammadWorkType === "support" && project) {
|
||||
changes.push({ field: "Projekt/Teamspace-Daten", from: "vorhanden", to: "werden gelöscht" });
|
||||
}
|
||||
|
||||
const consultingProjectCanBeTouched = zammadWorkType === "consulting" && (fields.type || localWorkType === "consulting");
|
||||
|
||||
if (consultingProjectCanBeTouched) {
|
||||
if (fields.type && !project) {
|
||||
changes.push({ field: "Projekt", from: null, to: "wird angelegt" });
|
||||
}
|
||||
|
||||
if (fields.projectName) {
|
||||
addCachedChange(changes, "Teamspace-Projekt", cachedValue(project, "teamspaceProject"), cachedValue(zammad, "teamspaceProject"));
|
||||
}
|
||||
|
||||
if (fields.budget) {
|
||||
addCachedChange(changes, "Teamspace-Budget", 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 });
|
||||
}
|
||||
}
|
||||
|
||||
return changes;
|
||||
}
|
||||
|
||||
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"];
|
||||
}
|
||||
|
||||
return [
|
||||
`ID ${formatValue(zammad.ticketId)}`,
|
||||
`Gruppe ${formatZammadGroup(zammad.groupId)}`,
|
||||
`Typ ${formatWorkType(zammad.workType)}`,
|
||||
`Status ${formatZammadState(zammad.stateId)}`,
|
||||
`Priorität ${formatZammadPriority(zammad.priorityId)}`,
|
||||
`Owner ${formatValue(zammad.ownerId)}`,
|
||||
`Org ${formatValue(zammad.organizationId)}`,
|
||||
`Titel ${formatValue(zammad.title)}`,
|
||||
`Projekt ${formatValue(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 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>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
{selection ? (
|
||||
<TableHead className="w-10">
|
||||
<Checkbox checked={allSelected} disabled={selectable.length === 0} onCheckedChange={(checked) => onToggleAll?.(checked === true)} />
|
||||
</TableHead>
|
||||
) : null}
|
||||
<TableHead className="min-w-32">Ticket</TableHead>
|
||||
<TableHead className="min-w-32">Aktion</TableHead>
|
||||
<TableHead className="min-w-72">Daten aus Zammad</TableHead>
|
||||
<TableHead className="min-w-80">Änderungen in TicketTracker</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={selection ? 5 : 4} className="py-8 text-center text-sm text-muted-foreground">
|
||||
Keine Datensätze im Lauf.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
result.items.map((item) => {
|
||||
const action = formatAction(item);
|
||||
const isSelectable = item.action === "update" || item.action === "create";
|
||||
const visibleChanges = visibleTicketTrackerChanges(item);
|
||||
|
||||
return (
|
||||
<TableRow key={`${result.mode}-${item.ticketNumber}-${item.action}`}>
|
||||
{selection ? (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={itemIsSelected(item, selection)}
|
||||
disabled={!isSelectable}
|
||||
onCheckedChange={(checked) => onToggle?.(item.ticketNumber, checked === true)}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="font-medium">{item.ticketNumber}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<Badge variant={action.variant}>{action.label}</Badge>
|
||||
{item.reason ? <p className="text-xs text-muted-foreground">{item.reason}</p> : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-xl space-y-1 text-xs text-muted-foreground">
|
||||
{zammadDetails(item).map((detail) => (
|
||||
<div key={detail} className="break-words">
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{visibleChanges.length > 0 ? (
|
||||
<div className="space-y-1 text-xs">
|
||||
{visibleChanges.map((change) => (
|
||||
<div key={`${item.ticketNumber}-${change.field}`} className="break-words">
|
||||
<span className="font-medium">{change.field}:</span>{" "}
|
||||
<span className="text-muted-foreground">{formatChangeValue(change.field, change.from)}</span>
|
||||
<span className="text-muted-foreground"> -> </span>
|
||||
<span>{formatChangeValue(change.field, change.to)}</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 {
|
||||
fields,
|
||||
existingResult,
|
||||
importResult,
|
||||
existingSelection,
|
||||
importSelection,
|
||||
syncingExisting,
|
||||
importingConsulting
|
||||
} = useZammadSyncPageState();
|
||||
const displayedExistingResult = useMemo(() => applyFieldsToExistingPreview(existingResult, fields), [existingResult, fields]);
|
||||
const selectedExistingCount = useMemo(() => selectedTicketNumbers(displayedExistingResult, existingSelection).length, [displayedExistingResult, existingSelection]);
|
||||
const selectedImportCount = useMemo(() => selectedTicketNumbers(importResult, importSelection).length, [importResult, importSelection]);
|
||||
const zammadReady = currentUser.has_zammad_api_token && Boolean(currentUser.zammad_user_id);
|
||||
|
||||
function setField(key: keyof ZammadSyncExistingFields, checked: boolean) {
|
||||
setZammadSyncPageState((current) => ({
|
||||
fields: { ...current.fields, [key]: checked }
|
||||
}));
|
||||
}
|
||||
|
||||
function resetRunningState() {
|
||||
setZammadSyncPageState({
|
||||
syncingExisting: false,
|
||||
importingConsulting: false
|
||||
});
|
||||
toast.info("Laufstatus zurückgesetzt");
|
||||
}
|
||||
|
||||
async function previewExistingSync() {
|
||||
if (zammadSyncPageState.syncingExisting) {
|
||||
return;
|
||||
}
|
||||
|
||||
const syncFields = zammadSyncPageState.fields;
|
||||
setZammadSyncPageState({ syncingExisting: true });
|
||||
try {
|
||||
const { jobId, result } = await previewExistingZammadTickets(syncFields);
|
||||
setZammadSyncPageState({
|
||||
existingResult: result,
|
||||
existingPreviewJobId: jobId,
|
||||
existingSelection: selectionFromResult(result)
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
try {
|
||||
const { result } = await applyExistingZammadTicketsFromPreview(zammadSyncPageState.existingPreviewJobId, syncFields, ticketNumbers);
|
||||
setZammadSyncPageState({
|
||||
existingResult: result,
|
||||
existingPreviewJobId: null,
|
||||
existingSelection: {}
|
||||
});
|
||||
toast.success("Auswahl synchronisiert", {
|
||||
description: `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.`
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Sync fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setZammadSyncPageState({ syncingExisting: false });
|
||||
}
|
||||
}
|
||||
|
||||
async function previewConsultingImport() {
|
||||
if (zammadSyncPageState.importingConsulting) {
|
||||
return;
|
||||
}
|
||||
|
||||
setZammadSyncPageState({ importingConsulting: true });
|
||||
try {
|
||||
const { jobId, result } = await previewOwnZammadConsultingTickets();
|
||||
setZammadSyncPageState({
|
||||
importResult: result,
|
||||
importPreviewJobId: jobId,
|
||||
importSelection: selectionFromResult(result)
|
||||
});
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
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 });
|
||||
try {
|
||||
const { result } = await applyOwnZammadConsultingTicketsFromPreview(zammadSyncPageState.importPreviewJobId, ticketNumbers);
|
||||
setZammadSyncPageState({
|
||||
importResult: result,
|
||||
importPreviewJobId: null,
|
||||
importSelection: {}
|
||||
});
|
||||
toast.success("Auswahl importiert", {
|
||||
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.`
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Import fehlgeschlagen", {
|
||||
description: error instanceof Error ? error.message : "Unbekannter Fehler"
|
||||
});
|
||||
} finally {
|
||||
setZammadSyncPageState({ importingConsulting: false });
|
||||
}
|
||||
}
|
||||
|
||||
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) => [item.ticketNumber, 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) => [item.ticketNumber, 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>
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user