From 6ab028d1be3c21e96115c8242f3310106713f8f1 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sun, 23 Aug 2026 11:50:45 +0200 Subject: [PATCH] feat(frontend): confirmation dialogs and undo for destructive-feeling actions Volk aufloesen, Bienenstand deaktivieren, Benutzer deaktivieren und Webhook loeschen liefen bisher ohne jede Rueckfrage direkt auf Klick. Neue wiederverwendbare ConfirmDialog-Komponente erzwingt jetzt eine bewusste Bestaetigung. Zusaetzlich zeigen Volk/Bienenstand/Benutzer nach dem Aufloesen/Deaktivieren einen Reaktivieren-Button (nutzt die neuen Backend-Endpunkte), da diese Aktionen nie ein echtes Loeschen sind und ein versehentlicher Klick bisher nicht rueckgaengig zu machen war. --- .../(app)/apiaries/[id]/apiary-edit-card.tsx | 44 +++++++--- .../(app)/colonies/[id]/colony-actions.tsx | 62 +++++++++++--- .../app/(app)/settings/users/page.spec.tsx | 1 + .../src/app/(app)/settings/users/page.tsx | 46 ++++++++--- .../src/app/(app)/settings/webhooks/page.tsx | 21 ++--- .../src/components/confirm-dialog.tsx | 81 +++++++++++++++++++ ApiCore-Frontend/src/lib/apiaries/api.ts | 4 + ApiCore-Frontend/src/lib/colonies/api.ts | 4 + ApiCore-Frontend/src/lib/users/api.ts | 4 + 9 files changed, 231 insertions(+), 36 deletions(-) create mode 100644 ApiCore-Frontend/src/components/confirm-dialog.tsx diff --git a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx index 3fd30e3..9394424 100644 --- a/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx +++ b/ApiCore-Frontend/src/app/(app)/apiaries/[id]/apiary-edit-card.tsx @@ -2,13 +2,15 @@ import { FormEvent, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { deactivateApiary, updateApiary } from "@/lib/apiaries/api"; +import { toast } from "sonner"; +import { deactivateApiary, reactivateApiary, updateApiary } from "@/lib/apiaries/api"; import { Apiary } from "@/lib/apiaries/types"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; +import { ConfirmDialog } from "@/components/confirm-dialog"; import { Card, CardContent, @@ -40,7 +42,20 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) { mutationFn: () => deactivateApiary(apiary.id), onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["apiaries"] }); + toast.success("Bienenstand deaktiviert", { + description: "Er kann jederzeit über „Reaktivieren“ wiederhergestellt werden.", + }); }, + onError: () => toast.error("Bienenstand konnte nicht deaktiviert werden"), + }); + + const reactivateMutation = useMutation({ + mutationFn: () => reactivateApiary(apiary.id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["apiaries"] }); + toast.success("Bienenstand reaktiviert"); + }, + onError: () => toast.error("Bienenstand konnte nicht reaktiviert werden"), }); function handleSubmit(event: FormEvent) { @@ -95,14 +110,25 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) { - + {apiary.status === "ACTIVE" ? ( + deactivateMutation.mutate()} + /> + ) : ( + + )} diff --git a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx index 7ebf7b9..5f305cc 100644 --- a/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx +++ b/ApiCore-Frontend/src/app/(app)/colonies/[id]/colony-actions.tsx @@ -2,13 +2,21 @@ import { FormEvent, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { dissolveColony, mergeColony, moveColony, splitColony } from "@/lib/colonies/api"; +import { toast } from "sonner"; +import { + dissolveColony, + mergeColony, + moveColony, + reactivateColony, + splitColony, +} from "@/lib/colonies/api"; import { Colony, ColonyType } from "@/lib/colonies/types"; import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchColonies } from "@/lib/colonies/api"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { ConfirmDialog } from "@/components/confirm-dialog"; import { Dialog, DialogContent, @@ -248,16 +256,46 @@ function DissolveButton({ colony }: { colony: Colony }) { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: () => dissolveColony(colony.id), - onSuccess: () => invalidateColony(queryClient, colony.id), + onSuccess: () => { + invalidateColony(queryClient, colony.id); + toast.success("Volk aufgelöst", { + description: "Das Volk kann jederzeit über „Reaktivieren“ wiederhergestellt werden.", + }); + }, + onError: () => toast.error("Volk konnte nicht aufgelöst werden"), + }); + + return ( + mutation.mutate()} + /> + ); +} + +function ReactivateButton({ colony }: { colony: Colony }) { + const queryClient = useQueryClient(); + const mutation = useMutation({ + mutationFn: () => reactivateColony(colony.id), + onSuccess: () => { + invalidateColony(queryClient, colony.id); + toast.success("Volk reaktiviert"); + }, + onError: () => toast.error("Volk konnte nicht reaktiviert werden"), }); return ( ); } @@ -265,10 +303,16 @@ function DissolveButton({ colony }: { colony: Colony }) { export function ColonyActions({ colony }: { colony: Colony }) { return (
- - - - + {colony.status === "ACTIVE" ? ( + <> + + + + + + ) : ( + + )}
); } diff --git a/ApiCore-Frontend/src/app/(app)/settings/users/page.spec.tsx b/ApiCore-Frontend/src/app/(app)/settings/users/page.spec.tsx index f6d2472..4f7f43b 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users/page.spec.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users/page.spec.tsx @@ -8,6 +8,7 @@ import { deactivateUser, fetchUsers } from "@/lib/users/api"; vi.mock("@/lib/users/api", () => ({ fetchUsers: vi.fn(), deactivateUser: vi.fn(), + activateUser: vi.fn(), createUser: vi.fn(), })); diff --git a/ApiCore-Frontend/src/app/(app)/settings/users/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/users/page.tsx index ccf2524..5efc911 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/users/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/users/page.tsx @@ -1,9 +1,11 @@ "use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { deactivateUser, fetchUsers } from "@/lib/users/api"; +import { toast } from "sonner"; +import { activateUser, deactivateUser, fetchUsers } from "@/lib/users/api"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; +import { ConfirmDialog } from "@/components/confirm-dialog"; import { Table, TableBody, @@ -25,7 +27,20 @@ export default function UsersPage() { mutationFn: deactivateUser, onSuccess: () => { void queryClient.invalidateQueries({ queryKey: ["users"] }); + toast.success("Benutzer deaktiviert", { + description: "Kann jederzeit über „Aktivieren“ wiederhergestellt werden.", + }); }, + onError: () => toast.error("Benutzer konnte nicht deaktiviert werden"), + }); + + const activateMutation = useMutation({ + mutationFn: activateUser, + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["users"] }); + toast.success("Benutzer aktiviert"); + }, + onError: () => toast.error("Benutzer konnte nicht aktiviert werden"), }); return ( @@ -65,14 +80,27 @@ export default function UsersPage() { - + {user.isActive ? ( + deactivateMutation.mutate(user.id)} + /> + ) : ( + + )} ))} diff --git a/ApiCore-Frontend/src/app/(app)/settings/webhooks/page.tsx b/ApiCore-Frontend/src/app/(app)/settings/webhooks/page.tsx index 7f5c7ba..a47d2d9 100644 --- a/ApiCore-Frontend/src/app/(app)/settings/webhooks/page.tsx +++ b/ApiCore-Frontend/src/app/(app)/settings/webhooks/page.tsx @@ -4,7 +4,7 @@ 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 { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/confirm-dialog"; import { Table, TableBody, @@ -28,6 +28,7 @@ export default function WebhooksPage() { void queryClient.invalidateQueries({ queryKey: ["webhooks"] }); toast.success("Webhook gelöscht"); }, + onError: () => toast.error("Webhook konnte nicht gelöscht werden"), }); return ( @@ -83,14 +84,16 @@ export default function WebhooksPage() { - + deleteMutation.mutate(webhook.id)} + /> ))} diff --git a/ApiCore-Frontend/src/components/confirm-dialog.tsx b/ApiCore-Frontend/src/components/confirm-dialog.tsx new file mode 100644 index 0000000..69348ce --- /dev/null +++ b/ApiCore-Frontend/src/components/confirm-dialog.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { useState } from "react"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import type { VariantProps } from "class-variance-authority"; + +interface ConfirmDialogProps { + triggerLabel: string; + triggerVariant?: VariantProps["variant"]; + triggerSize?: VariantProps["size"]; + triggerClassName?: string; + triggerDisabled?: boolean; + title: string; + description: string; + confirmLabel: string; + destructive?: boolean; + onConfirm: () => void; + isPending?: boolean; +} + +export function ConfirmDialog({ + triggerLabel, + triggerVariant = "outline", + triggerSize = "default", + triggerClassName, + triggerDisabled, + title, + description, + confirmLabel, + destructive = true, + onConfirm, + isPending, +}: ConfirmDialogProps) { + const [open, setOpen] = useState(false); + + return ( + + + + + {title} + {description} + + + + + + + + ); +} diff --git a/ApiCore-Frontend/src/lib/apiaries/api.ts b/ApiCore-Frontend/src/lib/apiaries/api.ts index 63f8bd4..95f3516 100644 --- a/ApiCore-Frontend/src/lib/apiaries/api.ts +++ b/ApiCore-Frontend/src/lib/apiaries/api.ts @@ -35,3 +35,7 @@ export function updateApiary(id: string, input: Partial): Promise { return apiFetch(`/apiaries/${id}/deactivate`, { method: "POST" }); } + +export function reactivateApiary(id: string): Promise { + return apiFetch(`/apiaries/${id}/reactivate`, { method: "POST" }); +} diff --git a/ApiCore-Frontend/src/lib/colonies/api.ts b/ApiCore-Frontend/src/lib/colonies/api.ts index 4129007..9847414 100644 --- a/ApiCore-Frontend/src/lib/colonies/api.ts +++ b/ApiCore-Frontend/src/lib/colonies/api.ts @@ -69,3 +69,7 @@ export function mergeColony(id: string, input: MergeColonyInput): Promise { return apiFetch(`/colonies/${id}/dissolve`, { method: "POST" }); } + +export function reactivateColony(id: string): Promise { + return apiFetch(`/colonies/${id}/reactivate`, { method: "POST" }); +} diff --git a/ApiCore-Frontend/src/lib/users/api.ts b/ApiCore-Frontend/src/lib/users/api.ts index 3b71b73..5bc6d86 100644 --- a/ApiCore-Frontend/src/lib/users/api.ts +++ b/ApiCore-Frontend/src/lib/users/api.ts @@ -15,3 +15,7 @@ export function createUser(input: CreateUserInput): Promise { export function deactivateUser(id: string): Promise { return apiFetch(`/users/${id}/deactivate`, { method: "POST" }); } + +export function activateUser(id: string): Promise { + return apiFetch(`/users/${id}/activate`, { method: "POST" }); +}