Files
pn-new-crm/src/components/interests/payments-section.tsx
Matt f0dbefcac2 chore(copy): em-dash sweep across user-facing JSX text + bump lint to error
Replaced 174 em-dashes (—) with " - " (space-hyphen-space) across 49
files in src/components + src/app. The em-dash reads as a tell-tale
"AI-generated" marker per the user's design feedback; hyphens with
spaces preserve the connector semantics without the AI tint.

Touched only lines outside pure-comment context (// /* * */). Code
comments, JSDoc, audit-log strings, structured logging strings, and
templates outside the lint scope retain their em-dashes for now —
they're not user-visible.

Also captured two remaining cases that used the `—` HTML entity
instead of the literal character (system-monitoring-dashboard,
interest-stage-picker) — replaced with a plain hyphen.

Bumped the existing `no-restricted-syntax` rule from `warn` → `error`
in eslint.config.mjs scoped to src/components/**/*.tsx +
src/app/**/*.tsx. New code reintroducing em-dashes in JSX text now
fails the lint gate.

Verified: tsc clean, vitest 1448/1448, eslint 0 em-dash warnings.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-21 20:02:58 +02:00

382 lines
13 KiB
TypeScript

'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 { DatePicker } from '@/components/ui/date-picker';
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 {
// `undefined` locale honours the user's browser locale. The
// previous `'en-EU'` literal is not a valid BCP-47 tag — every
// implementation falls back to the default anyway.
return new Intl.NumberFormat(undefined, { 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="payments" action="record">
<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="payments" action="delete">
<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&apos;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>
<DatePicker
id="payment-date"
value={receivedAt}
onChange={setReceivedAt}
toDate={new Date()}
placeholder="Pick a date"
/>
</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>
);
}