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.
This commit is contained in:
Nick Szaunig committed 2026-08-23 11:50:45 +02:00
1 parent 57979f0371
commit 6ab028d1be
9 files changed
+218 -23

No files matched your search

@@ -2,13 +2,15 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query"; 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 { Apiary } from "@/lib/apiaries/types";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { import {
Card, Card,
CardContent, CardContent,
@@ -40,7 +42,20 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
mutationFn: () => deactivateApiary(apiary.id), mutationFn: () => deactivateApiary(apiary.id),
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["apiaries"] }); 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<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
@@ -95,14 +110,25 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
<Button type="submit" disabled={updateMutation.isPending}> <Button type="submit" disabled={updateMutation.isPending}>
{updateMutation.isPending ? "Speichert…" : "Speichern"} {updateMutation.isPending ? "Speichert…" : "Speichern"}
</Button> </Button>
{apiary.status === "ACTIVE" ? (
<ConfirmDialog
triggerLabel="Deaktivieren"
triggerDisabled={deactivateMutation.isPending}
title="Bienenstand deaktivieren?"
description={`„${apiary.name}“ wird deaktiviert. Die Historie bleibt erhalten und der Bienenstand kann jederzeit wieder reaktiviert werden.`}
confirmLabel="Deaktivieren"
isPending={deactivateMutation.isPending}
onConfirm={() => deactivateMutation.mutate()}
/>
) : (
<Button <Button
type="button" type="button"
variant="outline" disabled={reactivateMutation.isPending}
disabled={apiary.status !== "ACTIVE" || deactivateMutation.isPending} onClick={() => reactivateMutation.mutate()}
onClick={() => deactivateMutation.mutate()}
> >
Deaktivieren {reactivateMutation.isPending ? "Wird reaktiviert…" : "Reaktivieren"}
</Button> </Button>
)}
</div> </div>
</form> </form>
</CardContent> </CardContent>
@@ -2,13 +2,21 @@
import { FormEvent, useState } from "react"; import { FormEvent, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { Colony, ColonyType } from "@/lib/colonies/types";
import { fetchApiaries } from "@/lib/apiaries/api"; import { fetchApiaries } from "@/lib/apiaries/api";
import { fetchColonies } from "@/lib/colonies/api"; import { fetchColonies } from "@/lib/colonies/api";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -248,16 +256,46 @@ function DissolveButton({ colony }: { colony: Colony }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => dissolveColony(colony.id), 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 (
<ConfirmDialog
triggerLabel="Auflösen"
triggerDisabled={colony.status !== "ACTIVE" || mutation.isPending}
title="Volk auflösen?"
description={`„${colony.code}“ wird als aufgelöst markiert und aus den aktiven Völkern entfernt. Die Historie bleibt erhalten und das Volk kann jederzeit wieder reaktiviert werden.`}
confirmLabel="Volk auflösen"
isPending={mutation.isPending}
onConfirm={() => 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 ( return (
<Button <Button
variant="outline" type="button"
disabled={colony.status !== "ACTIVE" || mutation.isPending} disabled={mutation.isPending}
onClick={() => mutation.mutate()} onClick={() => mutation.mutate()}
> >
Auflösen {mutation.isPending ? "Wird reaktiviert…" : "Reaktivieren"}
</Button> </Button>
); );
} }
@@ -265,10 +303,16 @@ function DissolveButton({ colony }: { colony: Colony }) {
export function ColonyActions({ colony }: { colony: Colony }) { export function ColonyActions({ colony }: { colony: Colony }) {
return ( return (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{colony.status === "ACTIVE" ? (
<>
<MoveDialog colony={colony} /> <MoveDialog colony={colony} />
<SplitDialog colony={colony} /> <SplitDialog colony={colony} />
<MergeDialog colony={colony} /> <MergeDialog colony={colony} />
<DissolveButton colony={colony} /> <DissolveButton colony={colony} />
</>
) : (
<ReactivateButton colony={colony} />
)}
</div> </div>
); );
} }
@@ -8,6 +8,7 @@ import { deactivateUser, fetchUsers } from "@/lib/users/api";
vi.mock("@/lib/users/api", () => ({ vi.mock("@/lib/users/api", () => ({
fetchUsers: vi.fn(), fetchUsers: vi.fn(),
deactivateUser: vi.fn(), deactivateUser: vi.fn(),
activateUser: vi.fn(),
createUser: vi.fn(), createUser: vi.fn(),
})); }));
@@ -1,9 +1,11 @@
"use client"; "use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog";
import { import {
Table, Table,
TableBody, TableBody,
@@ -25,7 +27,20 @@ export default function UsersPage() {
mutationFn: deactivateUser, mutationFn: deactivateUser,
onSuccess: () => { onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ["users"] }); 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 ( return (
@@ -65,14 +80,27 @@ export default function UsersPage() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
{user.isActive ? (
<ConfirmDialog
triggerLabel="Deaktivieren"
triggerSize="sm"
triggerDisabled={deactivateMutation.isPending}
title="Benutzer deaktivieren?"
description={`„${user.name}“ kann sich danach nicht mehr anmelden. Der Zugang kann jederzeit über „Aktivieren“ wiederhergestellt werden.`}
confirmLabel="Deaktivieren"
isPending={deactivateMutation.isPending}
onConfirm={() => deactivateMutation.mutate(user.id)}
/>
) : (
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!user.isActive || deactivateMutation.isPending} disabled={activateMutation.isPending}
onClick={() => deactivateMutation.mutate(user.id)} onClick={() => activateMutation.mutate(user.id)}
> >
Deaktivieren Aktivieren
</Button> </Button>
)}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -4,7 +4,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { deleteWebhook, fetchWebhooks } from "@/lib/webhooks/api"; import { deleteWebhook, fetchWebhooks } from "@/lib/webhooks/api";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { import {
Table, Table,
TableBody, TableBody,
@@ -28,6 +28,7 @@ export default function WebhooksPage() {
void queryClient.invalidateQueries({ queryKey: ["webhooks"] }); void queryClient.invalidateQueries({ queryKey: ["webhooks"] });
toast.success("Webhook gelöscht"); toast.success("Webhook gelöscht");
}, },
onError: () => toast.error("Webhook konnte nicht gelöscht werden"),
}); });
return ( return (
@@ -83,14 +84,16 @@ export default function WebhooksPage() {
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <ConfirmDialog
variant="outline" triggerLabel="Löschen"
size="sm" triggerSize="sm"
disabled={deleteMutation.isPending} triggerDisabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate(webhook.id)} title="Webhook löschen?"
> description={`Der Webhook für „${webhook.url}“ wird endgültig gelöscht. Externe Systeme erhalten danach keine Ereignisse mehr über diese Verbindung, und der Vorgang kann nicht rückgängig gemacht werden.`}
Löschen confirmLabel="Endgültig löschen"
</Button> isPending={deleteMutation.isPending}
onConfirm={() => deleteMutation.mutate(webhook.id)}
/>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
@@ -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<typeof buttonVariants>["variant"];
triggerSize?: VariantProps<typeof buttonVariants>["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 (
<Dialog open={open} onOpenChange={setOpen}>
<Button
type="button"
variant={triggerVariant}
size={triggerSize}
className={cn(triggerClassName)}
disabled={triggerDisabled}
onClick={() => setOpen(true)}
>
{triggerLabel}
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Abbrechen
</Button>
<Button
type="button"
variant={destructive ? "destructive" : "default"}
disabled={isPending}
onClick={() => {
onConfirm();
setOpen(false);
}}
>
{isPending ? "Wird ausgeführt…" : confirmLabel}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+4
View File
@@ -35,3 +35,7 @@ export function updateApiary(id: string, input: Partial<ApiaryInput>): Promise<A
export function deactivateApiary(id: string): Promise<Apiary> { export function deactivateApiary(id: string): Promise<Apiary> {
return apiFetch<Apiary>(`/apiaries/${id}/deactivate`, { method: "POST" }); return apiFetch<Apiary>(`/apiaries/${id}/deactivate`, { method: "POST" });
} }
export function reactivateApiary(id: string): Promise<Apiary> {
return apiFetch<Apiary>(`/apiaries/${id}/reactivate`, { method: "POST" });
}
+4
View File
@@ -69,3 +69,7 @@ export function mergeColony(id: string, input: MergeColonyInput): Promise<Colony
export function dissolveColony(id: string): Promise<Colony> { export function dissolveColony(id: string): Promise<Colony> {
return apiFetch<Colony>(`/colonies/${id}/dissolve`, { method: "POST" }); return apiFetch<Colony>(`/colonies/${id}/dissolve`, { method: "POST" });
} }
export function reactivateColony(id: string): Promise<Colony> {
return apiFetch<Colony>(`/colonies/${id}/reactivate`, { method: "POST" });
}
+4
View File
@@ -15,3 +15,7 @@ export function createUser(input: CreateUserInput): Promise<User> {
export function deactivateUser(id: string): Promise<User> { export function deactivateUser(id: string): Promise<User> {
return apiFetch<User>(`/users/${id}/deactivate`, { method: "POST" }); return apiFetch<User>(`/users/${id}/deactivate`, { method: "POST" });
} }
export function activateUser(id: string): Promise<User> {
return apiFetch<User>(`/users/${id}/activate`, { method: "POST" });
}