feat(pipeline): 9→7 stage refactor + v1.1 hardening wave
Replaces the legacy 9-stage pipeline with 7 canonical stages
(enquiry → qualified → eoi → reservation → deposit_paid → contract →
nurturing) plus three doc sub-status columns (eoi_doc_status,
reservation_doc_status, contract_doc_status) that track sent/signed
within a single stage instead of branching it.
Schema (migration 0062):
- interests gains assigned_to, deposit_expected_amount/currency,
three doc-status columns, two documenso-id columns, and
date_reservation_signed.
- New tables: qualification_criteria (per-port admin-configurable),
interest_qualifications (per-interest state), payments (deposit /
balance / refund records keyed to interest + client).
- Default qualification criteria seeded for every existing port.
- Dummy-data UPDATEs collapse Sent/Signed pairs and 'completed' into
the new stage + doc-status + outcome shape.
Migration 0063 adds interest_contact_log.voice_transcript and
template_used columns for v1.1-A/B (quick-template buttons + voice
transcription via Web Speech API).
v1.1 phase work bundled here:
- A/B: Quick-template buttons (Call / Visit / Email) + mic toggle on
the contact-log compose dialog (useVoiceTranscription hook).
- C: berth-rules-engine wraps state writes in pg_advisory_xact_lock
with an idempotent re-read; emits rule_evaluated audit traces.
- D: Documenso webhook: reservation/contract sub-status stamping
moved out of the PDF-download try-block so a download failure
no longer swallows the stamp. New integration test coverage.
- E: /admin/qualification-criteria CRUD page + admin component.
- F: default_new_interest_owner exposed in System Settings.
- G: recentActivityCount + active_engagement deal-pulse signal
surfaced as a chip on interests + hot-deals card.
- H: interest_assigned notification on assignedTo change (skips
self-assign, uses a dedupe key).
Plus the supporting components: AssignedToChip, DealPulseChip,
PaymentsSection, QualificationChecklist, MultiEoiChip,
SkipAheadBanner, WonStatusPanel, InterestBerthStatusBanner,
SupplementalInfoRequestButton, UserPicker.
Tests: 1370/1370 vitest pass (added deal-health unit suite +
expanded constants/validators/pipeline-transitions coverage). tsc
clean, eslint clean.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
377
src/components/interests/payments-section.tsx
Normal file
377
src/components/interests/payments-section.tsx
Normal file
@@ -0,0 +1,377 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Trash2, Receipt } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetFooter,
|
||||
} from '@/components/ui/sheet';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { PermissionGate } from '@/components/shared/permission-gate';
|
||||
import { apiFetch } from '@/lib/api/client';
|
||||
import { toastError } from '@/lib/api/toast-error';
|
||||
|
||||
interface PaymentRow {
|
||||
id: string;
|
||||
paymentType: string;
|
||||
amount: string;
|
||||
currency: string;
|
||||
receivedAt: string;
|
||||
receiptFileId: string | null;
|
||||
notes: string | null;
|
||||
recordedBy: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface PaymentsResponse {
|
||||
data: {
|
||||
payments: PaymentRow[];
|
||||
depositTotal: { total: string; currency: string };
|
||||
};
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
deposit: 'Deposit',
|
||||
balance: 'Balance',
|
||||
refund: 'Refund',
|
||||
other: 'Other',
|
||||
};
|
||||
|
||||
const TYPE_TINT: Record<string, string> = {
|
||||
deposit: 'bg-emerald-50 text-emerald-700 border-emerald-200',
|
||||
balance: 'bg-sky-50 text-sky-700 border-sky-200',
|
||||
refund: 'bg-rose-50 text-rose-700 border-rose-200',
|
||||
other: 'bg-slate-100 text-slate-700 border-slate-200',
|
||||
};
|
||||
|
||||
function formatMoney(amount: string, currency: string): string {
|
||||
const n = Number(amount);
|
||||
if (!Number.isFinite(n)) return `${amount} ${currency}`;
|
||||
try {
|
||||
return new Intl.NumberFormat('en-EU', { style: 'currency', currency }).format(n);
|
||||
} catch {
|
||||
return `${n.toFixed(2)} ${currency}`;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
return new Date(iso).toLocaleDateString();
|
||||
}
|
||||
|
||||
export function PaymentsSection({
|
||||
interestId,
|
||||
depositExpectedAmount,
|
||||
depositExpectedCurrency,
|
||||
}: {
|
||||
interestId: string;
|
||||
depositExpectedAmount: string | null;
|
||||
depositExpectedCurrency: string | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [recordOpen, setRecordOpen] = useState(false);
|
||||
|
||||
const { data, isLoading } = useQuery<PaymentsResponse>({
|
||||
queryKey: ['interest-payments', interestId],
|
||||
queryFn: () => apiFetch(`/api/v1/interests/${interestId}/payments`),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: async (paymentId: string) =>
|
||||
apiFetch(`/api/v1/payments/${paymentId}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interest-payments', interestId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<section className="rounded-lg border p-4 text-sm text-muted-foreground">
|
||||
Loading payments…
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const payments = data?.data.payments ?? [];
|
||||
const total = data?.data.depositTotal;
|
||||
const expectedAmount = depositExpectedAmount ? Number(depositExpectedAmount) : null;
|
||||
const expectedCurrency = depositExpectedCurrency ?? 'EUR';
|
||||
const runningTotal = total ? Number(total.total) : 0;
|
||||
const remaining =
|
||||
expectedAmount !== null && Number.isFinite(expectedAmount)
|
||||
? Math.max(0, expectedAmount - runningTotal)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<section className="rounded-lg border bg-card/40 p-4 space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold">Payments</h3>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Records that money was received or refunded. No invoices are issued — the bank handles
|
||||
that.
|
||||
</p>
|
||||
</div>
|
||||
<PermissionGate resource="invoices" action="record_payment">
|
||||
<Button size="sm" className="h-8 px-3 text-xs" onClick={() => setRecordOpen(true)}>
|
||||
<Plus className="size-3.5" aria-hidden />
|
||||
Record payment
|
||||
</Button>
|
||||
</PermissionGate>
|
||||
</div>
|
||||
|
||||
{expectedAmount !== null ? (
|
||||
<div className="flex items-center justify-between rounded-md border border-border bg-muted/30 px-3 py-2 text-xs">
|
||||
<span>
|
||||
Expected deposit:{' '}
|
||||
<strong>{formatMoney(String(expectedAmount), expectedCurrency)}</strong>
|
||||
</span>
|
||||
<span>
|
||||
Received so far: <strong>{formatMoney(total?.total ?? '0', expectedCurrency)}</strong>
|
||||
</span>
|
||||
{remaining !== null ? (
|
||||
<span className={remaining === 0 ? 'text-emerald-700' : 'text-amber-700'}>
|
||||
{remaining === 0
|
||||
? 'Fully received'
|
||||
: `${formatMoney(String(remaining), expectedCurrency)} outstanding`}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{payments.length === 0 ? (
|
||||
<p className="rounded border border-dashed px-3 py-4 text-center text-xs text-muted-foreground">
|
||||
No payments recorded yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border rounded border">
|
||||
{payments.map((p) => (
|
||||
<li key={p.id} className="flex items-center justify-between gap-3 px-3 py-2">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[10px] font-medium ${
|
||||
TYPE_TINT[p.paymentType] ?? TYPE_TINT.other
|
||||
}`}
|
||||
>
|
||||
{TYPE_LABELS[p.paymentType] ?? p.paymentType}
|
||||
</span>
|
||||
<div className="text-sm">
|
||||
<span className="font-medium">{formatMoney(p.amount, p.currency)}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">
|
||||
{formatDate(p.receivedAt)}
|
||||
</span>
|
||||
{p.notes ? (
|
||||
<span className="ml-2 text-xs text-muted-foreground">· {p.notes}</span>
|
||||
) : null}
|
||||
</div>
|
||||
{p.receiptFileId ? (
|
||||
<Receipt className="size-3 text-emerald-600" aria-hidden />
|
||||
) : null}
|
||||
</div>
|
||||
<PermissionGate resource="invoices" action="record_payment">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Delete payment record"
|
||||
className="rounded p-1 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
|
||||
disabled={deleteMutation.isPending}
|
||||
onClick={() => {
|
||||
if (confirm('Delete this payment record? This cannot be undone.')) {
|
||||
deleteMutation.mutate(p.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Trash2 className="size-3.5" aria-hidden />
|
||||
</button>
|
||||
</PermissionGate>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<RecordPaymentSheet
|
||||
open={recordOpen}
|
||||
onOpenChange={setRecordOpen}
|
||||
interestId={interestId}
|
||||
defaultCurrency={expectedCurrency}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordPaymentSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
interestId,
|
||||
defaultCurrency,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
interestId: string;
|
||||
defaultCurrency: string;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [paymentType, setPaymentType] = useState<string>('deposit');
|
||||
const [amount, setAmount] = useState('');
|
||||
const [currency, setCurrency] = useState(defaultCurrency);
|
||||
const [receivedAt, setReceivedAt] = useState(() => {
|
||||
const today = new Date();
|
||||
return today.toISOString().slice(0, 10);
|
||||
});
|
||||
const [notes, setNotes] = useState('');
|
||||
const [acknowledgedNoReceipt, setAcknowledgedNoReceipt] = useState(false);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
apiFetch(`/api/v1/interests/${interestId}/payments`, {
|
||||
method: 'POST',
|
||||
body: {
|
||||
interestId,
|
||||
paymentType,
|
||||
amount,
|
||||
currency,
|
||||
receivedAt: new Date(receivedAt).toISOString(),
|
||||
notes: notes || null,
|
||||
},
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['interest-payments', interestId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['interests', interestId] });
|
||||
onOpenChange(false);
|
||||
// Reset form for next use
|
||||
setAmount('');
|
||||
setNotes('');
|
||||
setAcknowledgedNoReceipt(false);
|
||||
},
|
||||
onError: (err) => toastError(err),
|
||||
});
|
||||
|
||||
const canSubmit =
|
||||
amount.trim().length > 0 && receivedAt && acknowledgedNoReceipt && !mutation.isPending;
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="w-full overflow-y-auto sm:max-w-md">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Record payment</SheetTitle>
|
||||
<SheetDescription>
|
||||
Capture that money was received (or refunded). Reps don't issue invoices — the bank
|
||||
does that — so this is just an audit record.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
|
||||
<form
|
||||
className="mt-5 space-y-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
mutation.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-type">Type</Label>
|
||||
<Select value={paymentType} onValueChange={setPaymentType}>
|
||||
<SelectTrigger id="payment-type">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="deposit">Deposit</SelectItem>
|
||||
<SelectItem value="balance">Balance</SelectItem>
|
||||
<SelectItem value="refund">Refund</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-[1fr_100px] gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-amount">Amount</Label>
|
||||
<Input
|
||||
id="payment-amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-currency">Currency</Label>
|
||||
<Input
|
||||
id="payment-currency"
|
||||
value={currency}
|
||||
onChange={(e) => setCurrency(e.target.value.toUpperCase())}
|
||||
maxLength={3}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-date">Received on</Label>
|
||||
<Input
|
||||
id="payment-date"
|
||||
type="date"
|
||||
value={receivedAt}
|
||||
onChange={(e) => setReceivedAt(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="payment-notes">Notes (optional)</Label>
|
||||
<Input
|
||||
id="payment-notes"
|
||||
placeholder="Reference, payer name, etc."
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-start gap-2 rounded border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-900">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={acknowledgedNoReceipt}
|
||||
onChange={(e) => setAcknowledgedNoReceipt(e.target.checked)}
|
||||
className="mt-0.5"
|
||||
/>
|
||||
<span>
|
||||
I understand that recording a payment without an attached receipt may make later
|
||||
verification harder, and that the bank-issued receipt is the canonical proof.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<SheetFooter className="gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{mutation.isPending ? 'Saving…' : 'Record payment'}
|
||||
</Button>
|
||||
</SheetFooter>
|
||||
</form>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user