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 { 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
|
// Edit-mode autopay trust panel: success rate over the last 12 months, a
|
||||||
// "Mark verified" action, and staleness / failure warnings. Presentational —
|
// "Mark verified" action, and staleness / failure warnings. Presentational —
|
||||||
// the parent owns the verify handler and the optimistic verified-at date.
|
// 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;
|
if (isNew || !autopay) return null;
|
||||||
|
|
||||||
const total = stats?.total ?? 0;
|
const total = stats?.total ?? 0;
|
||||||
|
|
@ -2,6 +2,28 @@ import { ChevronDown } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Input } from '@/components/ui/input';
|
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
|
// Collapsible Debt / Snowball fields (interest rate, current balance, minimum
|
||||||
// payment, snowball visibility). State lives in the parent BillModal (the save
|
// payment, snowball visibility). State lives in the parent BillModal (the save
|
||||||
|
|
@ -17,7 +39,7 @@ export default function DebtDetailsSection({
|
||||||
validateInterestRate, validateCurrentBalance, validateMinimumPayment,
|
validateInterestRate, validateCurrentBalance, validateMinimumPayment,
|
||||||
handleBlur,
|
handleBlur,
|
||||||
onSnowballVisibilityChange,
|
onSnowballVisibilityChange,
|
||||||
}) {
|
}: DebtDetailsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|
@ -3,6 +3,21 @@ import { cn, fmtDate } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import BillMerchantRules from '@/components/BillMerchantRules';
|
import BillMerchantRules from '@/components/BillMerchantRules';
|
||||||
import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay';
|
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
|
// Bank-matching rules (with a Sync-now action) + the list of transactions
|
||||||
// confirmed as matched to this bill (each with an Unmatch action). Presentational
|
// confirmed as matched to this bill (each with an Unmatch action). Presentational
|
||||||
|
|
@ -19,7 +34,7 @@ export default function LinkedTransactionsSection({
|
||||||
linkedTransactionsLoading,
|
linkedTransactionsLoading,
|
||||||
transactionBusyId,
|
transactionBusyId,
|
||||||
onUnmatch,
|
onUnmatch,
|
||||||
}) {
|
}: LinkedTransactionsSectionProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Bank Matching Rules */}
|
{/* Bank Matching Rules */}
|
||||||
|
|
@ -107,7 +122,7 @@ export default function LinkedTransactionsSection({
|
||||||
'font-mono text-sm font-semibold tabular-nums',
|
'font-mono text-sm font-semibold tabular-nums',
|
||||||
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
Number(transaction.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
||||||
)}>
|
)}>
|
||||||
{fmtTransactionAmount(transaction.amount, transaction.currency)}
|
{fmtTransactionAmount(transaction.amount, transaction.currency ?? undefined)}
|
||||||
</p>
|
</p>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -5,8 +5,9 @@ import {
|
||||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import type { Payment } from '@/types';
|
||||||
|
|
||||||
const PAYMENT_METHODS = [
|
const PAYMENT_METHODS: [string, string][] = [
|
||||||
['manual', 'Manual'],
|
['manual', 'Manual'],
|
||||||
['bank', 'Bank Transfer'],
|
['bank', 'Bank Transfer'],
|
||||||
['card', 'Card'],
|
['card', 'Card'],
|
||||||
|
|
@ -15,6 +16,18 @@ const PAYMENT_METHODS = [
|
||||||
['cash', 'Cash'],
|
['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 —
|
// 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.
|
// the parent owns the form state, the submit handler, and open/close.
|
||||||
export default function PaymentFormFields({
|
export default function PaymentFormFields({
|
||||||
|
|
@ -27,7 +40,7 @@ export default function PaymentFormFields({
|
||||||
notes, setNotes,
|
notes, setNotes,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onCancel,
|
onCancel,
|
||||||
}) {
|
}: PaymentFormFieldsProps) {
|
||||||
return (
|
return (
|
||||||
<form onSubmit={onSubmit} className="mt-3 rounded-lg border border-border/60 bg-muted/20 p-3">
|
<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">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
|
@ -1,35 +1,46 @@
|
||||||
import { Plus, Link2, Pencil, Trash2 } from 'lucide-react';
|
import { Plus, Link2, Pencil, Trash2 } from 'lucide-react';
|
||||||
import { cn, fmt, fmtDate } from '@/lib/utils';
|
import { cn, fmt, fmtDate } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
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;
|
return payment?.payment_source === 'transaction_match' || payment?.transaction_id != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isHistoryOnlyPayment(payment) {
|
function isHistoryOnlyPayment(payment: Payment): boolean {
|
||||||
return !!payment?.accounting_excluded;
|
return !!payment?.accounting_excluded;
|
||||||
}
|
}
|
||||||
|
|
||||||
function paymentSourceLabel(source) {
|
function paymentSourceLabel(source: string | null | undefined): string {
|
||||||
const labels = {
|
const labels: Record<string, string> = {
|
||||||
manual: 'Manual',
|
manual: 'Manual',
|
||||||
file_import: 'File import',
|
file_import: 'File import',
|
||||||
provider_sync: 'Sync',
|
provider_sync: 'Sync',
|
||||||
transaction_match: 'Transaction',
|
transaction_match: 'Transaction',
|
||||||
auto_match: 'SimpleFIN',
|
auto_match: 'SimpleFIN',
|
||||||
};
|
};
|
||||||
return labels[source] || source || 'Manual';
|
return labels[source ?? ''] || source || 'Manual';
|
||||||
}
|
}
|
||||||
|
|
||||||
function paymentSourceTone(source) {
|
const PAYMENT_SOURCE_TONES: Record<string, string> = {
|
||||||
const tones = {
|
|
||||||
manual: 'border-emerald-500/25 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400',
|
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',
|
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',
|
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',
|
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',
|
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
|
// Payment-history list for a bill (edit mode): each recorded payment with its
|
||||||
|
|
@ -43,7 +54,7 @@ export default function PaymentHistoryList({
|
||||||
onAdd,
|
onAdd,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}) {
|
}: PaymentHistoryListProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="mb-3 flex items-center justify-between gap-3">
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
|
@ -1,6 +1,15 @@
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Input } from '@/components/ui/input';
|
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.
|
// "Save this setup as a reusable template" toggle + optional template name.
|
||||||
// Presentational — the parent owns the state and performs the save.
|
// Presentational — the parent owns the state and performs the save.
|
||||||
export default function TemplateSection({
|
export default function TemplateSection({
|
||||||
|
|
@ -8,7 +17,7 @@ export default function TemplateSection({
|
||||||
saveTemplate, setSaveTemplate,
|
saveTemplate, setSaveTemplate,
|
||||||
templateName, setTemplateName,
|
templateName, setTemplateName,
|
||||||
namePlaceholder,
|
namePlaceholder,
|
||||||
}) {
|
}: TemplateSectionProps) {
|
||||||
return (
|
return (
|
||||||
<div className="col-span-2 rounded-lg border border-border/60 bg-muted/20 p-3">
|
<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">
|
<label className="flex items-center gap-2.5 cursor-pointer group">
|
||||||
|
|
@ -10,6 +10,34 @@ import {
|
||||||
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction,
|
||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { transactionTitle, transactionDate, fmtTransactionAmount } from '@/components/bill-modal/transactionDisplay';
|
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
|
// The three unmatch flows for a linked bank transaction: the choice dialog
|
||||||
// (single vs. review-similar), the single-unmatch confirm, and the bulk review
|
// (single vs. review-similar), the single-unmatch confirm, and the bulk review
|
||||||
|
|
@ -25,7 +53,7 @@ export default function UnmatchDialogs({
|
||||||
onSingleUnmatch,
|
onSingleUnmatch,
|
||||||
onOpenBulkUnmatch,
|
onOpenBulkUnmatch,
|
||||||
onBulkConfirm,
|
onBulkConfirm,
|
||||||
}) {
|
}: UnmatchDialogsProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* ── Unmatch choice dialog ─────────────────────────────────── */}
|
{/* ── Unmatch choice dialog ─────────────────────────────────── */}
|
||||||
|
|
@ -47,7 +75,7 @@ export default function UnmatchDialogs({
|
||||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||||
{transactionDate(unmatchTarget) ? fmtDate(transactionDate(unmatchTarget)) : 'No date'}
|
{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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -100,7 +128,7 @@ export default function UnmatchDialogs({
|
||||||
<>
|
<>
|
||||||
<span className="font-medium text-foreground">{transactionTitle(unmatchTarget)}</span>
|
<span className="font-medium text-foreground">{transactionTitle(unmatchTarget)}</span>
|
||||||
{' '}will be unlinked from this bill.
|
{' '}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 and the balance restored.'
|
||||||
: ' The payment record will be removed.'}
|
: ' The payment record will be removed.'}
|
||||||
</>
|
</>
|
||||||
|
|
@ -146,7 +174,7 @@ export default function UnmatchDialogs({
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-6 px-2 text-xs"
|
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
|
All
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -155,7 +183,7 @@ export default function UnmatchDialogs({
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-6 px-2 text-xs"
|
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
|
None
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -179,8 +207,9 @@ export default function UnmatchDialogs({
|
||||||
checked={bulkUnmatch.checkedIds.has(tx.id)}
|
checked={bulkUnmatch.checkedIds.has(tx.id)}
|
||||||
onCheckedChange={checked => {
|
onCheckedChange={checked => {
|
||||||
setBulkUnmatch(p => {
|
setBulkUnmatch(p => {
|
||||||
|
if (!p) return p;
|
||||||
const next = new Set(p.checkedIds);
|
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 };
|
return { ...p, checkedIds: next };
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
|
|
@ -196,7 +225,7 @@ export default function UnmatchDialogs({
|
||||||
'shrink-0 font-mono text-sm font-semibold tabular-nums',
|
'shrink-0 font-mono text-sm font-semibold tabular-nums',
|
||||||
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
Number(tx.amount) < 0 ? 'text-destructive' : 'text-emerald-600',
|
||||||
)}>
|
)}>
|
||||||
{fmtTransactionAmount(tx.amount, tx.currency)}
|
{fmtTransactionAmount(tx.amount, tx.currency ?? undefined)}
|
||||||
</p>
|
</p>
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
|
|
@ -213,7 +242,7 @@ export default function UnmatchDialogs({
|
||||||
className="mt-0.5"
|
className="mt-0.5"
|
||||||
checked={bulkUnmatch.removeRuleId === rule.id}
|
checked={bulkUnmatch.removeRuleId === rule.id}
|
||||||
onCheckedChange={checked =>
|
onCheckedChange={checked =>
|
||||||
setBulkUnmatch(p => ({ ...p, removeRuleId: checked ? rule.id : null }))
|
setBulkUnmatch(p => p ? { ...p, removeRuleId: checked ? rule.id : null } : p)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -4,25 +4,33 @@ import { formatCentsUSD } from '@/lib/money';
|
||||||
// the linked-transaction list, the unmatch dialogs, and the bulk-unmatch payee
|
// the linked-transaction list, the unmatch dialogs, and the bulk-unmatch payee
|
||||||
// grouping.
|
// 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 });
|
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;
|
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';
|
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, '');
|
return (s || '').toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two payees are "similar" when one normalized name is a prefix of the other
|
// 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.
|
// (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 na = normalizePayee(a);
|
||||||
const nb = normalizePayee(b);
|
const nb = normalizePayee(b);
|
||||||
const minLen = Math.min(na.length, nb.length);
|
const minLen = Math.min(na.length, nb.length);
|
||||||
|
|
@ -8,7 +8,25 @@
|
||||||
// Shapes are typed incrementally (endpoint-by-endpoint). Complex nested payloads
|
// Shapes are typed incrementally (endpoint-by-endpoint). Complex nested payloads
|
||||||
// that no typed consumer reads yet (trend series, autopay suggestion/stats,
|
// that no typed consumer reads yet (trend series, autopay suggestion/stats,
|
||||||
// sparkline) are left `unknown` and refined when a consumer needs them.
|
// 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.
|
// A bill's month status. Mirrors the server's statusService.
|
||||||
export type TrackerStatus =
|
export type TrackerStatus =
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue