1.2.0-rc1

This commit is contained in:
michael committed 2026-10-15 08:09:47 +02:00
1 parent 45ff46b7eb
commit fa9c122250
30 files changed
+6354 -989

No files matched your search

@@ -0,0 +1,330 @@
import { FormEvent, useEffect, useMemo, useRef, useState } from "react";
import { Building2, DatabaseZap, RefreshCcw, Save, Search } from "lucide-react";
import { toast } from "sonner";
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 { NativeSelect, NativeSelectOption } from "@/components/ui/native-select";
import { Pagination, PaginationContent, PaginationItem, PaginationNext, PaginationPrevious } from "@/components/ui/pagination";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { HelpLink } from "@/components/HelpLink";
import { getAdminOrganizations, getZammadSettings, saveZammadSettings, syncZammadOrganizations } from "../api";
import { formatDateTime } from "../format";
import type { Organization } from "../types";
type PageSize = 10 | 20 | 50 | 100 | "all";
const pageSizeOptions: PageSize[] = [10, 20, 50, 100, "all"];
function pageSizeFromValue(value: string): PageSize {
if (value === "all") {
return "all";
}
const parsed = Number(value);
return pageSizeOptions.includes(parsed as PageSize) ? parsed as PageSize : 20;
}
function emptyText(value: string | null | undefined) {
return value?.trim() || "leer";
}
export function AdminOrganizationsPage() {
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState<PageSize>(20);
const [search, setSearch] = useState("");
const [missingAssignment, setMissingAssignment] = useState(false);
const [zammadBaseUrl, setZammadBaseUrl] = useState("");
const [zammadApiKey, setZammadApiKey] = useState("");
const [hasZammadApiKey, setHasZammadApiKey] = useState(false);
const [loading, setLoading] = useState(false);
const [savingZammadSettings, setSavingZammadSettings] = useState(false);
const [syncingOrganizations, setSyncingOrganizations] = useState(false);
const loadRequestId = useRef(0);
const pageCount = pageSize === "all" ? 1 : Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, pageCount);
const rangeStart = total === 0 ? 0 : pageSize === "all" ? 1 : (safePage - 1) * pageSize + 1;
const rangeEnd = pageSize === "all" ? total : Math.min(safePage * pageSize, total);
const activeFilterCount = useMemo(
() => [search.trim(), missingAssignment].filter(Boolean).length,
[missingAssignment, search]
);
async function loadSettings() {
const result = await getZammadSettings();
setZammadBaseUrl(result.settings.baseUrl);
setHasZammadApiKey(result.settings.hasApiKey);
}
async function loadOrganizations() {
const requestId = loadRequestId.current + 1;
loadRequestId.current = requestId;
setLoading(true);
try {
const result = await getAdminOrganizations({
search,
page: safePage,
pageSize,
missingAssignment
});
if (requestId === loadRequestId.current) {
setOrganizations(result.organizations);
setTotal(result.total);
}
} catch (error) {
if (requestId === loadRequestId.current) {
toast.error("Organisationen konnten nicht geladen werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
}
} finally {
if (requestId === loadRequestId.current) {
setLoading(false);
}
}
}
useEffect(() => {
void loadSettings();
}, []);
useEffect(() => {
setPage(1);
}, [search, pageSize, missingAssignment]);
useEffect(() => {
void loadOrganizations();
}, [safePage, pageSize, search, missingAssignment]);
async function saveZammadAccess() {
setSavingZammadSettings(true);
try {
const result = await saveZammadSettings({
baseUrl: zammadBaseUrl,
apiKey: zammadApiKey.trim() || undefined
});
setZammadBaseUrl(result.settings.baseUrl);
setHasZammadApiKey(result.settings.hasApiKey);
setZammadApiKey("");
toast.success("Zammad-Zugang gespeichert", {
description: result.settings.hasApiKey ? "URL und API-Key sind hinterlegt." : "URL wurde gespeichert."
});
} catch (error) {
toast.error("Zammad-Zugang konnte nicht gespeichert werden", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSavingZammadSettings(false);
}
}
async function syncOrganizations(event: FormEvent) {
event.preventDefault();
setSyncingOrganizations(true);
try {
const result = await syncZammadOrganizations({
baseUrl: zammadBaseUrl,
apiKey: zammadApiKey.trim() || undefined
});
toast.success("Organisationen synchronisiert", {
description: `${result.attributeOptions} Attributoptionen und ${result.synced} Organisationen gespeichert, ${result.removed} Organisationen entfernt, ${result.unlinkedTickets + result.unlinkedSessions} Verknüpfung(en) gelöst.`
});
setHasZammadApiKey(true);
setZammadApiKey("");
await loadOrganizations();
} catch (error) {
toast.error("Zammad-Sync fehlgeschlagen", {
description: error instanceof Error ? error.message : "Unbekannter Fehler"
});
} finally {
setSyncingOrganizations(false);
}
}
return (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<h2 className="text-xl font-semibold tracking-normal sm:text-2xl">Organisationsabgleich</h2>
<p className="text-sm text-muted-foreground">Zammad-Organisationen synchronisieren, prüfen und nach fehlenden Zuordnungen filtern.</p>
</div>
<Badge variant={activeFilterCount > 0 ? "secondary" : "outline"}>{activeFilterCount} Filter aktiv</Badge>
</div>
<Card>
<CardHeader className="gap-3 p-4 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>Zammad-Sync</CardTitle>
<CardDescription>Zugang speichern und Organisationen aus Zammad in die lokale Datenbank übernehmen.</CardDescription>
</div>
<HelpLink anchor="organisationen" label="Hilfe zum Organisationsabgleich" />
</CardHeader>
<CardContent className="px-4 pb-4">
<form className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_minmax(220px,1fr)_auto_auto] lg:items-end" onSubmit={syncOrganizations}>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-base-url">Zammad URL</label>
<Input
id="zammad-base-url"
placeholder="https://zammad.example.de"
value={zammadBaseUrl}
onChange={(event) => setZammadBaseUrl(event.currentTarget.value)}
required
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium" htmlFor="zammad-api-key">API-Key</label>
<Input
id="zammad-api-key"
type="password"
placeholder={hasZammadApiKey ? "••••••••••••••••" : ""}
value={zammadApiKey}
onChange={(event) => setZammadApiKey(event.currentTarget.value)}
/>
</div>
<Button type="button" variant="secondary" disabled={savingZammadSettings} onClick={() => void saveZammadAccess()}>
<Save className="size-4" />
{savingZammadSettings ? "Speichert..." : "Zugang speichern"}
</Button>
<Button type="submit" disabled={syncingOrganizations}>
<DatabaseZap className="size-4" />
{syncingOrganizations ? "Synchronisiert..." : "Synchronisieren"}
</Button>
</form>
</CardContent>
</Card>
<Card>
<CardHeader className="gap-3 p-4 xl:flex-row xl:items-end xl:justify-between xl:space-y-0">
<div>
<CardTitle>Organisationen</CardTitle>
<CardDescription>{loading ? "Lädt..." : `${rangeStart}-${rangeEnd} von ${total} Organisation(en)`}</CardDescription>
</div>
<div className="grid gap-2 md:grid-cols-[minmax(220px,1fr)_auto_auto] md:items-end">
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground" htmlFor="organization-search">Suche</label>
<div className="relative">
<Search className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
<Input
id="organization-search"
className="pl-8"
placeholder="Name, ID, Zuordnung, Hotline..."
value={search}
onChange={(event) => setSearch(event.currentTarget.value)}
/>
</div>
</div>
<label className="flex h-9 items-center gap-2 rounded-md border bg-background px-3 text-sm">
<Checkbox checked={missingAssignment} onCheckedChange={(checked) => setMissingAssignment(checked === true)} />
Zuordnung fehlt
</label>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground" htmlFor="organization-page-size">Anzeigen</label>
<NativeSelect
id="organization-page-size"
className="w-28"
value={String(pageSize)}
onChange={(event) => setPageSize(pageSizeFromValue(event.currentTarget.value))}
>
{pageSizeOptions.map((option) => (
<NativeSelectOption key={String(option)} value={String(option)}>
{option === "all" ? "Alle" : option}
</NativeSelectOption>
))}
</NativeSelect>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 px-4 pb-4">
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="min-w-20">Zammad-ID</TableHead>
<TableHead className="min-w-[260px]">Organisation</TableHead>
<TableHead className="min-w-[180px]">Zugeordnet</TableHead>
<TableHead className="min-w-[160px]">Hotline</TableHead>
<TableHead className="min-w-[150px]">Synchronisiert</TableHead>
<TableHead className="min-w-[150px]">Geändert</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{organizations.map((organization) => (
<TableRow key={organization.zammad_id}>
<TableCell className="font-mono text-xs">{organization.zammad_id}</TableCell>
<TableCell>
<div className="flex min-w-0 items-center gap-2">
<Building2 className="size-4 shrink-0 text-muted-foreground" />
<span className="truncate font-medium">{organization.name}</span>
</div>
</TableCell>
<TableCell className={organization.customer_owner ? "" : "text-muted-foreground"}>{emptyText(organization.customer_owner)}</TableCell>
<TableCell className={organization.hotline_contract ? "" : "text-muted-foreground"}>{emptyText(organization.hotline_contract)}</TableCell>
<TableCell className="text-xs text-muted-foreground">{formatDateTime(organization.synced_at)}</TableCell>
<TableCell className="text-xs text-muted-foreground">{organization.updated_at ? formatDateTime(organization.updated_at) : "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{organizations.length === 0 ? (
<div className="grid place-items-center gap-2 px-4 py-10 text-center text-sm text-muted-foreground">
<Building2 className="size-8" />
Keine Organisationen für die aktuellen Filter.
</div>
) : null}
</div>
{pageSize !== "all" ? (
<Pagination className="justify-end">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
href="#"
text="Zurück"
aria-disabled={safePage <= 1}
className={safePage <= 1 ? "pointer-events-none opacity-50" : undefined}
onClick={(event) => {
event.preventDefault();
setPage((current) => Math.max(1, current - 1));
}}
/>
</PaginationItem>
<PaginationItem>
<span className="flex h-9 items-center px-3 text-sm text-muted-foreground">
Seite {safePage} von {pageCount}
</span>
</PaginationItem>
<PaginationItem>
<PaginationNext
href="#"
text="Weiter"
aria-disabled={safePage >= pageCount}
className={safePage >= pageCount ? "pointer-events-none opacity-50" : undefined}
onClick={(event) => {
event.preventDefault();
setPage((current) => Math.min(pageCount, current + 1));
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
) : null}
<Button type="button" variant="secondary" size="sm" disabled={loading} onClick={() => void loadOrganizations()}>
<RefreshCcw className="size-4" />
Aktualisieren
</Button>
</CardContent>
</Card>
</div>
);
}