2026-05-02 00:01:33 +02:00
|
|
|
'use client';
|
|
|
|
|
|
|
|
|
|
import { useState } from 'react';
|
|
|
|
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
|
|
|
|
import { Loader2, Trophy, XCircle } from 'lucide-react';
|
|
|
|
|
|
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
|
import { Label } from '@/components/ui/label';
|
|
|
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
|
|
|
import {
|
|
|
|
|
Dialog,
|
|
|
|
|
DialogContent,
|
|
|
|
|
DialogFooter,
|
|
|
|
|
DialogHeader,
|
|
|
|
|
DialogTitle,
|
|
|
|
|
} from '@/components/ui/dialog';
|
|
|
|
|
import {
|
|
|
|
|
Select,
|
|
|
|
|
SelectContent,
|
|
|
|
|
SelectItem,
|
|
|
|
|
SelectTrigger,
|
|
|
|
|
SelectValue,
|
|
|
|
|
} from '@/components/ui/select';
|
|
|
|
|
import { apiFetch } from '@/lib/api/client';
|
|
|
|
|
import { type InterestOutcome } from '@/lib/validators/interests';
|
|
|
|
|
|
|
|
|
|
const OUTCOME_LABELS: Record<InterestOutcome, string> = {
|
|
|
|
|
won: 'Won',
|
2026-05-04 22:57:01 +02:00
|
|
|
lost_other_marina: 'Lost - went to another marina',
|
|
|
|
|
lost_unqualified: 'Lost - unqualified',
|
|
|
|
|
lost_no_response: 'Lost - no response',
|
2026-05-12 14:50:58 +02:00
|
|
|
lost_other: 'Lost - other',
|
2026-05-02 00:01:33 +02:00
|
|
|
cancelled: 'Cancelled',
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const LOST_OUTCOMES: InterestOutcome[] = [
|
|
|
|
|
'lost_other_marina',
|
|
|
|
|
'lost_unqualified',
|
|
|
|
|
'lost_no_response',
|
2026-05-12 14:50:58 +02:00
|
|
|
'lost_other',
|
2026-05-02 00:01:33 +02:00
|
|
|
'cancelled',
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
interface Props {
|
|
|
|
|
interestId: string;
|
|
|
|
|
open: boolean;
|
|
|
|
|
onOpenChange: (open: boolean) => void;
|
|
|
|
|
/** Determines which outcomes are offered. 'won' opens with just the Won option preselected. */
|
|
|
|
|
mode: 'won' | 'lost';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function InterestOutcomeDialog({ interestId, open, onOpenChange, mode }: Props) {
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const choices: InterestOutcome[] = mode === 'won' ? ['won'] : LOST_OUTCOMES;
|
|
|
|
|
const [outcome, setOutcome] = useState<InterestOutcome>(choices[0]!);
|
|
|
|
|
const [reason, setReason] = useState('');
|
|
|
|
|
|
|
|
|
|
const mutation = useMutation({
|
|
|
|
|
mutationFn: () =>
|
|
|
|
|
apiFetch(`/api/v1/interests/${interestId}/outcome`, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
body: { outcome, reason: reason || undefined },
|
|
|
|
|
}),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ['interests'] });
|
|
|
|
|
onOpenChange(false);
|
|
|
|
|
setReason('');
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
function handleOpenChange(next: boolean) {
|
|
|
|
|
if (!next) {
|
|
|
|
|
setReason('');
|
|
|
|
|
setOutcome(choices[0]!);
|
|
|
|
|
}
|
|
|
|
|
onOpenChange(next);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<Dialog open={open} onOpenChange={handleOpenChange}>
|
|
|
|
|
<DialogContent className="sm:max-w-md">
|
|
|
|
|
<DialogHeader>
|
|
|
|
|
<DialogTitle className="flex items-center gap-2">
|
|
|
|
|
{mode === 'won' ? (
|
fix(audit-wave-10): aria-hidden sweep on decorative Lucide icons (#69)
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:
- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/
The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.
Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.
Test suite stays at 1315/1315 vitest. typescript clean.
Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:37:22 +02:00
|
|
|
<Trophy className="h-4 w-4 text-emerald-600" aria-hidden />
|
2026-05-02 00:01:33 +02:00
|
|
|
) : (
|
fix(audit-wave-10): aria-hidden sweep on decorative Lucide icons (#69)
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:
- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/
The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.
Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.
Test suite stays at 1315/1315 vitest. typescript clean.
Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:37:22 +02:00
|
|
|
<XCircle className="h-4 w-4 text-rose-600" aria-hidden />
|
2026-05-02 00:01:33 +02:00
|
|
|
)}
|
|
|
|
|
{mode === 'won' ? 'Mark interest as won' : 'Close interest as lost'}
|
|
|
|
|
</DialogTitle>
|
|
|
|
|
</DialogHeader>
|
|
|
|
|
|
|
|
|
|
<div className="space-y-4 py-2">
|
|
|
|
|
{mode === 'lost' ? (
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label>Reason</Label>
|
|
|
|
|
<Select value={outcome} onValueChange={(v) => setOutcome(v as InterestOutcome)}>
|
|
|
|
|
<SelectTrigger>
|
|
|
|
|
<SelectValue />
|
|
|
|
|
</SelectTrigger>
|
|
|
|
|
<SelectContent>
|
|
|
|
|
{LOST_OUTCOMES.map((o) => (
|
|
|
|
|
<SelectItem key={o} value={o}>
|
|
|
|
|
{OUTCOME_LABELS[o]}
|
|
|
|
|
</SelectItem>
|
|
|
|
|
))}
|
|
|
|
|
</SelectContent>
|
|
|
|
|
</Select>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<div className="space-y-1">
|
|
|
|
|
<Label htmlFor="outcome-reason">Notes (optional)</Label>
|
|
|
|
|
<Textarea
|
|
|
|
|
id="outcome-reason"
|
|
|
|
|
value={reason}
|
|
|
|
|
onChange={(e) => setReason(e.target.value)}
|
|
|
|
|
placeholder={
|
|
|
|
|
mode === 'won'
|
|
|
|
|
? 'Anything notable about the win? (visible in timeline + reports)'
|
|
|
|
|
: 'What happened? (visible in timeline + reports)'
|
|
|
|
|
}
|
|
|
|
|
rows={3}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<p className="text-xs text-muted-foreground">
|
2026-05-14 22:59:38 +02:00
|
|
|
{mode === 'won' ? (
|
|
|
|
|
<>
|
|
|
|
|
This will mark the interest as <strong>Won</strong>. The pipeline stage stays where
|
|
|
|
|
it is; the outcome flag is set. You can clear the outcome later to reopen.
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
This will close the interest with a <strong>Lost</strong> outcome. The pipeline
|
|
|
|
|
stage stays where it is. You can clear the outcome later to reopen.
|
|
|
|
|
</>
|
|
|
|
|
)}
|
2026-05-02 00:01:33 +02:00
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<DialogFooter>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
onClick={() => handleOpenChange(false)}
|
|
|
|
|
disabled={mutation.isPending}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
onClick={() => mutation.mutate()}
|
|
|
|
|
disabled={mutation.isPending}
|
|
|
|
|
className={
|
|
|
|
|
mode === 'won'
|
|
|
|
|
? 'bg-emerald-600 hover:bg-emerald-700'
|
|
|
|
|
: 'bg-rose-600 hover:bg-rose-700'
|
|
|
|
|
}
|
|
|
|
|
>
|
fix(audit-wave-10): aria-hidden sweep on decorative Lucide icons (#69)
Mechanical codemod added \`aria-hidden\` to 444 self-closing single-line
Lucide icon JSX elements across 267 .tsx files in:
- shared/, layout/, dashboard/
- admin/ (all sections)
- clients/, berths/, yachts/, companies/, interests/, documents/
- reminders/, reservations/, residential/, expenses/, email/
The regex targeted only the safe pattern \`<IconName className="..." />\`
(no other props, self-closing, capitalized component name). Every match
inspected is a decorative companion to visible text or sits inside a
button whose accessible name comes from \`aria-label\` / sr-only text
— the icon itself should not be announced.
Screen readers no longer double-read the icon + the adjacent label
text (e.g. "Pencil Pencil Edit" → just "Edit"). The existing
@axe-core/playwright smoke test (\`20-accessibility.spec.ts\`) continues
to pass.
Test suite stays at 1315/1315 vitest. typescript clean.
Closes task #69 (aria-hidden sweep) from the AUDIT-2026-05-12 follow-ups
backlog.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 12:37:22 +02:00
|
|
|
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />}
|
2026-05-02 00:01:33 +02:00
|
|
|
{mode === 'won' ? 'Mark as won' : 'Close as lost'}
|
|
|
|
|
</Button>
|
|
|
|
|
</DialogFooter>
|
|
|
|
|
</DialogContent>
|
|
|
|
|
</Dialog>
|
|
|
|
|
);
|
|
|
|
|
}
|