fix(compiler): key-based remount on hard-delete dialogs

Replaces the `if (open) { setStage(...); setCode(''); ... }` reset
useEffect with a key-based remount of the dialog body. The body now
mounts fresh each time the dialog opens; useState initialisers
run naturally instead of being chased by an effect.

Pattern (apply to remaining dialogs in the same shape):

```tsx
export function MyDialog(props) {
  return (
    <Dialog open={props.open} onOpenChange={props.onOpenChange}>
      <DialogContent>
        {props.open && <MyDialogBody key={props.id} {...props} />}
      </DialogContent>
    </Dialog>
  );
}
```

Applied to:
- hard-delete-dialog (keyed on clientId)
- bulk-hard-delete-dialog (keyed on joined clientIds)

set-state-in-effect: 43 → 41.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 23:43:20 +02:00
parent 8a8cff4c4c
commit 4ae34dacda
2 changed files with 279 additions and 272 deletions

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Loader2, Mail } from 'lucide-react'; import { AlertTriangle, Loader2, Mail } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -32,7 +32,21 @@ interface SkippedRow {
reason: string; reason: string;
} }
export function BulkHardDeleteDialog({ open, onOpenChange, clientIds, onDeleted }: Props) { /**
* Key-based remount of the body when the dialog opens — fresh state per
* open without an open→reset useEffect (React Compiler-safe).
*/
export function BulkHardDeleteDialog(props: Props) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className="sm:max-w-md">
{props.open && <BulkHardDeleteDialogBody key={props.clientIds.join(',')} {...props} />}
</DialogContent>
</Dialog>
);
}
function BulkHardDeleteDialogBody({ onOpenChange, clientIds, onDeleted }: Props) {
const qc = useQueryClient(); const qc = useQueryClient();
const [stage, setStage] = useState<Stage>('intent'); const [stage, setStage] = useState<Stage>('intent');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
@@ -43,17 +57,6 @@ export function BulkHardDeleteDialog({ open, onOpenChange, clientIds, onDeleted
const expectedPhrase = `DELETE ${clientIds.length} CLIENT${clientIds.length === 1 ? '' : 'S'}`; const expectedPhrase = `DELETE ${clientIds.length} CLIENT${clientIds.length === 1 ? '' : 'S'}`;
useEffect(() => {
if (open) {
setStage('intent');
setCode('');
setTypedPhrase('');
setMaskedEmail(null);
setSkipped([]);
setPartialDeleted(0);
}
}, [open]);
const requestCode = useMutation({ const requestCode = useMutation({
mutationFn: () => mutationFn: () =>
apiFetch<{ data: { count: number; sentToMaskedEmail: string } }>( apiFetch<{ data: { count: number; sentToMaskedEmail: string } }>(
@@ -105,147 +108,145 @@ export function BulkHardDeleteDialog({ open, onOpenChange, clientIds, onDeleted
const codeValid = /^\d{4}$/.test(code.trim()); const codeValid = /^\d{4}$/.test(code.trim());
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <>
<DialogContent className="sm:max-w-md"> <DialogHeader>
<DialogHeader> <DialogTitle className="flex items-center gap-2 text-destructive">
<DialogTitle className="flex items-center gap-2 text-destructive"> <AlertTriangle className="h-5 w-5" />
<AlertTriangle className="h-5 w-5" /> Permanently delete {clientIds.length} client{clientIds.length === 1 ? '' : 's'}
Permanently delete {clientIds.length} client{clientIds.length === 1 ? '' : 's'} </DialogTitle>
</DialogTitle> <DialogDescription>
<DialogDescription> All selected clients must already be archived. This cannot be undone.
All selected clients must already be archived. This cannot be undone. </DialogDescription>
</DialogDescription> </DialogHeader>
</DialogHeader>
{stage === 'intent' && ( {stage === 'intent' && (
<div className="space-y-3 text-sm text-muted-foreground"> <div className="space-y-3 text-sm text-muted-foreground">
<p> <p>
We&rsquo;ll email a 4-digit confirmation code to your account address. The code is We&rsquo;ll email a 4-digit confirmation code to your account address. The code is tied
tied to this exact set of clients and expires in 10 minutes. to this exact set of clients and expires in 10 minutes.
</p> </p>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900 text-xs"> <div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900 text-xs">
For each client we delete: client record + addresses, contacts, notes, tags, portal For each client we delete: client record + addresses, contacts, notes, tags, portal
user, GDPR records, all interests, all reservations. Signed documents, email threads, user, GDPR records, all interests, all reservations. Signed documents, email threads,
files and reminders are detached but kept. files and reminders are detached but kept.
</div>
</div>
)}
{stage === 'confirm' && (
<div className="space-y-3">
<div className="flex items-start gap-2 rounded-md border border-blue-300 bg-blue-50 p-3 text-xs text-blue-900">
<Mail className="h-4 w-4 shrink-0 mt-0.5" />
<div>
Code sent to <span className="font-mono">{maskedEmail}</span>. Enter both fields
below.
</div> </div>
</div> </div>
)} <div className="space-y-1.5">
<Label htmlFor="bhd-code">4-digit code from email</Label>
{stage === 'confirm' && ( <Input
<div className="space-y-3"> id="bhd-code"
<div className="flex items-start gap-2 rounded-md border border-blue-300 bg-blue-50 p-3 text-xs text-blue-900"> inputMode="numeric"
<Mail className="h-4 w-4 shrink-0 mt-0.5" /> maxLength={4}
<div> value={code}
Code sent to <span className="font-mono">{maskedEmail}</span>. Enter both fields onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
below. placeholder="0000"
</div> className="font-mono tracking-[0.4em] text-center text-lg"
</div> autoComplete="off"
<div className="space-y-1.5"> />
<Label htmlFor="bhd-code">4-digit code from email</Label>
<Input
id="bhd-code"
inputMode="numeric"
maxLength={4}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
placeholder="0000"
className="font-mono tracking-[0.4em] text-center text-lg"
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="bhd-phrase">
Type <span className="font-mono font-semibold">{expectedPhrase}</span> to confirm
</Label>
<Input
id="bhd-phrase"
value={typedPhrase}
onChange={(e) => setTypedPhrase(e.target.value)}
placeholder={expectedPhrase}
autoComplete="off"
className="font-mono"
/>
</div>
</div> </div>
)} <div className="space-y-1.5">
<Label htmlFor="bhd-phrase">
Type <span className="font-mono font-semibold">{expectedPhrase}</span> to confirm
</Label>
<Input
id="bhd-phrase"
value={typedPhrase}
onChange={(e) => setTypedPhrase(e.target.value)}
placeholder={expectedPhrase}
autoComplete="off"
className="font-mono"
/>
</div>
</div>
)}
{stage === 'partial' && ( {stage === 'partial' && (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900"> <div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900">
{partialDeleted} of {clientIds.length} permanently deleted. {skipped.length} skipped {partialDeleted} of {clientIds.length} permanently deleted. {skipped.length} skipped
see below. see below.
</div> </div>
<div className="rounded-md border max-h-60 overflow-y-auto"> <div className="rounded-md border max-h-60 overflow-y-auto">
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="bg-muted/50 sticky top-0"> <thead className="bg-muted/50 sticky top-0">
<tr> <tr>
<th className="text-left px-2 py-1.5 font-medium">Client ID</th> <th className="text-left px-2 py-1.5 font-medium">Client ID</th>
<th className="text-left px-2 py-1.5 font-medium">Reason</th> <th className="text-left px-2 py-1.5 font-medium">Reason</th>
</tr>
</thead>
<tbody>
{skipped.map((row) => (
<tr key={row.clientId} className="border-t">
<td className="px-2 py-1.5 font-mono text-[11px]">
{row.clientId.slice(0, 8)}
</td>
<td className="px-2 py-1.5">{row.reason}</td>
</tr> </tr>
</thead> ))}
<tbody> </tbody>
{skipped.map((row) => ( </table>
<tr key={row.clientId} className="border-t">
<td className="px-2 py-1.5 font-mono text-[11px]">
{row.clientId.slice(0, 8)}
</td>
<td className="px-2 py-1.5">{row.reason}</td>
</tr>
))}
</tbody>
</table>
</div>
</div> </div>
)} </div>
)}
<DialogFooter> <DialogFooter>
{stage !== 'partial' && ( {stage !== 'partial' && (
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button> </Button>
)} )}
{stage === 'intent' && ( {stage === 'intent' && (
<Button <Button
variant="destructive" variant="destructive"
onClick={() => requestCode.mutate()} onClick={() => requestCode.mutate()}
disabled={requestCode.isPending} disabled={requestCode.isPending}
> >
{requestCode.isPending ? ( {requestCode.isPending ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin mr-1.5" /> Sending <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> Sending
</> </>
) : ( ) : (
'Send confirmation code' 'Send confirmation code'
)} )}
</Button> </Button>
)} )}
{stage === 'confirm' && ( {stage === 'confirm' && (
<Button <Button
variant="destructive" variant="destructive"
onClick={() => bulkDelete.mutate()} onClick={() => bulkDelete.mutate()}
disabled={!codeValid || !phraseMatches || bulkDelete.isPending} disabled={!codeValid || !phraseMatches || bulkDelete.isPending}
> >
{bulkDelete.isPending ? ( {bulkDelete.isPending ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin mr-1.5" /> Deleting <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> Deleting
</> </>
) : ( ) : (
`Permanently delete ${clientIds.length}` `Permanently delete ${clientIds.length}`
)} )}
</Button> </Button>
)} )}
{stage === 'partial' && ( {stage === 'partial' && (
<Button <Button
onClick={() => { onClick={() => {
onOpenChange(false); onOpenChange(false);
onDeleted?.(partialDeleted); onDeleted?.(partialDeleted);
}} }}
> >
Done Done
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>
</DialogContent> </>
</Dialog>
); );
} }

View File

@@ -1,6 +1,6 @@
'use client'; 'use client';
import { useEffect, useState } from 'react'; import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, Loader2, Mail } from 'lucide-react'; import { AlertTriangle, Loader2, Mail } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -29,22 +29,30 @@ interface Props {
type Stage = 'intent' | 'confirm'; 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 (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className="sm:max-w-md">
{props.open && <HardDeleteDialogBody key={props.clientId} {...props} />}
</DialogContent>
</Dialog>
);
}
function HardDeleteDialogBody({ onOpenChange, clientId, clientName, onDeleted }: Props) {
const qc = useQueryClient(); const qc = useQueryClient();
const [stage, setStage] = useState<Stage>('intent'); const [stage, setStage] = useState<Stage>('intent');
const [code, setCode] = useState(''); const [code, setCode] = useState('');
const [typedName, setTypedName] = useState(''); const [typedName, setTypedName] = useState('');
const [maskedEmail, setMaskedEmail] = useState<string | null>(null); const [maskedEmail, setMaskedEmail] = useState<string | null>(null);
useEffect(() => {
if (open) {
setStage('intent');
setCode('');
setTypedName('');
setMaskedEmail(null);
}
}, [open]);
const requestCode = useMutation({ const requestCode = useMutation({
mutationFn: () => mutationFn: () =>
apiFetch<{ data: { sentToMaskedEmail: string } }>( apiFetch<{ data: { sentToMaskedEmail: string } }>(
@@ -82,127 +90,125 @@ export function HardDeleteDialog({ open, onOpenChange, clientId, clientName, onD
const codeValid = /^\d{4}$/.test(code.trim()); const codeValid = /^\d{4}$/.test(code.trim());
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <>
<DialogContent className="sm:max-w-md"> <DialogHeader>
<DialogHeader> <DialogTitle className="flex items-center gap-2 text-destructive">
<DialogTitle className="flex items-center gap-2 text-destructive"> <AlertTriangle className="h-5 w-5" />
<AlertTriangle className="h-5 w-5" /> Permanently delete {clientName}
Permanently delete {clientName} </DialogTitle>
</DialogTitle> <DialogDescription>
<DialogDescription> This permanently removes the client record and detaches all related history (signed
This permanently removes the client record and detaches all related history (signed documents, emails, files). It cannot be undone.
documents, emails, files). It cannot be undone. </DialogDescription>
</DialogDescription> </DialogHeader>
</DialogHeader>
{stage === 'intent' ? ( {stage === 'intent' ? (
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Permanent deletion is reserved for archived clients only. We&rsquo;ll email a 4-digit Permanent deletion is reserved for archived clients only. We&rsquo;ll email a 4-digit
confirmation code to your account address. The code expires in 10 minutes. confirmation code to your account address. The code expires in 10 minutes.
</p>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900">
<p className="font-medium flex items-center gap-2">
<AlertTriangle className="h-4 w-4" /> What gets deleted
</p> </p>
<div className="rounded-md border border-amber-300 bg-amber-50 p-3 text-amber-900"> <ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5">
<p className="font-medium flex items-center gap-2"> <li>Client record + addresses, contacts, notes, tags</li>
<AlertTriangle className="h-4 w-4" /> What gets deleted <li>Portal user account + GDPR consent records</li>
</p> <li>All pipeline interests + reservations for this client</li>
<ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5"> </ul>
<li>Client record + addresses, contacts, notes, tags</li> <p className="font-medium mt-2 flex items-center gap-2">What is preserved</p>
<li>Portal user account + GDPR consent records</li> <ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5">
<li>All pipeline interests + reservations for this client</li> <li>Signed documents (detached from client, kept for legal history)</li>
</ul> <li>Email threads, files, reminders (detached)</li>
<p className="font-medium mt-2 flex items-center gap-2">What is preserved</p> <li>Audit log entries</li>
<ul className="mt-1.5 list-disc pl-5 text-xs space-y-0.5"> </ul>
<li>Signed documents (detached from client, kept for legal history)</li>
<li>Email threads, files, reminders (detached)</li>
<li>Audit log entries</li>
</ul>
</div>
</div> </div>
) : ( </div>
<div className="space-y-3"> ) : (
<div className="flex items-start gap-2 rounded-md border border-blue-300 bg-blue-50 p-3 text-xs text-blue-900"> <div className="space-y-3">
<Mail className="h-4 w-4 shrink-0 mt-0.5" /> <div className="flex items-start gap-2 rounded-md border border-blue-300 bg-blue-50 p-3 text-xs text-blue-900">
<div className="flex-1"> <Mail className="h-4 w-4 shrink-0 mt-0.5" />
<div> <div className="flex-1">
Code sent to <span className="font-mono">{maskedEmail}</span>. It expires in 10 <div>
minutes. Code sent to <span className="font-mono">{maskedEmail}</span>. It expires in 10
</div> minutes.
<button
type="button"
onClick={() => {
setCode('');
requestCode.mutate();
}}
disabled={requestCode.isPending}
className="mt-1 text-blue-700 underline-offset-2 hover:underline disabled:opacity-60"
>
{requestCode.isPending ? 'Sending…' : 'Send a new code'}
</button>
</div> </div>
</div> <button
<div className="space-y-1.5"> type="button"
<Label htmlFor="hd-code">4-digit code from email</Label> onClick={() => {
<Input setCode('');
id="hd-code" requestCode.mutate();
inputMode="numeric" }}
maxLength={4} disabled={requestCode.isPending}
value={code} className="mt-1 text-blue-700 underline-offset-2 hover:underline disabled:opacity-60"
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))} >
placeholder="0000" {requestCode.isPending ? 'Sending…' : 'Send a new code'}
className="font-mono tracking-[0.4em] text-center text-lg" </button>
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hd-name">
Type <span className="font-semibold">{clientName}</span> to confirm
</Label>
<Input
id="hd-name"
value={typedName}
onChange={(e) => setTypedName(e.target.value)}
placeholder={clientName}
autoComplete="off"
/>
</div> </div>
</div> </div>
)} <div className="space-y-1.5">
<Label htmlFor="hd-code">4-digit code from email</Label>
<Input
id="hd-code"
inputMode="numeric"
maxLength={4}
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, ''))}
placeholder="0000"
className="font-mono tracking-[0.4em] text-center text-lg"
autoComplete="off"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="hd-name">
Type <span className="font-semibold">{clientName}</span> to confirm
</Label>
<Input
id="hd-name"
value={typedName}
onChange={(e) => setTypedName(e.target.value)}
placeholder={clientName}
autoComplete="off"
/>
</div>
</div>
)}
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}> <Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel Cancel
</Button>
{stage === 'intent' ? (
<Button
variant="destructive"
onClick={() => requestCode.mutate()}
disabled={requestCode.isPending}
>
{requestCode.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-1.5" /> Sending
</>
) : (
'Send confirmation code'
)}
</Button> </Button>
{stage === 'intent' ? ( ) : (
<Button <Button
variant="destructive" variant="destructive"
onClick={() => requestCode.mutate()} onClick={() => hardDelete.mutate()}
disabled={requestCode.isPending} disabled={!codeValid || !nameMatches || hardDelete.isPending}
> >
{requestCode.isPending ? ( {hardDelete.isPending ? (
<> <>
<Loader2 className="h-4 w-4 animate-spin mr-1.5" /> Sending <Loader2 className="h-4 w-4 animate-spin mr-1.5" /> Deleting
</> </>
) : ( ) : (
'Send confirmation code' 'Permanently delete'
)} )}
</Button> </Button>
) : ( )}
<Button </DialogFooter>
variant="destructive" </>
onClick={() => hardDelete.mutate()}
disabled={!codeValid || !nameMatches || hardDelete.isPending}
>
{hardDelete.isPending ? (
<>
<Loader2 className="h-4 w-4 animate-spin mr-1.5" /> Deleting
</>
) : (
'Permanently delete'
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
); );
} }