This commit is contained in:
michael committed 2026-10-08 09:03:15 +02:00
1 parent fc0dfd0556
commit 649dd6f028
20 files changed
+3725 -988

No files matched your search

+129 -23
View File
@@ -17,7 +17,9 @@ import {
previewExistingZammadTickets,
previewOwnZammadConsultingTickets,
previewZammadMailSessions,
syncTeamspaceProjectIds,
waitForZammadSyncJob,
type TeamspaceProjectIdSyncResult,
type ZammadSyncExistingFields,
type ZammadSyncChange,
type ZammadSyncJob,
@@ -40,7 +42,7 @@ const defaultFields: ZammadSyncExistingFields = {
organization: true,
type: true,
projectName: true,
budget: true,
budget: false,
overrideBudget: false,
pendingTodo: false
};
@@ -50,7 +52,6 @@ const fieldOptions: Array<{ key: Exclude<keyof ZammadSyncExistingFields, "overri
{ 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." }
];
@@ -429,10 +430,6 @@ function formatChangeValue(field: string, value: unknown) {
return formatDurationSeconds(value);
}
if (field === "Teamspace-Budget") {
return formatMinutes(value);
}
if (field === "Teamspace-Projekte" || field === "Teamspace-Prüfung" || field === "Merge-Projektdaten") {
return formatTeamspaceProjectList(value) || "-";
}
@@ -498,7 +495,7 @@ function changeAllowedByFields(item: ZammadSyncItem, change: ZammadSyncChange, f
}
if (change.field === "Teamspace-Budget") {
return fields.budget && consultingProjectCanBeTouched;
return false;
}
if (change.field === "ToDo") {
@@ -711,7 +708,6 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
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"));
@@ -753,10 +749,6 @@ function changesFromExistingPreviewCache(item: ZammadSyncItem, fields: ZammadSyn
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 });
}
@@ -920,8 +912,7 @@ function zammadDetails(item: ZammadSyncItem) {
`Owner ${formatValue(zammad.ownerId)}`,
`Org ${formatValue(zammad.organizationId)}`,
`Titel ${formatValue(zammad.title)}`,
`Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`,
`Budget ${formatMinutes(zammad.teamspaceBudgetMinutes)}`
`Projekt ${formatValue(formatTeamspaceProjectList(zammad.teamspaceProjects) || zammad.teamspaceProject)}`
];
}
@@ -1105,11 +1096,14 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
syncingMail
} = pageState;
const [storageReady, setStorageReady] = useState(false);
const [syncingTeamspaceProjectIds, setSyncingTeamspaceProjectIds] = useState(false);
const [teamspaceProjectIdResult, setTeamspaceProjectIdResult] = useState<TeamspaceProjectIdSyncResult | null>(null);
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);
const teamspaceReady = currentUser.has_teamspace_credentials && Boolean(currentUser.teamspace_user_id);
useEffect(() => {
setStorageReady(false);
@@ -1552,6 +1546,28 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
}
}
async function runTeamspaceProjectIdSync() {
if (syncingTeamspaceProjectIds) {
return;
}
setSyncingTeamspaceProjectIds(true);
try {
const { result } = await syncTeamspaceProjectIds();
setTeamspaceProjectIdResult(result);
toast.success("Teamspace-Projekt-IDs synchronisiert", {
description: `${result.updated} aktualisiert, ${result.alreadyCurrent} bereits aktuell, ${result.subentriesSynced} Unterposition(en), ${result.missing + result.ambiguous} ohne eindeutige Zuordnung.`
});
} catch (error) {
toast.error("Teamspace-Projekt-IDs konnten nicht synchronisiert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSyncingTeamspaceProjectIds(false);
}
}
function toggleExisting(ticketNumber: string, checked: boolean) {
setZammadSyncPageState((current) => ({
existingSelection: { ...current.existingSelection, [ticketNumber]: checked }
@@ -1617,6 +1633,105 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
</Alert>
) : null}
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2">
<DatabaseZap className="size-5 text-muted-foreground" />
Teamspace-Projekt-IDs
</CardTitle>
<CardDescription>Offene Consulting-Projekte gegen die Teamspace-API auflösen und API-ID, Gesamtbudget sowie Unterpositionen aus Teamspace speichern.</CardDescription>
</div>
<Badge variant={teamspaceReady ? "success" : "secondary"}>{teamspaceReady ? "bereit" : "nicht bereit"}</Badge>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
{!teamspaceReady ? (
<Alert variant="warning">
<AlertCircle className="size-4" />
<AlertTitle>Teamspace-Zugang fehlt</AlertTitle>
<AlertDescription>Bitte im Profil Geräte-ID und Gerätepasswort speichern, damit die Teamspace User-ID bekannt ist.</AlertDescription>
</Alert>
) : null}
<div className="flex justify-end">
<Button type="button" disabled={!teamspaceReady || syncingTeamspaceProjectIds} onClick={() => void runTeamspaceProjectIdSync()}>
<RefreshCw className="size-4" />
{syncingTeamspaceProjectIds ? "Synchronisiert..." : "Teamspace-IDs, Budgets und Positionen synchronisieren"}
</Button>
</div>
{teamspaceProjectIdResult ? (
<div className="space-y-3">
<div className="grid gap-2 sm:grid-cols-3 xl:grid-cols-9">
{[
["TT-Einträge", teamspaceProjectIdResult.checkedEntries],
["TS gelesen", teamspaceProjectIdResult.remoteProjectsScanned],
["TS Positionen gelesen", teamspaceProjectIdResult.remoteSubprojectsScanned],
["Treffer", teamspaceProjectIdResult.matched],
["IDs aktualisiert", teamspaceProjectIdResult.updated],
["Aktuell", teamspaceProjectIdResult.alreadyCurrent],
["Positionen", teamspaceProjectIdResult.subentriesSynced],
["Entfernt", teamspaceProjectIdResult.subentriesRemoved],
["Offen", teamspaceProjectIdResult.missing + teamspaceProjectIdResult.ambiguous]
].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>
{teamspaceProjectIdResult.duplicateRemoteProjectIds.length > 0 ? (
<Alert variant="warning">
<AlertCircle className="size-4" />
<AlertTitle>Nicht eindeutige Teamspace projectIds</AlertTitle>
<AlertDescription>{teamspaceProjectIdResult.duplicateRemoteProjectIds.slice(0, 8).join(", ")}</AlertDescription>
</Alert>
) : null}
<div className="max-h-80 overflow-auto rounded-md border">
<Table className="w-full table-fixed">
<colgroup>
<col className="w-36" />
<col className="w-44" />
<col className="w-28" />
<col className="w-32" />
<col />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>Ticket</TableHead>
<TableHead>Teamspace-ID</TableHead>
<TableHead>Status</TableHead>
<TableHead>API-ID</TableHead>
<TableHead>Hinweis</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teamspaceProjectIdResult.items.slice(0, 100).map((item) => (
<TableRow key={item.entryId}>
<TableCell className="truncate font-medium" title={item.ticketNumber}>{item.ticketNumber}</TableCell>
<TableCell className="truncate" title={item.teamspaceProject}>{item.teamspaceProject}</TableCell>
<TableCell>
<Badge variant={item.status === "updated" ? "success" : item.status === "current" ? "info" : "warning"}>
{item.status === "updated" ? "Aktualisiert" : item.status === "current" ? "Aktuell" : item.status === "missing" ? "Fehlt" : "Mehrdeutig"}
</Badge>
</TableCell>
<TableCell className="truncate" title={item.apiProjectId ?? ""}>{item.apiProjectId ?? "-"}</TableCell>
<TableCell className="truncate text-xs text-muted-foreground" title={item.message ?? item.apiProjectUrl ?? ""}>
{item.message ?? item.apiProjectUrl ?? "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{teamspaceProjectIdResult.items.length > 100 ? (
<p className="text-xs text-muted-foreground">Weitere {teamspaceProjectIdResult.items.length - 100} Einträge wurden synchronisiert und sind hier nicht gelistet.</p>
) : null}
</div>
) : null}
</CardContent>
</Card>
<Card>
<CardHeader className="p-4">
<div className="flex items-start justify-between gap-3">
@@ -1642,15 +1757,6 @@ export function ZammadSyncPage({ currentUser }: ZammadSyncPageProps) {
</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}>