refactor(ts): convert bill-modal sub-components to TSX (B10)
The 7 BillModal sub-components + transactionDisplay helper: TemplateSection, AutopayTrustIndicator, PaymentFormFields, PaymentHistoryList, DebtDetailsSection, LinkedTransactionsSection, UnmatchDialogs. New @/types.BankTransaction — raw bank amounts stay in CENTS on the wire, so branded `Cents` (formatCentsUSD, not formatUSD), the complement to the Dollars brand. BulkUnmatchState typed with null-safe setState callbacks. typecheck 0, lint 0 errors, build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8a4b51915f
commit
31ff6adbd9
|
|
@ -1,9 +1,18 @@
|
|||
import { cn } from '@/lib/utils';
|
||||
import type { AutopayStats } from '@/types';
|
||||
|
||||
interface AutopayTrustIndicatorProps {
|
||||
isNew?: boolean;
|
||||
autopay?: boolean;
|
||||
stats?: AutopayStats | null;
|
||||
verifiedAt?: Date | null;
|
||||
onVerify?: () => void;
|
||||
}
|
||||
|
||||
// Edit-mode autopay trust panel: success rate over the last 12 months, a
|
||||
// "Mark verified" action, and staleness / failure warnings. Presentational —
|
||||
// the parent owns the verify handler and the optimistic verified-at date.
|
||||
export default function AutopayTrustIndicator({ isNew, autopay, stats, verifiedAt, onVerify }) {
|
||||
export default function AutopayTrustIndicator({ isNew, autopay, stats, verifiedAt, onVerify }: AutopayTrustIndicatorProps) {
|
||||
if (isNew || !autopay) return null;
|
||||
|
||||
const total = stats?.total ?? 0;
|
||||
|
|
@ -2,6 +2,28 @@ import { ChevronDown } from 'lucide-react';
|
|||
import { cn } from '@/lib/utils';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
|
||||
type FormErrors = Record<string, string | null | undefined>;
|
||||
type Validator = (v: string) => string | null;
|
||||
|
||||
interface DebtDetailsSectionProps {
|
||||
inp?: string;
|
||||
errors: FormErrors;
|
||||
setErrors: Dispatch<SetStateAction<FormErrors>>;
|
||||
showDebtSection: boolean;
|
||||
setShowDebtSection: Dispatch<SetStateAction<boolean>>;
|
||||
isSnowballCategory?: boolean;
|
||||
showOnSnowball?: boolean;
|
||||
interestRate: string; setInterestRate: (v: string) => void;
|
||||
currentBalance: string; setCurrentBalance: (v: string) => void;
|
||||
minimumPayment: string; setMinimumPayment: (v: string) => void;
|
||||
validateInterestRate: Validator;
|
||||
validateCurrentBalance: Validator;
|
||||
validateMinimumPayment: Validator;
|
||||
handleBlur: (field: string, value: string, validator: Validator) => void;
|
||||
onSnowballVisibilityChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
// Collapsible Debt / Snowball fields (interest rate, current balance, minimum
|
||||
// payment, snowball visibility). State lives in the parent BillModal (the save
|
||||
|
|
@ -17,7 +39,7 @@ export default function DebtDetailsSection({
|
|||
validateInterestRate, validateCurrentBalance, validateMinimumPayment,
|
||||
handleBlur,
|
||||
onSnowballVisibilityChange,
|
||||
}) {
|
||||
}: DebtDetailsSectionProps) {
|
||||
return (
|
||||
<div className="col-span-2">
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
@ -3,6 +3,21 @@ import { cn, fmtDate } from '@/lib/utils';
|
|||
import { Button } from '@/components/ui/button';
|
||||
import BillMerchantRules from '@/components/BillMerchantRules';
|
||||
import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay';
|
||||
import type { BankTransaction } from '@/types';
|
||||
|
||||
interface LinkedTransactionsSectionProps {
|
||||
isNew?: boolean;
|
||||
billId?: number | string;
|
||||
billName?: string;
|
||||
localHasRules?: boolean;
|
||||
syncingPayments?: boolean;
|
||||
onSync?: () => void;
|
||||
onRulesChanged?: () => void;
|
||||
linkedTransactions: BankTransaction[];
|
||||
linkedTransactionsLoading?: boolean;
|
||||
transactionBusyId?: number | string | null;
|
||||
onUnmatch: (transaction: BankTransaction) => void;
|
||||
}
|
||||
|
||||
// Bank-matching rules (with a Sync-now action) + the list of transactions
|
||||
// confirmed as matched to this bill (each with an Unmatch action). Presentational
|
||||
|
|
@ -19,7 +34,7 @@ export default function LinkedTransactionsSection({
|
|||
linkedTransactionsLoading,
|
||||
transactionBusyId,
|
||||
onUnmatch,
|
||||
}) {
|
||||
}: LinkedTransactionsSectionProps) {
|
||||
return (
|
||||
<>
|
||||
{/* Bank Matching Rules */}
|
||||
|
|
@ -107,7 +122,7 @@ export default function LinkedTransactionsSection({
|
|||
'font-mono text-sm font-semibold tabular-nums',
|
||||
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
||||
)}>
|
||||
{fmtTransactionAmount(transaction.amount, transaction.currency)}
|
||||
{fmtTransactionAmount(transaction.amount, transaction.currency ?? undefined)}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
|
|
@ -5,8 +5,9 @@ import {
|
|||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Payment } from '@/types';
|
||||
|
||||
const PAYMENT_METHODS = [
|
||||
const PAYMENT_METHODS: [string, string][] = [
|
||||
['manual', 'Manual'],
|
||||
['bank', 'Bank Transfer'],
|
||||
['card', 'Card'],
|
||||
|
|
@ -15,6 +16,18 @@ const PAYMENT_METHODS = [
|
|||
['cash', 'Cash'],
|
||||
];
|
||||
|
||||
interface PaymentFormFieldsProps {
|
||||
inp?: string;
|
||||
editingPayment?: Payment | null;
|
||||
paymentBusy?: boolean;
|
||||
amount: string; setAmount: (v: string) => void;
|
||||
date: string; setDate: (v: string) => void;
|
||||
method: string; setMethod: (v: string) => void;
|
||||
notes: string; setNotes: (v: string) => void;
|
||||
onSubmit?: React.FormEventHandler<HTMLFormElement>;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
// Add/edit form for a manual payment on a bill (edit mode). Presentational —
|
||||
// the parent owns the form state, the submit handler, and open/close.
|
||||
export default function PaymentFormFields({
|
||||
|
|
@ -27,7 +40,7 @@ export default function PaymentFormFields({
|
|||
notes, setNotes,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) {
|
||||
}: PaymentFormFieldsProps) {
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="mt-3 rounded-lg border border-border/60 bg-muted/20 p-3">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
|
|
@ -1,35 +1,46 @@
|
|||
import { Plus, Link2, Pencil, Trash2 } from 'lucide-react';
|
||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { Payment } from '@/types';
|
||||
|
||||
function isTransactionLinkedPayment(payment) {
|
||||
function isTransactionLinkedPayment(payment: Payment): boolean {
|
||||
return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null;
|
||||
}
|
||||
|
||||
function isHistoryOnlyPayment(payment) {
|
||||
function isHistoryOnlyPayment(payment: Payment): boolean {
|
||||
return !!payment?.accounting_excluded;
|
||||
}
|
||||
|
||||
function paymentSourceLabel(source) {
|
||||
const labels = {
|
||||
function paymentSourceLabel(source: string | null | undefined): string {
|
||||
const labels: Record<string, string> = {
|
||||
manual: 'Manual',
|
||||
file_import: 'File import',
|
||||
provider_sync: 'Sync',
|
||||
transaction_match: 'Transaction',
|
||||
auto_match: 'SimpleFIN',
|
||||
};
|
||||
return labels[source] || source || 'Manual';
|
||||
return labels[source ?? ''] || source || 'Manual';
|
||||
}
|
||||
|
||||
function paymentSourceTone(source) {
|
||||
const tones = {
|
||||
const PAYMENT_SOURCE_TONES: Record<string, string> = {
|
||||
manual: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
||||
file_import: 'border-sky-500/25 bg-sky-500/10 text-sky-600 dark:text-sky-400',
|
||||
provider_sync: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
transaction_match: 'border-primary/25 bg-primary/10 text-primary',
|
||||
auto_match: 'border-violet-500/25 bg-violet-500/10 text-violet-600 dark:text-violet-400',
|
||||
};
|
||||
return tones[source] || tones.manual;
|
||||
|
||||
function paymentSourceTone(source: string | null | undefined): string {
|
||||
return PAYMENT_SOURCE_TONES[source ?? ''] || PAYMENT_SOURCE_TONES.manual!;
|
||||
}
|
||||
|
||||
interface PaymentHistoryListProps {
|
||||
payments: Payment[];
|
||||
paymentsLoading?: boolean;
|
||||
paymentBusy?: boolean;
|
||||
onAdd?: () => void;
|
||||
onEdit: (payment: Payment) => void;
|
||||
onDelete: (payment: Payment) => void;
|
||||
}
|
||||
|
||||
// Payment-history list for a bill (edit mode): each recorded payment with its
|
||||
|
|
@ -43,7 +54,7 @@ export default function PaymentHistoryList({
|
|||
onAdd,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}) {
|
||||
}: PaymentHistoryListProps) {
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
|
|
@ -1,6 +1,15 @@
|
|||
import { Label } from '@/components/ui/label';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
interface TemplateSectionProps {
|
||||
inp?: string;
|
||||
saveTemplate: boolean;
|
||||
setSaveTemplate: (v: boolean) => void;
|
||||
templateName: string;
|
||||
setTemplateName: (v: string) => void;
|
||||
namePlaceholder?: string;
|
||||
}
|
||||
|
||||
// "Save this setup as a reusable template" toggle + optional template name.
|
||||
// Presentational — the parent owns the state and performs the save.
|
||||
export default function TemplateSection({
|
||||
|
|
@ -8,7 +17,7 @@ export default function TemplateSection({
|
|||
saveTemplate, setSaveTemplate,
|
||||
templateName, setTemplateName,
|
||||
namePlaceholder,
|
||||
}) {
|
||||
}: TemplateSectionProps) {
|
||||
return (
|
||||
<div className="col-span-2 rounded-lg border border-border/60 bg-muted/20 p-3">
|
||||
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||
|
|
@ -10,6 +10,34 @@ import {
|
|||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import type { BankTransaction } from '@/types';
|
||||
|
||||
interface MerchantRuleLite {
|
||||
id: number;
|
||||
merchant: string;
|
||||
}
|
||||
|
||||
export interface BulkUnmatchState {
|
||||
similar: BankTransaction[];
|
||||
checkedIds: Set<number>;
|
||||
rules: MerchantRuleLite[];
|
||||
removeRuleId: number | null;
|
||||
}
|
||||
|
||||
interface UnmatchDialogsProps {
|
||||
unmatchTarget: BankTransaction | null;
|
||||
bulkUnmatch: BulkUnmatchState | null;
|
||||
setBulkUnmatch: Dispatch<SetStateAction<BulkUnmatchState | null>>;
|
||||
unmatchConfirmOpen: boolean;
|
||||
setUnmatchConfirmOpen: (v: boolean) => void;
|
||||
transactionBusyId?: number | string | null;
|
||||
bulkBusy?: boolean;
|
||||
closeUnmatch: () => void;
|
||||
onSingleUnmatch: () => void;
|
||||
onOpenBulkUnmatch: () => void;
|
||||
onBulkConfirm: () => void;
|
||||
}
|
||||
|
||||
// The three unmatch flows for a linked bank transaction: the choice dialog
|
||||
// (single vs. review-similar), the single-unmatch confirm, and the bulk review
|
||||
|
|
@ -25,7 +53,7 @@ export default function UnmatchDialogs({
|
|||
onSingleUnmatch,
|
||||
onOpenBulkUnmatch,
|
||||
onBulkConfirm,
|
||||
}) {
|
||||
}: UnmatchDialogsProps) {
|
||||
return (
|
||||
<>
|
||||
{/* ── Unmatch choice dialog ─────────────────────────────────── */}
|
||||
|
|
@ -47,7 +75,7 @@ export default function UnmatchDialogs({
|
|||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'}
|
||||
{' · '}
|
||||
<span className="font-mono">{fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency)}</span>
|
||||
<span className="font-mono">{fmtTransactionAmount(unmatchTarget.amount, unmatchTarget.currency ?? undefined)}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -100,7 +128,7 @@ export default function UnmatchDialogs({
|
|||
<>
|
||||
<span className="font-medium text-foreground">{transactionTitle(unmatchTarget)}</span>
|
||||
{' '}will be unlinked from this bill.
|
||||
{unmatchTarget.linked_payment?.payment_source === 'provider_sync'
|
||||
{(unmatchTarget.linked_payment as { payment_source?: string } | null | undefined)?.payment_source === 'provider_sync'
|
||||
? ' The payment record will be removed and the balance restored.'
|
||||
: ' The payment record will be removed.'}
|
||||
</>
|
||||
|
|
@ -146,7 +174,7 @@ export default function UnmatchDialogs({
|
|||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setBulkUnmatch(p => ({ ...p, checkedIds: new Set(p.similar.map(t => t.id)) }))}
|
||||
onClick={() => setBulkUnmatch(p => p ? { ...p, checkedIds: new Set(p.similar.map(t => t.id)) } : p)}
|
||||
>
|
||||
All
|
||||
</Button>
|
||||
|
|
@ -155,7 +183,7 @@ export default function UnmatchDialogs({
|
|||
size="sm"
|
||||
variant="outline"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => setBulkUnmatch(p => ({ ...p, checkedIds: new Set() }))}
|
||||
onClick={() => setBulkUnmatch(p => p ? { ...p, checkedIds: new Set<number>() } : p)}
|
||||
>
|
||||
None
|
||||
</Button>
|
||||
|
|
@ -179,8 +207,9 @@ export default function UnmatchDialogs({
|
|||
checked={bulkUnmatch.checkedIds.has(tx.id)}
|
||||
onCheckedChange={checked => {
|
||||
setBulkUnmatch(p => {
|
||||
if (!p) return p;
|
||||
const next = new Set(p.checkedIds);
|
||||
checked ? next.add(tx.id) : next.delete(tx.id);
|
||||
if (checked) next.add(tx.id); else next.delete(tx.id);
|
||||
return { ...p, checkedIds: next };
|
||||
});
|
||||
}}
|
||||
|
|
@ -196,7 +225,7 @@ export default function UnmatchDialogs({
|
|||
'shrink-0 font-mono text-sm font-semibold tabular-nums',
|
||||
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
||||
)}>
|
||||
{fmtTransactionAmount(tx.amount, tx.currency)}
|
||||
{fmtTransactionAmount(tx.amount, tx.currency ?? undefined)}
|
||||
</p>
|
||||
</label>
|
||||
))}
|
||||
|
|
@ -213,7 +242,7 @@ export default function UnmatchDialogs({
|
|||
className="mt-0.5"
|
||||
checked={bulkUnmatch.removeRuleId === rule.id}
|
||||
onCheckedChange={checked =>
|
||||
setBulkUnmatch(p => ({ ...p, removeRuleId: checked ? rule.id : null }))
|
||||
setBulkUnmatch(p => p ? { ...p, removeRuleId: checked ? rule.id : null } : p)
|
||||
}
|
||||
/>
|
||||
<div>
|
||||
|
|
@ -4,25 +4,33 @@ import { formatCentsUSD } from '@/lib/money';
|
|||
// the linked-transaction list, the unmatch dialogs, and the bulk-unmatch payee
|
||||
// grouping.
|
||||
|
||||
export function fmtTransactionAmount(amount, currency = 'USD') {
|
||||
interface TxLike {
|
||||
payee?: string | null;
|
||||
description?: string | null;
|
||||
memo?: string | null;
|
||||
posted_date?: string | null;
|
||||
transacted_at?: string | number | null;
|
||||
}
|
||||
|
||||
export function fmtTransactionAmount(amount: Parameters<typeof formatCentsUSD>[0], currency = 'USD') {
|
||||
return formatCentsUSD(amount, { signed: true, currency });
|
||||
}
|
||||
|
||||
export function transactionDate(tx) {
|
||||
export function transactionDate(tx: TxLike | null | undefined): string | null {
|
||||
return tx?.posted_date || String(tx?.transacted_at || '').slice(0, 10) || null;
|
||||
}
|
||||
|
||||
export function transactionTitle(tx) {
|
||||
export function transactionTitle(tx: TxLike | null | undefined): string {
|
||||
return tx?.payee || tx?.description || tx?.memo || 'Transaction';
|
||||
}
|
||||
|
||||
export function normalizePayee(s) {
|
||||
export function normalizePayee(s: string | null | undefined): string {
|
||||
return (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
// Two payees are "similar" when one normalized name is a prefix of the other
|
||||
// (min 3 chars) — the grouping used to find related matches to unmatch together.
|
||||
export function isSimilarPayee(a, b) {
|
||||
export function isSimilarPayee(a: string | null | undefined, b: string | null | undefined): boolean {
|
||||
const na = normalizePayee(a);
|
||||
const nb = normalizePayee(b);
|
||||
const minLen = Math.min(na.length, nb.length);
|
||||
|
|
@ -8,7 +8,25 @@
|
|||
// Shapes are typed incrementally (endpoint-by-endpoint). Complex nested payloads
|
||||
// that no typed consumer reads yet (trend series, autopay suggestion/stats,
|
||||
// sparkline) are left `unknown` and refined when a consumer needs them.
|
||||
import type { Dollars } from '@/lib/money';
|
||||
import type { Cents, Dollars } from '@/lib/money';
|
||||
|
||||
// A bank transaction as serialized by the server. Unlike bill/payment amounts,
|
||||
// raw bank-transaction amounts stay in CENTS on the wire — hence branded `Cents`
|
||||
// (format them with formatCentsUSD, never formatUSD).
|
||||
export interface BankTransaction {
|
||||
id: number;
|
||||
amount: Cents;
|
||||
currency?: string | null;
|
||||
payee?: string | null;
|
||||
description?: string | null;
|
||||
memo?: string | null;
|
||||
posted_date?: string | null;
|
||||
transacted_at?: string | number | null;
|
||||
account_name?: string | null;
|
||||
source_label?: string | null;
|
||||
source_type_label?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
// A bill's month status. Mirrors the server's statusService.
|
||||
export type TrackerStatus =
|
||||
|
|
|
|||
Loading…
Reference in New Issue