dodil/erp-ui

src/components/confirm-dialog/ConfirmDialog.tsx

/**
 * ConfirmDialog — "are you sure", for the steps that cannot be taken back or that the next person reads:
 * disqualifying a lead, losing a deal, blocking a partner. When the step needs a reason (the API refuses one
 * without it), the dialog asks for it and will not confirm while it is empty.
 *
 * The dialog stays open while the call runs and shows the API's refusal in place, so a person never loses
 * what they typed to an error.
 */
import { AlertDialog, Button, Callout, Flex, Text, TextArea } from "@radix-ui/themes";
import { useState, type ReactNode } from "react";

export interface ConfirmDialogProps {
  /** The button that opens the dialog. */
  trigger: ReactNode;
  title: string;
  description?: ReactNode;
  confirmLabel: string;
  tone?: "danger" | "primary";
  /** Ask for a reason; the label of its field, e.g. "Why is it lost?". */
  reason?: { label: string; placeholder?: string };
  /** Throw to keep the dialog open with the error shown. */
  onConfirm: (reason: string) => Promise<unknown> | unknown;
}

export function ConfirmDialog({ trigger, title, description, confirmLabel, tone = "primary", reason, onConfirm }: ConfirmDialogProps) {
  const [open, setOpen] = useState(false);
  const [text, setText] = useState("");
  const [busy, setBusy] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const blocked = !!reason && text.trim() === "";

  const confirm = async () => {
    setBusy(true);
    setError(null);
    try {
      await onConfirm(text.trim());
      setOpen(false);
      setText("");
    } catch (e) {
      setError(e instanceof Error ? e.message : String(e));
    } finally {
      setBusy(false);
    }
  };

  return (
    <AlertDialog.Root open={open} onOpenChange={(o) => { setOpen(o); if (!o) setError(null); }}>
      <AlertDialog.Trigger>{trigger}</AlertDialog.Trigger>
      <AlertDialog.Content maxWidth="460px">
        <AlertDialog.Title>{title}</AlertDialog.Title>
        <AlertDialog.Description size="2">{description ?? "This is recorded with your name."}</AlertDialog.Description>
        {reason && (
          <Flex direction="column" gap="1" mt="3" asChild>
            <label>
              <Text size="2" weight="medium">{reason.label}</Text>
              <TextArea value={text} onChange={(e) => setText(e.target.value)} placeholder={reason.placeholder} required />
            </label>
          </Flex>
        )}
        {error && (
          <Callout.Root color="red" role="alert" mt="3" size="1"><Callout.Text>{error}</Callout.Text></Callout.Root>
        )}
        <Flex gap="3" mt="4" justify="end">
          <AlertDialog.Cancel><Button variant="soft" color="gray">Cancel</Button></AlertDialog.Cancel>
          <Button color={tone === "danger" ? "red" : undefined} onClick={confirm} disabled={blocked} loading={busy}>
            {confirmLabel}
          </Button>
        </Flex>
      </AlertDialog.Content>
    </AlertDialog.Root>
  );
}