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 { 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
@@ -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" });
|
||||||
|
}
|
||||||
Reference in new issue
Block a user