confirm actions
This commit is contained in:
@@ -216,7 +216,9 @@ export function AppShell() {
|
||||
<p className="text-xs text-muted">{user?.email}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
onClick={() => {
|
||||
void logout();
|
||||
}}
|
||||
className="mt-4 rounded-xl bg-text px-4 py-2 text-sm font-semibold text-page"
|
||||
>
|
||||
Sign out
|
||||
|
||||
107
client/src/components/ConfirmActionDialog.tsx
Normal file
107
client/src/components/ConfirmActionDialog.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ConfirmActionDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
description: string;
|
||||
impact?: string;
|
||||
recovery?: string;
|
||||
confirmLabel?: string;
|
||||
cancelLabel?: string;
|
||||
intent?: "danger" | "primary";
|
||||
confirmationLabel?: string;
|
||||
confirmationValue?: string;
|
||||
isConfirming?: boolean;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ConfirmActionDialog({
|
||||
open,
|
||||
title,
|
||||
description,
|
||||
impact,
|
||||
recovery,
|
||||
confirmLabel = "Confirm",
|
||||
cancelLabel = "Cancel",
|
||||
intent = "danger",
|
||||
confirmationLabel,
|
||||
confirmationValue,
|
||||
isConfirming = false,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: ConfirmActionDialogProps) {
|
||||
const [typedValue, setTypedValue] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setTypedValue("");
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requiresTypedConfirmation = Boolean(confirmationLabel && confirmationValue);
|
||||
const isConfirmDisabled = isConfirming || (requiresTypedConfirmation && typedValue.trim() !== confirmationValue);
|
||||
const confirmButtonClass =
|
||||
intent === "danger"
|
||||
? "bg-red-600 text-white hover:bg-red-700"
|
||||
: "bg-brand text-white hover:brightness-110";
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/55 px-4 py-6">
|
||||
<div className="w-full max-w-xl rounded-[28px] border border-line/70 bg-surface p-5 shadow-panel">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted">Confirm Action</p>
|
||||
<h3 className="mt-2 text-lg font-bold text-text">{title}</h3>
|
||||
<p className="mt-3 text-sm leading-6 text-text">{description}</p>
|
||||
{impact ? (
|
||||
<div className="mt-4 rounded-2xl border border-red-300/50 bg-red-50 px-3 py-3 text-sm text-red-800">
|
||||
<span className="block text-xs font-semibold uppercase tracking-[0.18em]">Impact</span>
|
||||
<span className="mt-1 block">{impact}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{recovery ? (
|
||||
<div className="mt-3 rounded-2xl border border-line/70 bg-page/70 px-3 py-3 text-sm text-muted">
|
||||
<span className="block text-xs font-semibold uppercase tracking-[0.18em] text-text">Recovery</span>
|
||||
<span className="mt-1 block">{recovery}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{requiresTypedConfirmation ? (
|
||||
<label className="mt-4 block">
|
||||
<span className="mb-2 block text-sm font-semibold text-text">
|
||||
{confirmationLabel} <span className="font-mono">{confirmationValue}</span>
|
||||
</span>
|
||||
<input
|
||||
value={typedValue}
|
||||
onChange={(event) => setTypedValue(event.target.value)}
|
||||
className="w-full rounded-2xl border border-line/70 bg-page px-3 py-2 text-text outline-none"
|
||||
autoFocus
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<div className="mt-5 flex flex-col-reverse gap-3 sm:flex-row sm:justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isConfirming}
|
||||
className="rounded-2xl border border-line/70 px-4 py-2 text-sm font-semibold text-text disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{cancelLabel}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
void onConfirm();
|
||||
}}
|
||||
disabled={isConfirmDisabled}
|
||||
className={`rounded-2xl px-4 py-2 text-sm font-semibold disabled:cursor-not-allowed disabled:opacity-60 ${confirmButtonClass}`}
|
||||
>
|
||||
{isConfirming ? "Working..." : confirmLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
|
||||
|
||||
import { useAuth } from "../auth/AuthProvider";
|
||||
import { api, ApiError } from "../lib/api";
|
||||
import { ConfirmActionDialog } from "./ConfirmActionDialog";
|
||||
|
||||
interface FileAttachmentsPanelProps {
|
||||
ownerType: string;
|
||||
@@ -41,6 +42,7 @@ export function FileAttachmentsPanel({
|
||||
const [status, setStatus] = useState("Loading attachments...");
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [deletingAttachmentId, setDeletingAttachmentId] = useState<string | null>(null);
|
||||
const [attachmentPendingDelete, setAttachmentPendingDelete] = useState<FileAttachmentDto | null>(null);
|
||||
|
||||
const canReadFiles = user?.permissions.includes(permissions.filesRead) ?? false;
|
||||
const canWriteFiles = user?.permissions.includes(permissions.filesWrite) ?? false;
|
||||
@@ -120,12 +122,13 @@ export function FileAttachmentsPanel({
|
||||
onAttachmentCountChange?.(nextAttachments.length);
|
||||
return nextAttachments;
|
||||
});
|
||||
setStatus("Attachment deleted.");
|
||||
setStatus("Attachment deleted. Upload a replacement file if this document is still required for the record.");
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof ApiError ? error.message : "Unable to delete attachment.";
|
||||
setStatus(message);
|
||||
} finally {
|
||||
setDeletingAttachmentId(null);
|
||||
setAttachmentPendingDelete(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,7 +180,7 @@ export function FileAttachmentsPanel({
|
||||
{canWriteFiles ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDelete(attachment)}
|
||||
onClick={() => setAttachmentPendingDelete(attachment)}
|
||||
disabled={deletingAttachmentId === attachment.id}
|
||||
className="rounded-2xl border border-rose-400/40 px-4 py-2 text-sm font-semibold text-rose-700 disabled:cursor-not-allowed disabled:opacity-60 dark:text-rose-300"
|
||||
>
|
||||
@@ -189,6 +192,29 @@ export function FileAttachmentsPanel({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmActionDialog
|
||||
open={attachmentPendingDelete != null}
|
||||
title="Delete attachment"
|
||||
description={
|
||||
attachmentPendingDelete
|
||||
? `Delete ${attachmentPendingDelete.originalName} from this record.`
|
||||
: "Delete this attachment."
|
||||
}
|
||||
impact="The file link will be removed from this record immediately."
|
||||
recovery="Re-upload the document if it was removed by mistake. Historical downloads are not retained in the UI."
|
||||
confirmLabel="Delete file"
|
||||
isConfirming={attachmentPendingDelete != null && deletingAttachmentId === attachmentPendingDelete.id}
|
||||
onClose={() => {
|
||||
if (!deletingAttachmentId) {
|
||||
setAttachmentPendingDelete(null);
|
||||
}
|
||||
}}
|
||||
onConfirm={async () => {
|
||||
if (attachmentPendingDelete) {
|
||||
await handleDelete(attachmentPendingDelete);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user