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>
382 lines
13 KiB
TypeScript
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'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>
|
|
);
|
|
}
|