Update
This commit is contained in:
1 parent
2105a0c5f7
commit
c89d4825e2
30 files changed
+5435
-627
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, RefreshCw } from "lucide-react";
|
||||
import { AlertCircle, CheckCircle2, DatabaseZap, DownloadCloud, MailPlus, RefreshCw } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
@@ -7,13 +7,16 @@ 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,
|
||||
@@ -61,10 +64,18 @@ type ZammadSyncPageState = {
|
||||
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 {
|
||||
@@ -78,10 +89,18 @@ function emptyZammadSyncPageState(): ZammadSyncPageState {
|
||||
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
|
||||
importingConsulting: false,
|
||||
syncingMail: false
|
||||
};
|
||||
}
|
||||
|
||||
@@ -162,6 +181,40 @@ 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));
|
||||
|
||||
@@ -180,6 +233,8 @@ function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
|
||||
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),
|
||||
@@ -191,10 +246,18 @@ function loadZammadSyncPageState(userId: string): ZammadSyncPageState | null {
|
||||
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)
|
||||
importingConsulting: Boolean(importRunningJobId && importRunningJobKind),
|
||||
syncingMail: Boolean(mailRunningJobId && mailRunningJobKind)
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
@@ -213,12 +276,17 @@ 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[item.ticketNumber] !== false;
|
||||
return (item.action === "update" || item.action === "create") && selection[selectionKey(item)] !== false;
|
||||
}
|
||||
|
||||
function selectionFromResult(result: ZammadSyncResult): SelectionMap {
|
||||
return Object.fromEntries(selectableItems(result).map((item) => [item.ticketNumber, true]));
|
||||
return Object.fromEntries(selectableItems(result).map((item) => [selectionKey(item), true]));
|
||||
}
|
||||
|
||||
function selectedTicketNumbers(result: ZammadSyncResult | null, selection: SelectionMap) {
|
||||
@@ -231,6 +299,25 @@ function selectedTicketNumbers(result: ZammadSyncResult | null, selection: Selec
|
||||
.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);
|
||||
@@ -243,6 +330,28 @@ function formatMinutes(value: unknown) {
|
||||
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;
|
||||
@@ -316,6 +425,10 @@ function formatValue(value: unknown): string {
|
||||
}
|
||||
|
||||
function formatChangeValue(field: string, value: unknown) {
|
||||
if (field === "Dauer") {
|
||||
return formatDurationSeconds(value);
|
||||
}
|
||||
|
||||
if (field === "Teamspace-Budget") {
|
||||
return formatMinutes(value);
|
||||
}
|
||||
@@ -725,6 +838,17 @@ function zammadDetails(item: ZammadSyncItem) {
|
||||
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)}`,
|
||||
@@ -795,18 +919,25 @@ function ResultSummary({
|
||||
) : null}
|
||||
|
||||
<div className="max-h-[520px] overflow-auto rounded-md border">
|
||||
<Table>
|
||||
<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 className="w-10">
|
||||
<TableHead>
|
||||
<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>
|
||||
<TableHead>Ticket</TableHead>
|
||||
<TableHead>Aktion</TableHead>
|
||||
<TableHead>Daten aus Zammad</TableHead>
|
||||
<TableHead>Änderungen in TicketTracker</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -823,27 +954,27 @@ function ResultSummary({
|
||||
const visibleChanges = visibleTicketTrackerChanges(item);
|
||||
|
||||
return (
|
||||
<TableRow key={`${result.mode}-${item.ticketNumber}-${item.action}`}>
|
||||
<TableRow key={`${result.mode}-${selectionKey(item)}-${item.action}`}>
|
||||
{selection ? (
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={itemIsSelected(item, selection)}
|
||||
disabled={!isSelectable}
|
||||
onCheckedChange={(checked) => onToggle?.(item.ticketNumber, checked === true)}
|
||||
onCheckedChange={(checked) => onToggle?.(selectionKey(item), checked === true)}
|
||||
/>
|
||||
</TableCell>
|
||||
) : null}
|
||||
<TableCell className="font-medium">{item.ticketNumber}</TableCell>
|
||||
<TableCell className="truncate font-medium" title={item.ticketNumber}>{item.ticketNumber}</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<div className="min-w-0 space-y-1">
|
||||
<Badge variant={action.variant}>{action.label}</Badge>
|
||||
{item.reason ? <p className="text-xs text-muted-foreground">{item.reason}</p> : null}
|
||||
{item.reason ? <p className="truncate text-xs text-muted-foreground" title={item.reason}>{item.reason}</p> : null}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-xl space-y-1 text-xs text-muted-foreground">
|
||||
<div className="min-w-0 space-y-1 text-xs text-muted-foreground">
|
||||
{zammadDetails(item).map((detail) => (
|
||||
<div key={detail} className="break-words">
|
||||
<div key={detail} className="truncate" title={detail}>
|
||||
{detail}
|
||||
</div>
|
||||
))}
|
||||
@@ -851,15 +982,21 @@ function ResultSummary({
|
||||
</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 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>
|
||||
@@ -894,15 +1031,23 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
importResult,
|
||||
importRunningJobId,
|
||||
importRunningJobKind,
|
||||
mailResult,
|
||||
mailRunningJobId,
|
||||
mailRunningJobKind,
|
||||
mailDaysBack,
|
||||
mailPerPage,
|
||||
existingSelection,
|
||||
importSelection,
|
||||
mailSelection,
|
||||
syncingExisting,
|
||||
importingConsulting
|
||||
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(() => {
|
||||
@@ -952,7 +1097,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}));
|
||||
} else {
|
||||
setZammadSyncPageState({
|
||||
existingResult: result,
|
||||
existingResult: null,
|
||||
existingPreviewJobId: null,
|
||||
existingSelection: {},
|
||||
existingRunningJobId: null,
|
||||
@@ -1005,7 +1150,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}));
|
||||
} else {
|
||||
setZammadSyncPageState({
|
||||
importResult: result,
|
||||
importResult: null,
|
||||
importPreviewJobId: null,
|
||||
importSelection: {},
|
||||
importRunningJobId: null,
|
||||
@@ -1034,6 +1179,59 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
};
|
||||
}, [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 } : {}) }
|
||||
@@ -1044,10 +1242,13 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
setZammadSyncPageState({
|
||||
syncingExisting: false,
|
||||
importingConsulting: false,
|
||||
syncingMail: false,
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null,
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null
|
||||
importRunningJobKind: null,
|
||||
mailRunningJobId: null,
|
||||
mailRunningJobKind: null
|
||||
});
|
||||
toast.info("Laufstatus zurückgesetzt");
|
||||
}
|
||||
@@ -1058,7 +1259,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}
|
||||
|
||||
const syncFields = zammadSyncPageState.fields;
|
||||
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: null, existingRunningJobKind: null });
|
||||
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" });
|
||||
@@ -1107,14 +1315,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
setZammadSyncPageState({ syncingExisting: true, existingRunningJobId: job.id, existingRunningJobKind: "apply" });
|
||||
});
|
||||
setZammadSyncPageState({
|
||||
existingResult: result,
|
||||
existingResult: null,
|
||||
existingPreviewJobId: null,
|
||||
existingSelection: {},
|
||||
existingRunningJobId: null,
|
||||
existingRunningJobKind: null
|
||||
});
|
||||
toast.success("Auswahl synchronisiert", {
|
||||
description: `${result.updated} aktualisiert, ${result.skipped} ohne Transfer.`
|
||||
description: existingApplyDescription(result)
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Sync fehlgeschlagen", {
|
||||
@@ -1130,7 +1338,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
return;
|
||||
}
|
||||
|
||||
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: null, importRunningJobKind: null });
|
||||
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" });
|
||||
@@ -1177,14 +1392,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
setZammadSyncPageState({ importingConsulting: true, importRunningJobId: job.id, importRunningJobKind: "apply" });
|
||||
});
|
||||
setZammadSyncPageState({
|
||||
importResult: result,
|
||||
importResult: null,
|
||||
importPreviewJobId: null,
|
||||
importSelection: {},
|
||||
importRunningJobId: null,
|
||||
importRunningJobKind: null
|
||||
});
|
||||
toast.success("Auswahl importiert", {
|
||||
description: `${result.created} angelegt, ${result.skipped} ohne Transfer.`
|
||||
description: importApplyDescription(result)
|
||||
});
|
||||
} catch (error) {
|
||||
toast.error("Zammad-Import fehlgeschlagen", {
|
||||
@@ -1195,6 +1410,87 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
@@ -1209,7 +1505,7 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}
|
||||
|
||||
setZammadSyncPageState({
|
||||
existingSelection: Object.fromEntries(selectableItems(currentResult).map((item) => [item.ticketNumber, checked]))
|
||||
existingSelection: Object.fromEntries(selectableItems(currentResult).map((item) => [selectionKey(item), checked]))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1225,7 +1521,23 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
}
|
||||
|
||||
setZammadSyncPageState({
|
||||
importSelection: Object.fromEntries(selectableItems(zammadSyncPageState.importResult).map((item) => [item.ticketNumber, checked]))
|
||||
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]))
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1340,6 +1652,64 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user