1.2.0-rc1
This commit is contained in:
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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user