From d998f611e2e04a48367ae9dc9deff93d2ae659dc Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Thu, 3 Sep 2026 10:37:22 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Verwaltungsmen=C3=BC=20zu=203=20Sammels?= =?UTF-8?q?eiten=20umgebaut=20-=20Benutzer=20&=20Rollen,=20Automatisierung?= =?UTF-8?q?,=20Backup?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Menü unter "Verwaltung" wie gewünscht neu strukturiert: - "Benutzer & Rollen" (/settings/users-roles): Benutzerverwaltung und Rollen & Rechte als zwei Reiter auf einer Seite (vorher zwei getrennte Menüpunkte). - "Automatisierung" (/settings/automation): Webhooks, Wiederkehrende Aufgaben, Etikettenvorlagen und Social Media Links als Reiter gebündelt - dazu (eigene Ergänzung, da sonst zwei fast gleich benannte Menüpunkte "Automatisierung"/"Automatisierungen" nebeneinander gestanden hätten) auch die bestehenden Automatisierungsregeln als fünfter Reiter "Regeln". - "Backup" (/settings/backup): neuer voller Daten-Export/Import (Commit zuvor) plus der bisherige CSV-Import, beides auf einer Seite. Aktiver Reiter wird als ?tab=... in der URL gehalten statt nur im Client-State, damit z.B. "Speichern" im Etiketten-Designer wieder auf den richtigen Reiter zurückführt. Technisch: bestehende Seiteninhalte in Reiter-Komponenten extrahiert (PageHeader entfernt, Rest unverändert übernommen), Dialog-Unterkomponenten in die neuen Ordner verschoben (git mv, Historie bleibt erhalten). Die Etiketten-Editor-Routen (/settings/label-templates/new, /[id]) bleiben unverändert bestehen, nur der Rücksprung nach dem Speichern zeigt jetzt auf den neuen Reiter. settings/layout.tsx's Berechtigungsliste um label-templates:read, site-settings:read, backup:export/import ergänzt - schließt nebenbei eine bestehende Lücke (eine Rolle mit ausschließlich diesen Rechten wurde vorher faelschlich von der ganzen Verwaltungssektion ausgesperrt. Neuer Reiter-Baustein src/components/tab-switcher.tsx (generisch, nach Vorbild von ActiveHistoryTabs). sidebar.tsx's NavItem.permission unterstützt jetzt auch ein Array (irgendeine der Berechtigungen reicht), fuer die neuen gebündelten Menüpunkte. Lokal verifiziert: typecheck/lint/test/build fehlerfrei, Build zeigt alle neuen Routen korrekt, Live-Test im Browser (ohne Login) zeigt sauberen Redirect zu /login ohne Konsolenfehler. Co-Authored-By: Claude Sonnet 5 --- .../automation-rules-tab.tsx} | 15 +-- .../create-task-template-dialog.tsx | 0 .../create-webhook-dialog.tsx | 0 .../edit-task-template-dialog.tsx | 0 .../label-templates-tab.tsx} | 25 ++-- .../app/(app)/settings/automation/page.tsx | 63 +++++++++ .../social-media-tab.tsx} | 15 +-- .../task-templates-tab.tsx} | 30 ++--- .../page.tsx => automation/webhooks-tab.tsx} | 18 +-- .../settings/{import => backup}/page.tsx | 122 ++++++++++++++++-- .../designer/label-designer.tsx | 2 +- .../src/app/(app)/settings/layout.tsx | 4 + .../create-role-dialog.tsx | 0 .../create-user-dialog.tsx | 0 .../edit-role-dialog.tsx | 0 .../app/(app)/settings/users-roles/page.tsx | 53 ++++++++ .../permission-matrix.tsx | 0 .../page.tsx => users-roles/roles-tab.tsx} | 30 ++--- .../users-tab.spec.tsx} | 8 +- .../page.tsx => users-roles/users-tab.tsx} | 17 +-- ApiCore-Frontend/src/app/(app)/sidebar.tsx | 68 +++++----- .../src/components/tab-switcher.tsx | 34 +++++ ApiCore-Frontend/src/lib/backup/api.ts | 12 ++ ApiCore-Frontend/src/lib/backup/types.ts | 3 + 24 files changed, 371 insertions(+), 148 deletions(-) rename ApiCore-Frontend/src/app/(app)/settings/{automation-rules/page.tsx => automation/automation-rules-tab.tsx} (92%) rename ApiCore-Frontend/src/app/(app)/settings/{task-templates => automation}/create-task-template-dialog.tsx (100%) rename ApiCore-Frontend/src/app/(app)/settings/{webhooks => automation}/create-webhook-dialog.tsx (100%) rename ApiCore-Frontend/src/app/(app)/settings/{task-templates => automation}/edit-task-template-dialog.tsx (100%) rename ApiCore-Frontend/src/app/(app)/settings/{label-templates/page.tsx => automation/label-templates-tab.tsx} (83%) create mode 100644 ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx rename ApiCore-Frontend/src/app/(app)/settings/{website/page.tsx => automation/social-media-tab.tsx} (90%) rename ApiCore-Frontend/src/app/(app)/settings/{task-templates/page.tsx => automation/task-templates-tab.tsx} (87%) rename ApiCore-Frontend/src/app/(app)/settings/{webhooks/page.tsx => automation/webhooks-tab.tsx} (88%) rename ApiCore-Frontend/src/app/(app)/settings/{import => backup}/page.tsx (60%) rename ApiCore-Frontend/src/app/(app)/settings/{roles => users-roles}/create-role-dialog.tsx (100%) rename ApiCore-Frontend/src/app/(app)/settings/{users => users-roles}/create-user-dialog.tsx (100%) rename ApiCore-Frontend/src/app/(app)/settings/{roles => users-roles}/edit-role-dialog.tsx (100%) create mode 100644 ApiCore-Frontend/src/app/(app)/settings/users-roles/page.tsx rename ApiCore-Frontend/src/app/(app)/settings/{roles => users-roles}/permission-matrix.tsx (100%) rename ApiCore-Frontend/src/app/(app)/settings/{roles/page.tsx => users-roles/roles-tab.tsx} (91%) rename ApiCore-Frontend/src/app/(app)/settings/{users/page.spec.tsx => users-roles/users-tab.spec.tsx} (91%) rename ApiCore-Frontend/src/app/(app)/settings/{users/page.tsx => users-roles/users-tab.tsx} (93%) create mode 100644 ApiCore-Frontend/src/components/tab-switcher.tsx create mode 100644 ApiCore-Frontend/src/lib/backup/api.ts create mode 100644 ApiCore-Frontend/src/lib/backup/types.ts diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation-rules/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx similarity index 92% rename from ApiCore-Frontend/src/app/(app)/settings/automation-rules/page.tsx rename to ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx index 5b40a5a..3d42426 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/automation-rules/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/automation-rules-tab.tsx @@ -1,7 +1,6 @@ "use client"; import { useState } from "react"; -import { Zap } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { fetchAutomationRules, updateAutomationRule } from "@/lib/automation/api"; @@ -10,7 +9,6 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { PageHeader } from "@/components/page-header"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; const TRIGGER_LABELS: Record = { @@ -115,21 +113,14 @@ function RuleCard({ rule }: { rule: AutomationRule }) { ); } -export default function AutomationRulesPage() { +export function AutomationRulesTab() { const { data: rules, isLoading, isError } = useQuery({ queryKey: ["automation-rules"], queryFn: fetchAutomationRules, }); return ( -
- - +
{isLoading ?

Lädt…

: null} {isError ? (

@@ -142,6 +133,6 @@ export default function AutomationRulesPage() { ))}

-
+ ); } diff --git a/ApiCore-Frontend/src/app/(app)/settings/task-templates/create-task-template-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/create-task-template-dialog.tsx similarity index 100% rename from ApiCore-Frontend/src/app/(app)/settings/task-templates/create-task-template-dialog.tsx rename to ApiCore-Frontend/src/app/(app)/settings/automation/create-task-template-dialog.tsx diff --git a/ApiCore-Frontend/src/app/(app)/settings/webhooks/create-webhook-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/create-webhook-dialog.tsx similarity index 100% rename from ApiCore-Frontend/src/app/(app)/settings/webhooks/create-webhook-dialog.tsx rename to ApiCore-Frontend/src/app/(app)/settings/automation/create-webhook-dialog.tsx diff --git a/ApiCore-Frontend/src/app/(app)/settings/task-templates/edit-task-template-dialog.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/edit-task-template-dialog.tsx similarity index 100% rename from ApiCore-Frontend/src/app/(app)/settings/task-templates/edit-task-template-dialog.tsx rename to ApiCore-Frontend/src/app/(app)/settings/automation/edit-task-template-dialog.tsx diff --git a/ApiCore-Frontend/src/app/(app)/settings/label-templates/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/label-templates-tab.tsx similarity index 83% rename from ApiCore-Frontend/src/app/(app)/settings/label-templates/page.tsx rename to ApiCore-Frontend/src/app/(app)/settings/automation/label-templates-tab.tsx index 3e827a7..d633e66 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/label-templates/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/label-templates-tab.tsx @@ -7,11 +7,10 @@ import { toast } from "sonner"; import { deactivateLabelTemplate, fetchLabelTemplates } from "@/lib/label-templates/api"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; -export default function LabelTemplatesPage() { +export function LabelTemplatesTab() { const queryClient = useQueryClient(); const { data: templates, isLoading, isError } = useQuery({ queryKey: ["label-templates"], @@ -30,19 +29,13 @@ export default function LabelTemplatesPage() { const activeTemplates = (templates ?? []).filter((template) => template.active); return ( -
- }> - - Neue Etikettenvorlage - - } - /> +
+
+ +
{isLoading ?

Lädt…

: null} {isError ? ( @@ -96,6 +89,6 @@ export default function LabelTemplatesPage() { ))}
) : null} -
+ ); } diff --git a/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx new file mode 100644 index 0000000..1f2b17f --- /dev/null +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/page.tsx @@ -0,0 +1,63 @@ +"use client"; + +import { Suspense } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Zap } from "lucide-react"; +import { PageHeader } from "@/components/page-header"; +import { TabSwitcher } from "@/components/tab-switcher"; +import { WebhooksTab } from "./webhooks-tab"; +import { TaskTemplatesTab } from "./task-templates-tab"; +import { LabelTemplatesTab } from "./label-templates-tab"; +import { SocialMediaTab } from "./social-media-tab"; +import { AutomationRulesTab } from "./automation-rules-tab"; + +type Tab = "webhooks" | "task-templates" | "label-templates" | "social-media" | "rules"; + +const TABS: { value: Tab; label: string }[] = [ + { value: "webhooks", label: "Webhooks" }, + { value: "task-templates", label: "Wiederkehrende Aufgaben" }, + { value: "label-templates", label: "Etikettenvorlagen" }, + { value: "social-media", label: "Social Media" }, + { value: "rules", label: "Regeln" }, +]; + +function isTab(value: string | null): value is Tab { + return TABS.some((tab) => tab.value === value); +} + +function AutomationPageContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const tab: Tab = isTab(searchParams.get("tab")) ? (searchParams.get("tab") as Tab) : "webhooks"; + + function setTab(next: Tab) { + router.replace(`/settings/automation?tab=${next}`); + } + + return ( +
+ + + + + {tab === "webhooks" ? : null} + {tab === "task-templates" ? : null} + {tab === "label-templates" ? : null} + {tab === "social-media" ? : null} + {tab === "rules" ? : null} +
+ ); +} + +export default function AutomationPage() { + return ( + + + + ); +} diff --git a/ApiCore-Frontend/src/app/(app)/settings/website/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx similarity index 90% rename from ApiCore-Frontend/src/app/(app)/settings/website/page.tsx rename to ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx index 8837602..1a34a8d 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/website/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/social-media-tab.tsx @@ -1,7 +1,6 @@ "use client"; import { FormEvent, useState } from "react"; -import { Globe } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { fetchSiteSettings, updateSiteSettings } from "@/lib/site-settings/api"; @@ -9,7 +8,6 @@ import { SiteSettings } from "@/lib/site-settings/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; -import { PageHeader } from "@/components/page-header"; import { Card, CardContent } from "@/components/ui/card"; // Mounted only once `settings` has arrived, so its useState initial values @@ -85,21 +83,14 @@ function WebsiteSettingsForm({ settings }: { settings: SiteSettings }) { ); } -export default function WebsiteSettingsPage() { +export function SocialMediaTab() { const { data: settings, isLoading, isError } = useQuery({ queryKey: ["site-settings"], queryFn: fetchSiteSettings, }); return ( -
- - +
{isLoading ?

Lädt…

: null} {isError ? (

@@ -108,6 +99,6 @@ export default function WebsiteSettingsPage() { ) : null} {settings ? : null} -

+ ); } diff --git a/ApiCore-Frontend/src/app/(app)/settings/task-templates/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/task-templates-tab.tsx similarity index 87% rename from ApiCore-Frontend/src/app/(app)/settings/task-templates/page.tsx rename to ApiCore-Frontend/src/app/(app)/settings/automation/task-templates-tab.tsx index 485ac7e..b5ffd7e 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/task-templates/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/task-templates-tab.tsx @@ -8,7 +8,6 @@ import { deactivateTaskTemplate, fetchTaskTemplates } from "@/lib/task-templates import { TaskTemplate, TaskTemplateRecurrenceType } from "@/lib/task-templates/types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { PageHeader } from "@/components/page-header"; import { EmptyState } from "@/components/empty-state"; import { EntityCard } from "@/components/entity-card"; import { ActiveHistoryTabs, type EntityLifecycleTab } from "@/components/active-history-tabs"; @@ -35,7 +34,7 @@ function formatDate(value: string): string { return new Date(value).toLocaleDateString("de-DE"); } -export default function TaskTemplatesPage() { +export function TaskTemplatesTab() { const queryClient = useQueryClient(); const [tab, setTab] = useState("active"); const { data: templates, isLoading, isError } = useQuery({ @@ -57,21 +56,16 @@ export default function TaskTemplatesPage() { const visibleTemplates = tab === "active" ? activeTemplates : historyTemplates; return ( -
- } - /> - - +
+
+ + +
{isLoading ?

Lädt…

: null} {isError ? ( @@ -127,6 +121,6 @@ export default function TaskTemplatesPage() { ))}
) : null} -
+ ); } diff --git a/ApiCore-Frontend/src/app/(app)/settings/webhooks/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/automation/webhooks-tab.tsx similarity index 88% rename from ApiCore-Frontend/src/app/(app)/settings/webhooks/page.tsx rename to ApiCore-Frontend/src/app/(app)/settings/automation/webhooks-tab.tsx index 3b3c05c..f97df17 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/webhooks/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/automation/webhooks-tab.tsx @@ -1,12 +1,10 @@ "use client"; -import { Webhook } from "lucide-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { deleteWebhook, fetchWebhooks } from "@/lib/webhooks/api"; import { Badge } from "@/components/ui/badge"; import { ConfirmDialog } from "@/components/confirm-dialog"; -import { PageHeader } from "@/components/page-header"; import { Table, TableBody, @@ -17,7 +15,7 @@ import { } from "@/components/ui/table"; import { CreateWebhookDialog } from "./create-webhook-dialog"; -export default function WebhooksPage() { +export function WebhooksTab() { const queryClient = useQueryClient(); const { data: webhooks, isLoading, isError } = useQuery({ queryKey: ["webhooks"], @@ -34,14 +32,10 @@ export default function WebhooksPage() { }); return ( -
- } - /> +
+
+ +
{isLoading ?

Lädt…

: null} {isError ? ( @@ -101,6 +95,6 @@ export default function WebhooksPage() {
) : null} -
+ ); } diff --git a/ApiCore-Frontend/src/app/(app)/settings/import/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx similarity index 60% rename from ApiCore-Frontend/src/app/(app)/settings/import/page.tsx rename to ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx index 6fcd2f9..fc6fd7f 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/import/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/backup/page.tsx @@ -1,15 +1,17 @@ "use client"; import { ChangeEvent, useState } from "react"; -import { Upload } from "lucide-react"; +import { Database } from "lucide-react"; import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; +import { exportBackup, importBackup } from "@/lib/backup/api"; import { importCsv } from "@/lib/import/api"; import { ImportEntity, ImportReport, ImportRowStatus } from "@/lib/import/types"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { PageHeader } from "@/components/page-header"; +import { ConfirmDialog } from "@/components/confirm-dialog"; import { Select, SelectContent, @@ -27,6 +29,90 @@ import { TableRow, } from "@/components/ui/table"; +function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} + +function BackupSection() { + const [file, setFile] = useState(null); + + const exportMutation = useMutation({ + mutationFn: exportBackup, + onSuccess: ({ blob, filename }) => { + downloadBlob(blob, filename); + toast.success("Backup heruntergeladen"); + }, + onError: () => toast.error("Backup konnte nicht exportiert werden"), + }); + + const importMutation = useMutation({ + mutationFn: () => { + if (!file) { + throw new Error("Keine Datei ausgewählt"); + } + return importBackup(file); + }, + onSuccess: (summary) => { + const total = Object.values(summary.imported).reduce((sum, count) => sum + count, 0); + toast.success(`Backup eingespielt: ${total} Datensätze verarbeitet`); + setFile(null); + }, + onError: () => toast.error("Backup konnte nicht eingespielt werden"), + }); + + function handleFileChange(event: ChangeEvent) { + setFile(event.target.files?.[0] ?? null); + } + + return ( + + + Backup + + +

+ Sichert Bienenstände bis Etikettenvorlagen (Betriebsdaten) in einer JSON-Datei - + für Backups und zur Übertragung auf ein anderes Deployment. Benutzer, Rollen, + Audit-Log und hochgeladene Dokumente sind nicht enthalten. +

+ + +
+ + + importMutation.mutate()} + /> +
+
+
+ ); +} + const ENTITY_LABELS: Record = { apiaries: "Bienenstände", colonies: "Völker", @@ -53,7 +139,7 @@ const ROW_STATUS_LABELS: Record = { error: "Fehler", }; -export default function ImportPage() { +function CsvImportSection() { const [entity, setEntity] = useState("apiaries"); const [file, setFile] = useState(null); const [report, setReport] = useState(null); @@ -87,19 +173,15 @@ export default function ImportPage() { } return ( -
- - +
- Datei hochladen + CSV-Import +

+ Einzelne Stammdaten (kein Backup) aus einer CSV-Tabelle neu anlegen. +