This commit is contained in:
michael committed 2026-09-29 07:46:21 +02:00
1 parent 2105a0c5f7
commit c89d4825e2
30 files changed
+5435 -627

No files matched your search

+409 -39
View File
@@ -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"> -&gt; </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"> -&gt; </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>
);
}