331 lines
14 KiB
TypeScript
331 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|