feat(import): bulk tool import (CSV/JSON/YAML) for admins; NetBox-style help button
Build & Push Docker Image / build (push) Successful in 2m49s

- Add POST /admin/tools/import with format auto-detect, CSV delimiters
  (comma/semicolon/tab), per-row validation via CreateToolBody, bulk insert,
  audit log entries per imported tool; gated by new 'tool-import' feature
  flag (premium/enterprise; admins always pass)
- Add tool-import-dialog UI (format tabs, delimiter select, textarea, file
  upload, result/error list) behind hasFeature('tool-import')
- Replace FieldHelp question marks and bare GuideHelp links with a NetBox-style
  'Hilfe/Help' outline button (HelpCircle + text) in form headers only
- Sync locales to 482 keys per language (de/en), update handbook docs
  (administration import section, index/plaene feature tables), regenerate
  API client + zod schemas, add yaml dependency
This commit is contained in:
opencode
2026-08-04 23:09:11 +02:00
parent d47db6d386
commit 8f2fd89847
38 changed files with 870 additions and 159 deletions
@@ -15,7 +15,6 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/hooks/use-auth";
import { Button } from "@/components/ui/button";
import { GuideHelp } from "@/components/guide-help";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
@@ -24,12 +23,13 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import { useToast } from "@/hooks/use-toast";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog";
import { Search, Trash2, ExternalLink, Pencil, Star } from "lucide-react";
import { ToolImportDialog } from "@/components/tool-import-dialog";
import { Search, Trash2, ExternalLink, Pencil, Star, Upload } from "lucide-react";
import { format } from "date-fns";
export function AdminToolsTab() {
const { t } = useTranslation();
const { isAdmin, isLoading: authLoading } = useAuth();
const { isAdmin, hasFeature, isLoading: authLoading } = useAuth();
const { toast } = useToast();
const queryClient = useQueryClient();
@@ -37,6 +37,7 @@ export function AdminToolsTab() {
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<number>>(new Set());
const [confirmTrash, setConfirmTrash] = useState(false);
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
const t = setTimeout(() => setSearch(searchInput), 300);
@@ -106,15 +107,22 @@ export function AdminToolsTab() {
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-4 flex-wrap">
<div className="space-y-1.5">
<CardTitle className="flex items-center gap-2">
{t("adminTools.allTools")}
<GuideHelp guide="administration" label={t("adminTools.allTools")} />
</CardTitle>
<CardTitle>{t("adminTools.allTools")}</CardTitle>
<CardDescription>
{selectedIds.length > 0 ? t("adminTools.selected", { count: selectedIds.length }) : t("adminTools.toolCount", { count: allTools.length })}
</CardDescription>
</div>
<div className="flex items-center gap-2 flex-wrap">
{hasFeature("tool-import") && (
<Button
size="sm"
variant="outline"
onClick={() => setImportOpen(true)}
data-testid="button-tool-import"
>
<Upload className="w-4 h-4 mr-2" /> {t("adminTools.importButton")}
</Button>
)}
<div className="relative">
<Search className="w-4 h-4 absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
<Input
@@ -233,6 +241,15 @@ export function AdminToolsTab() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ToolImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImported={() => {
toast({ title: t("adminTools.toastImportDone") });
invalidate();
}}
/>
</Card>
);
}
@@ -1,32 +0,0 @@
import { HelpCircle } from "lucide-react";
import { Link } from "wouter";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function FieldHelp({
schema,
field,
children,
}: {
schema: string;
field: string;
children?: React.ReactNode;
}) {
const label = children ?? field;
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/docs/reference/schemas/${schema}#${field}`}
target="_blank"
rel="noreferrer"
aria-label={`Help: ${label}`}
data-testid={`help-${schema}-${field}`}
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<HelpCircle className="h-3.5 w-3.5" />
</Link>
</TooltipTrigger>
<TooltipContent>{label} Details in der Dokumentation</TooltipContent>
</Tooltip>
);
}
@@ -1,5 +1,7 @@
import { BookOpen } from "lucide-react";
import { HelpCircle } from "lucide-react";
import { Link } from "wouter";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export function GuideHelp({
@@ -9,21 +11,24 @@ export function GuideHelp({
guide: string;
label: string;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
href={`/docs/handbook/${guide}`}
target="_blank"
rel="noreferrer"
aria-label={`Help: ${label}`}
data-testid={`guide-${guide}`}
className="inline-flex shrink-0 text-muted-foreground hover:text-foreground transition-colors"
>
<BookOpen className="h-3.5 w-3.5" />
</Link>
<Button asChild variant="outline" size="sm" className="gap-1.5 text-muted-foreground">
<Link
href={`/docs/handbook/${guide}`}
target="_blank"
rel="noreferrer"
aria-label={`${t("common.help")}: ${label}`}
data-testid={`guide-${guide}`}
>
<HelpCircle className="h-4 w-4" />
{t("common.help")}
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>{label} Anleitung in der Dokumentation</TooltipContent>
<TooltipContent>{label} {t("common.guideTooltip")}</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,185 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useImportTools, type ToolImportResponse } from "@workspace/api-client-react";
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Upload, FileUp, CheckCircle2, AlertCircle } from "lucide-react";
type Format = "auto" | "csv" | "json" | "yaml";
type Delimiter = "auto" | "comma" | "semicolon" | "tab";
export function ToolImportDialog({
open,
onOpenChange,
onImported,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
onImported: () => void;
}) {
const { t } = useTranslation();
const importTools = useImportTools();
const [format, setFormat] = useState<Format>("auto");
const [delimiter, setDelimiter] = useState<Delimiter>("auto");
const [data, setData] = useState("");
const [result, setResult] = useState<ToolImportResponse | null>(null);
function handleFile(file: File) {
const reader = new FileReader();
reader.onload = () => {
setData(String(reader.result ?? ""));
};
reader.readAsText(file);
}
function handleImport() {
importTools.mutate(
{ data: { format, delimiter, data } },
{
onSuccess: (res) => {
setResult(res);
if (res.imported > 0) onImported();
},
},
);
}
function handleClose(open: boolean) {
if (!importTools.isPending) {
onOpenChange(open);
if (!open) {
setData("");
setResult(null);
setFormat("auto");
setDelimiter("auto");
}
}
}
const canSubmit = data.trim().length > 0 && !importTools.isPending;
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-4 h-4" />
{t("adminTools.importTitle")}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<p className="text-sm text-muted-foreground">{t("adminTools.importSub")}</p>
<Tabs value={format} onValueChange={(v) => setFormat(v as Format)}>
<TabsList className="w-full grid grid-cols-4">
<TabsTrigger value="auto">{t("adminTools.importFormatAuto")}</TabsTrigger>
<TabsTrigger value="csv">CSV</TabsTrigger>
<TabsTrigger value="json">JSON</TabsTrigger>
<TabsTrigger value="yaml">YAML</TabsTrigger>
</TabsList>
<TabsContent value="auto" className="text-xs text-muted-foreground pt-2">
{t("adminTools.importFormatAutoSub")}
</TabsContent>
<TabsContent value="csv" className="pt-2 space-y-3">
<div className="space-y-2">
<Label>{t("adminTools.importDelimiter")}</Label>
<Select value={delimiter} onValueChange={(v) => setDelimiter(v as Delimiter)}>
<SelectTrigger className="w-52">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">{t("adminTools.importDelimiterAuto")}</SelectItem>
<SelectItem value="comma">{t("adminTools.importDelimiterComma")}</SelectItem>
<SelectItem value="semicolon">{t("adminTools.importDelimiterSemicolon")}</SelectItem>
<SelectItem value="tab">{t("adminTools.importDelimiterTab")}</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-muted-foreground">{t("adminTools.importCsvSub")}</p>
</TabsContent>
<TabsContent value="json" className="pt-2 space-y-3">
<p className="text-xs text-muted-foreground">{t("adminTools.importJsonSub")}</p>
</TabsContent>
<TabsContent value="yaml" className="pt-2 space-y-3">
<p className="text-xs text-muted-foreground">{t("adminTools.importYamlSub")}</p>
</TabsContent>
</Tabs>
<div className="flex items-center gap-3">
<Button type="button" variant="outline" size="sm" className="gap-2" onClick={() => document.getElementById("tool-import-file")?.click()}>
<FileUp className="w-4 h-4" />
{t("adminTools.importFile")}
</Button>
<input
id="tool-import-file"
type="file"
accept=".csv,.json,.yaml,.yml,.txt,text/plain,application/json"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) handleFile(f);
e.target.value = "";
}}
/>
</div>
<Textarea
value={data}
onChange={(e) => setData(e.target.value)}
placeholder={t("adminTools.importTextareaPlaceholder")}
className="min-h-[220px] font-mono text-sm"
data-testid="input-tool-import-data"
/>
{importTools.isError && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
<span>{importTools.error?.data?.error ?? importTools.error?.message}</span>
</div>
)}
{result && (
<div className="space-y-2">
<div className="flex items-center gap-2 rounded-md border border-primary/20 bg-primary/5 px-3 py-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-primary shrink-0" />
<span>{t("adminTools.importDone", { count: result.imported, total: result.total })}</span>
</div>
{result.errors.length > 0 && (
<div className="space-y-1">
<p className="text-sm font-medium flex items-center gap-1.5">
<AlertCircle className="w-4 h-4 text-amber-500" />
{t("adminTools.importErrors", { count: result.errors.length })}
</p>
<div className="max-h-40 overflow-y-auto rounded-md border bg-muted/40 divide-y divide-border">
{result.errors.map((err, i) => (
<div key={i} className="flex items-start gap-2 px-3 py-1.5 text-xs">
<span className="font-mono text-muted-foreground shrink-0">
{t("adminTools.importErrorRow", { row: err.row })}:
</span>
<span className="text-foreground break-words">{err.error}</span>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleClose(false)} disabled={importTools.isPending}>
{t("common.cancel")}
</Button>
<Button onClick={handleImport} disabled={!canSubmit}>
{importTools.isPending ? t("adminTools.importing") : t("adminTools.importButton")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -164,11 +164,9 @@ export function UserMenu() {
<Dialog open={pwOpen} onOpenChange={setPwOpen}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{t("auth.changePassword")}
<GuideHelp guide="konto" label={t("auth.changePassword")} />
</DialogTitle>
<DialogHeader className="flex flex-row items-start justify-between gap-4 space-y-0">
<DialogTitle>{t("auth.changePassword")}</DialogTitle>
<GuideHelp guide="konto" label={t("auth.changePassword")} />
</DialogHeader>
{!user?.isLocal ? (
<div className="space-y-4 py-2">