confirm actions

This commit is contained in:
2026-03-15 18:59:37 -05:00
parent 59754c7657
commit df041254da
28 changed files with 999 additions and 63 deletions

View File

@@ -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

View 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>
);
}

View File

@@ -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>
);
}