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:
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 { 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<HTMLFormElement>) {
|
||||
@@ -95,14 +110,25 @@ export function ApiaryEditCard({ apiary }: { apiary: Apiary }) {
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? "Speichert…" : "Speichern"}
|
||||
</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
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={apiary.status !== "ACTIVE" || deactivateMutation.isPending}
|
||||
onClick={() => deactivateMutation.mutate()}
|
||||
disabled={reactivateMutation.isPending}
|
||||
onClick={() => reactivateMutation.mutate()}
|
||||
>
|
||||
Deaktivieren
|
||||
{reactivateMutation.isPending ? "Wird reaktiviert…" : "Reaktivieren"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</CardContent>
|
||||
|
||||
@@ -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 (
|
||||
<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 (
|
||||
<Button
|
||||
variant="outline"
|
||||
disabled={colony.status !== "ACTIVE" || mutation.isPending}
|
||||
type="button"
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => mutation.mutate()}
|
||||
>
|
||||
Auflösen
|
||||
{mutation.isPending ? "Wird reaktiviert…" : "Reaktivieren"}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -265,10 +303,16 @@ function DissolveButton({ colony }: { colony: Colony }) {
|
||||
export function ColonyActions({ colony }: { colony: Colony }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{colony.status === "ACTIVE" ? (
|
||||
<>
|
||||
<MoveDialog colony={colony} />
|
||||
<SplitDialog colony={colony} />
|
||||
<MergeDialog colony={colony} />
|
||||
<DissolveButton colony={colony} />
|
||||
</>
|
||||
) : (
|
||||
<ReactivateButton colony={colony} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
|
||||
@@ -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() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!user.isActive || deactivateMutation.isPending}
|
||||
onClick={() => deactivateMutation.mutate(user.id)}
|
||||
disabled={activateMutation.isPending}
|
||||
onClick={() => activateMutation.mutate(user.id)}
|
||||
>
|
||||
Deaktivieren
|
||||
Aktivieren
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
@@ -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() {
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => deleteMutation.mutate(webhook.id)}
|
||||
>
|
||||
Löschen
|
||||
</Button>
|
||||
<ConfirmDialog
|
||||
triggerLabel="Löschen"
|
||||
triggerSize="sm"
|
||||
triggerDisabled={deleteMutation.isPending}
|
||||
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.`}
|
||||
confirmLabel="Endgültig löschen"
|
||||
isPending={deleteMutation.isPending}
|
||||
onConfirm={() => deleteMutation.mutate(webhook.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -35,3 +35,7 @@ export function updateApiary(id: string, input: Partial<ApiaryInput>): Promise<A
|
||||
export function deactivateApiary(id: string): Promise<Apiary> {
|
||||
return apiFetch<Apiary>(`/apiaries/${id}/deactivate`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function reactivateApiary(id: string): Promise<Apiary> {
|
||||
return apiFetch<Apiary>(`/apiaries/${id}/reactivate`, { method: "POST" });
|
||||
}
|
||||
@@ -69,3 +69,7 @@ export function mergeColony(id: string, input: MergeColonyInput): Promise<Colony
|
||||
export function dissolveColony(id: string): Promise<Colony> {
|
||||
return apiFetch<Colony>(`/colonies/${id}/dissolve`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function reactivateColony(id: string): Promise<Colony> {
|
||||
return apiFetch<Colony>(`/colonies/${id}/reactivate`, { method: "POST" });
|
||||
}
|
||||
@@ -15,3 +15,7 @@ export function createUser(input: CreateUserInput): Promise<User> {
|
||||
export function deactivateUser(id: string): Promise<User> {
|
||||
return apiFetch<User>(`/users/${id}/deactivate`, { method: "POST" });
|
||||
}
|
||||
|
||||
export function activateUser(id: string): Promise<User> {
|
||||
return apiFetch<User>(`/users/${id}/activate`, { method: "POST" });
|
||||
}
|
||||
Reference in new issue
Block a user