- For each client we delete: client record + addresses, contacts, notes, tags, portal
- user, GDPR records, all interests, all reservations. Signed documents, email threads,
- files and reminders are detached but kept.
+ {stage === 'intent' && (
+
+
+ We’ll email a 4-digit confirmation code to your account address. The code is tied
+ to this exact set of clients and expires in 10 minutes.
+
+
+ For each client we delete: client record + addresses, contacts, notes, tags, portal
+ user, GDPR records, all interests, all reservations. Signed documents, email threads,
+ files and reminders are detached but kept.
+
+
+
+
+
+ Code sent to {maskedEmail}. Enter both fields
+ below.
- )}
-
- {stage === 'confirm' && (
-
-
-
-
- Code sent to {maskedEmail}. Enter both fields
- below.
-
-
-
-
- setCode(e.target.value.replace(/\D/g, ''))}
- placeholder="0000"
- className="font-mono tracking-[0.4em] text-center text-lg"
- autoComplete="off"
- />
-
-
-
- setTypedPhrase(e.target.value)}
- placeholder={expectedPhrase}
- autoComplete="off"
- className="font-mono"
- />
-
+
+
+ setCode(e.target.value.replace(/\D/g, ''))}
+ placeholder="0000"
+ className="font-mono tracking-[0.4em] text-center text-lg"
+ autoComplete="off"
+ />
- )}
+
+
+ setTypedPhrase(e.target.value)}
+ placeholder={expectedPhrase}
+ autoComplete="off"
+ className="font-mono"
+ />
+
+
+ )}
- {stage === 'partial' && (
-
-
- {partialDeleted} of {clientIds.length} permanently deleted. {skipped.length} skipped —
- see below.
-
-
-
-
-
- | Client ID |
- Reason |
+ {stage === 'partial' && (
+
+
+ {partialDeleted} of {clientIds.length} permanently deleted. {skipped.length} skipped —
+ see below.
+
+
+
+
+
+ | Client ID |
+ Reason |
+
+
+
+ {skipped.map((row) => (
+
+ |
+ {row.clientId.slice(0, 8)}…
+ |
+ {row.reason} |
-
-
- {skipped.map((row) => (
-
- |
- {row.clientId.slice(0, 8)}…
- |
- {row.reason} |
-
- ))}
-
-
-
+ ))}
+
+
- )}
+
+ )}
-
- {stage !== 'partial' && (
-
- )}
- {stage === 'intent' && (
-
- )}
- {stage === 'confirm' && (
-
- )}
- {stage === 'partial' && (
-
- )}
-
-
-
+
+ {stage !== 'partial' && (
+
+ )}
+ {stage === 'intent' && (
+
+ )}
+ {stage === 'confirm' && (
+
+ )}
+ {stage === 'partial' && (
+
+ )}
+
+ >
);
}
diff --git a/src/components/clients/hard-delete-dialog.tsx b/src/components/clients/hard-delete-dialog.tsx
index 5c219a28..77ee20c1 100644
--- a/src/components/clients/hard-delete-dialog.tsx
+++ b/src/components/clients/hard-delete-dialog.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useEffect, useState } from 'react';
+import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Loader2, Mail } from 'lucide-react';
import { toast } from 'sonner';
@@ -29,22 +29,30 @@ interface Props {
type Stage = 'intent' | 'confirm';
-export function HardDeleteDialog({ open, onOpenChange, clientId, clientName, onDeleted }: Props) {
+/**
+ * Outer wrapper keeps the Dialog mounted (so its close animation runs);
+ * the body only mounts when `open` is true and remounts on each
+ * open via the `clientId` key. This avoids the open→reset-state
+ * useEffect that React Compiler flags — fresh state per open is just
+ * the natural mount.
+ */
+export function HardDeleteDialog(props: Props) {
+ return (
+
+ );
+}
+
+function HardDeleteDialogBody({ onOpenChange, clientId, clientName, onDeleted }: Props) {
const qc = useQueryClient();
const [stage, setStage] = useState
('intent');
const [code, setCode] = useState('');
const [typedName, setTypedName] = useState('');
const [maskedEmail, setMaskedEmail] = useState(null);
- useEffect(() => {
- if (open) {
- setStage('intent');
- setCode('');
- setTypedName('');
- setMaskedEmail(null);
- }
- }, [open]);
-
const requestCode = useMutation({
mutationFn: () =>
apiFetch<{ data: { sentToMaskedEmail: string } }>(
@@ -82,127 +90,125 @@ export function HardDeleteDialog({ open, onOpenChange, clientId, clientName, onD
const codeValid = /^\d{4}$/.test(code.trim());
return (
-