157 lines
4.7 KiB
TypeScript
157 lines
4.7 KiB
TypeScript
|
|
'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',
|
||
|
|
lost_other_marina: 'Lost — went to another marina',
|
||
|
|
lost_unqualified: 'Lost — unqualified',
|
||
|
|
lost_no_response: 'Lost — no response',
|
||
|
|
cancelled: 'Cancelled',
|
||
|
|
};
|
||
|
|
|
||
|
|
const LOST_OUTCOMES: InterestOutcome[] = [
|
||
|
|
'lost_other_marina',
|
||
|
|
'lost_unqualified',
|
||
|
|
'lost_no_response',
|
||
|
|
'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' ? (
|
||
|
|
<Trophy className="h-4 w-4 text-emerald-600" />
|
||
|
|
) : (
|
||
|
|
<XCircle className="h-4 w-4 text-rose-600" />
|
||
|
|
)}
|
||
|
|
{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">
|
||
|
|
This will move the interest to <strong>Completed</strong> and stamp the outcome. You can
|
||
|
|
reopen it later.
|
||
|
|
</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'
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{mutation.isPending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||
|
|
{mode === 'won' ? 'Mark as won' : 'Close as lost'}
|
||
|
|
</Button>
|
||
|
|
</DialogFooter>
|
||
|
|
</DialogContent>
|
||
|
|
</Dialog>
|
||
|
|
);
|
||
|
|
}
|