refactor(ts): MobileBillRow, IncomeBreakdownModal, CalendarFeedManager → TS
Also removed a stale PayoffChart.jsx duplicate (its .tsx was already committed). Branded Dollars on income transactions/bank-tracking balances. typecheck 0.
This commit is contained in:
parent
af1a28c00b
commit
41e847a33d
|
|
@ -1,13 +1,27 @@
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import { useCallback, useEffect, useState, type ReactNode } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { CalendarDays, Copy, Eye, KeyRound, RefreshCw, ShieldOff, Settings2 } from 'lucide-react';
|
import { CalendarDays, Copy, Eye, KeyRound, RefreshCw, ShieldOff, Settings2 } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn, errMessage } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
function PlatformNote({ title, children }) {
|
interface CalFeed {
|
||||||
|
active?: boolean;
|
||||||
|
feed_url?: string;
|
||||||
|
last_used_at?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CalEvent {
|
||||||
|
uid: string;
|
||||||
|
name?: string;
|
||||||
|
due_date?: string;
|
||||||
|
cycle_type?: string;
|
||||||
|
amount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlatformNote({ title, children }: { title: ReactNode; children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md bg-muted/35 p-3">
|
<div className="rounded-md bg-muted/35 p-3">
|
||||||
<p className="font-medium text-foreground">{title}</p>
|
<p className="font-medium text-foreground">{title}</p>
|
||||||
|
|
@ -16,25 +30,25 @@ function PlatformNote({ title, children }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CalendarFeedManager({ compact = false, showManageLink = false }) {
|
export function CalendarFeedManager({ compact = false, showManageLink = false }: { compact?: boolean; showManageLink?: boolean }) {
|
||||||
const [feed, setFeed] = useState(null);
|
const [feed, setFeed] = useState<CalFeed | null>(null);
|
||||||
const [preview, setPreview] = useState([]);
|
const [preview, setPreview] = useState<CalEvent[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [busy, setBusy] = useState(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
const loadFeed = useCallback(async () => {
|
const loadFeed = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const data = await api.calendarFeed();
|
const data = await api.calendarFeed() as CalFeed;
|
||||||
setFeed(data);
|
setFeed(data);
|
||||||
if (data?.active) {
|
if (data?.active) {
|
||||||
const nextPreview = await api.calendarFeedPreview(10);
|
const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] };
|
||||||
setPreview(nextPreview.events || []);
|
setPreview(nextPreview.events || []);
|
||||||
} else {
|
} else {
|
||||||
setPreview([]);
|
setPreview([]);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to load calendar feed.');
|
toast.error(errMessage(err, 'Failed to load calendar feed.'));
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -47,13 +61,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
|
||||||
async function createFeed() {
|
async function createFeed() {
|
||||||
setBusy('create');
|
setBusy('create');
|
||||||
try {
|
try {
|
||||||
const data = await api.createCalendarFeed();
|
const data = await api.createCalendarFeed() as CalFeed;
|
||||||
setFeed(data);
|
setFeed(data);
|
||||||
const nextPreview = await api.calendarFeedPreview(10);
|
const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] };
|
||||||
setPreview(nextPreview.events || []);
|
setPreview(nextPreview.events || []);
|
||||||
toast.success('Calendar feed created.');
|
toast.success('Calendar feed created.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to create calendar feed.');
|
toast.error(errMessage(err, 'Failed to create calendar feed.'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
|
|
@ -72,13 +86,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
|
||||||
async function regenerateFeed() {
|
async function regenerateFeed() {
|
||||||
setBusy('regenerate');
|
setBusy('regenerate');
|
||||||
try {
|
try {
|
||||||
const data = await api.regenerateCalendarFeed();
|
const data = await api.regenerateCalendarFeed() as CalFeed;
|
||||||
setFeed(data);
|
setFeed(data);
|
||||||
const nextPreview = await api.calendarFeedPreview(10);
|
const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] };
|
||||||
setPreview(nextPreview.events || []);
|
setPreview(nextPreview.events || []);
|
||||||
toast.success('Calendar feed regenerated. Update any subscribed calendars with the new URL.');
|
toast.success('Calendar feed regenerated. Update any subscribed calendars with the new URL.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to regenerate calendar feed.');
|
toast.error(errMessage(err, 'Failed to regenerate calendar feed.'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
|
|
@ -87,12 +101,12 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
|
||||||
async function revokeFeed() {
|
async function revokeFeed() {
|
||||||
setBusy('revoke');
|
setBusy('revoke');
|
||||||
try {
|
try {
|
||||||
const data = await api.revokeCalendarFeed();
|
const data = await api.revokeCalendarFeed() as CalFeed;
|
||||||
setFeed(data);
|
setFeed(data);
|
||||||
setPreview([]);
|
setPreview([]);
|
||||||
toast.success('Calendar feed revoked.');
|
toast.success('Calendar feed revoked.');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to revoke calendar feed.');
|
toast.error(errMessage(err, 'Failed to revoke calendar feed.'));
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null);
|
setBusy(null);
|
||||||
}
|
}
|
||||||
|
|
@ -150,13 +164,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row">
|
<div className="flex flex-col gap-2 sm:flex-row">
|
||||||
<Input value={feed.feed_url} readOnly className="min-w-0 flex-1 font-mono text-xs" aria-label="Calendar feed URL" />
|
<Input value={feed?.feed_url ?? ''} readOnly className="min-w-0 flex-1 font-mono text-xs" aria-label="Calendar feed URL" />
|
||||||
<Button size="sm" variant="outline" onClick={copyFeedUrl} className="gap-2">
|
<Button size="sm" variant="outline" onClick={copyFeedUrl} className="gap-2">
|
||||||
<Copy className="h-3.5 w-3.5" />
|
<Copy className="h-3.5 w-3.5" />
|
||||||
Copy URL
|
Copy URL
|
||||||
</Button>
|
</Button>
|
||||||
<Button asChild size="sm" variant="outline" className="gap-2">
|
<Button asChild size="sm" variant="outline" className="gap-2">
|
||||||
<a href={feed.feed_url} target="_blank" rel="noreferrer">
|
<a href={feed?.feed_url} target="_blank" rel="noreferrer">
|
||||||
<Eye className="h-3.5 w-3.5" />
|
<Eye className="h-3.5 w-3.5" />
|
||||||
Preview ICS
|
Preview ICS
|
||||||
</a>
|
</a>
|
||||||
|
|
@ -182,7 +196,7 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
|
||||||
<div className="flex flex-col gap-1 border-b border-border/60 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-1 border-b border-border/60 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Next events that will appear</p>
|
<p className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Next events that will appear</p>
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
Last fetched: {feed.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'}
|
Last fetched: {feed?.last_used_at ? new Date(feed.last_used_at).toLocaleString() : 'Not yet'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-border/50">
|
<div className="divide-y divide-border/50">
|
||||||
|
|
@ -1,33 +1,60 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { formatUSD } from '@/lib/money';
|
import { formatUSD, asDollars, type Dollars } from '@/lib/money';
|
||||||
import { TrendingUp, EyeOff, Eye, ArrowRight, Loader2 } from 'lucide-react';
|
import { TrendingUp, EyeOff, Eye, Loader2 } from 'lucide-react';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
|
import { errMessage } from '@/lib/utils';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
function fmt(n) {
|
function fmt(n: Parameters<typeof formatUSD>[0]): string {
|
||||||
return formatUSD(n);
|
return formatUSD(n);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }) {
|
interface IncomeTx {
|
||||||
const [transactions, setTransactions] = useState([]);
|
id: number | string;
|
||||||
|
payee?: string | null;
|
||||||
|
date?: string | null;
|
||||||
|
amount: Dollars;
|
||||||
|
ignored?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IncomeBt {
|
||||||
|
enabled?: boolean;
|
||||||
|
org_name?: string | null;
|
||||||
|
account_name?: string | null;
|
||||||
|
balance?: Dollars;
|
||||||
|
pending_payments?: Dollars;
|
||||||
|
pending_days?: number;
|
||||||
|
effective_balance?: Dollars;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IncomeBreakdownModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
bankTracking?: IncomeBt | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }: IncomeBreakdownModalProps) {
|
||||||
|
const [transactions, setTransactions] = useState<IncomeTx[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadError, setLoadError] = useState('');
|
const [loadError, setLoadError] = useState('');
|
||||||
const [showIgnored, setShowIgnored] = useState(false);
|
const [showIgnored, setShowIgnored] = useState(false);
|
||||||
const [actionId, setActionId] = useState(null); // tx being acted on
|
const [actionId, setActionId] = useState<number | string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadError('');
|
setLoadError('');
|
||||||
try {
|
try {
|
||||||
const d = await api.spendingIncome({ year, month, include_ignored: showIgnored ? 'true' : undefined, limit: 100 });
|
const d = await api.spendingIncome({ year, month, include_ignored: showIgnored ? 'true' : undefined, limit: 100 }) as { transactions?: IncomeTx[] };
|
||||||
setTransactions(d.transactions || []);
|
setTransactions(d.transactions || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err.message || 'Failed to load income transactions';
|
const msg = errMessage(err, 'Failed to load income transactions');
|
||||||
setLoadError(msg);
|
setLoadError(msg);
|
||||||
toast.error(msg);
|
toast.error(msg);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -37,13 +64,13 @@ export default function IncomeBreakdownModal({ open, onClose, year, month, bankT
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
const handleIgnore = async (tx) => {
|
const handleIgnore = async (tx: IncomeTx) => {
|
||||||
setActionId(tx.id);
|
setActionId(tx.id);
|
||||||
try {
|
try {
|
||||||
await api.ignoreTransaction(tx.id);
|
await api.ignoreTransaction(tx.id);
|
||||||
toast.success(`"${tx.payee}" marked as excluded`);
|
toast.success(`"${tx.payee}" marked as excluded`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to exclude transaction');
|
toast.error(errMessage(err, 'Failed to exclude transaction'));
|
||||||
setActionId(null);
|
setActionId(null);
|
||||||
return; // don't reload if the action itself failed
|
return; // don't reload if the action itself failed
|
||||||
}
|
}
|
||||||
|
|
@ -51,13 +78,13 @@ export default function IncomeBreakdownModal({ open, onClose, year, month, bankT
|
||||||
await load(); // reload outside the action try so load errors are surfaced separately
|
await load(); // reload outside the action try so load errors are surfaced separately
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUnignore = async (tx) => {
|
const handleUnignore = async (tx: IncomeTx) => {
|
||||||
setActionId(tx.id);
|
setActionId(tx.id);
|
||||||
try {
|
try {
|
||||||
await api.unignoreTransaction(tx.id);
|
await api.unignoreTransaction(tx.id);
|
||||||
toast.success(`"${tx.payee}" restored as income`);
|
toast.success(`"${tx.payee}" restored as income`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
toast.error(err.message || 'Failed to restore transaction');
|
toast.error(errMessage(err, 'Failed to restore transaction'));
|
||||||
setActionId(null);
|
setActionId(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +94,7 @@ export default function IncomeBreakdownModal({ open, onClose, year, month, bankT
|
||||||
|
|
||||||
const active = transactions.filter(t => !t.ignored);
|
const active = transactions.filter(t => !t.ignored);
|
||||||
const ignored = transactions.filter(t => t.ignored);
|
const ignored = transactions.filter(t => t.ignored);
|
||||||
const total = active.reduce((s, t) => s + t.amount, 0);
|
const total = asDollars(active.reduce((s, t) => s + t.amount, 0));
|
||||||
|
|
||||||
const bt = bankTracking || {};
|
const bt = bankTracking || {};
|
||||||
|
|
||||||
|
|
@ -91,7 +118,7 @@ export default function IncomeBreakdownModal({ open, onClose, year, month, bankT
|
||||||
<span className="text-muted-foreground">{bt.org_name || bt.account_name || 'Bank'} balance</span>
|
<span className="text-muted-foreground">{bt.org_name || bt.account_name || 'Bank'} balance</span>
|
||||||
<span className="font-mono font-semibold">{fmt(bt.balance)}</span>
|
<span className="font-mono font-semibold">{fmt(bt.balance)}</span>
|
||||||
</div>
|
</div>
|
||||||
{bt.pending_payments > 0 && (
|
{(bt.pending_payments ?? 0) > 0 && (
|
||||||
<div className="flex items-center justify-between text-muted-foreground">
|
<div className="flex items-center justify-between text-muted-foreground">
|
||||||
<span>Pending payments ({bt.pending_days}d window)</span>
|
<span>Pending payments ({bt.pending_days}d window)</span>
|
||||||
<span className="font-mono text-destructive">−{fmt(bt.pending_payments)}</span>
|
<span className="font-mono text-destructive">−{fmt(bt.pending_payments)}</span>
|
||||||
|
|
@ -3,13 +3,26 @@ import { ArrowDown, ArrowUp, Copy, GripVertical, History } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { scheduleLabel } from '@/lib/billingSchedule';
|
import { scheduleLabel } from '@/lib/billingSchedule';
|
||||||
|
import type { Bill } from '@/types';
|
||||||
|
import type { DragProps, MoveControls } from '@/components/tracker/MobileTrackerRow';
|
||||||
|
|
||||||
function hasHistoricalVisibility(bill) {
|
function hasHistoricalVisibility(bill: Bill): boolean {
|
||||||
const visibility = bill.history_visibility;
|
const visibility = bill.history_visibility;
|
||||||
return !!bill.has_history_ranges || (visibility && visibility !== 'default');
|
return !!bill.has_history_ranges || (!!visibility && visibility !== 'default');
|
||||||
}
|
}
|
||||||
|
|
||||||
export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }) {
|
interface MobileBillRowProps {
|
||||||
|
bill: Bill;
|
||||||
|
onEdit?: (id: number) => void;
|
||||||
|
onToggle?: (bill: Bill) => void;
|
||||||
|
onDelete?: (bill: Bill) => void;
|
||||||
|
onHistory?: (bill: Bill) => void;
|
||||||
|
onDuplicate?: (bill: Bill) => void;
|
||||||
|
moveControls?: MoveControls;
|
||||||
|
dragProps?: DragProps;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, onToggle, onDelete, onHistory, onDuplicate, moveControls, dragProps }: MobileBillRowProps) {
|
||||||
const hasHistory = useMemo(() => hasHistoricalVisibility(bill), [bill]);
|
const hasHistory = useMemo(() => hasHistoricalVisibility(bill), [bill]);
|
||||||
|
|
||||||
const statusClass = useMemo(() => {
|
const statusClass = useMemo(() => {
|
||||||
|
|
@ -33,11 +46,11 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
draggable={dragProps?.draggable}
|
draggable={dragProps?.draggable}
|
||||||
onDragStart={dragProps?.onDragStart}
|
onDragStart={dragProps?.onDragStart as React.ComponentProps<'div'>['onDragStart']}
|
||||||
onDragEnter={dragProps?.onDragEnter}
|
onDragEnter={dragProps?.onDragEnter as React.ComponentProps<'div'>['onDragEnter']}
|
||||||
onDragOver={dragProps?.onDragOver}
|
onDragOver={dragProps?.onDragOver as React.ComponentProps<'div'>['onDragOver']}
|
||||||
onDragEnd={dragProps?.onDragEnd}
|
onDragEnd={dragProps?.onDragEnd as React.ComponentProps<'div'>['onDragEnd']}
|
||||||
onDrop={dragProps?.onDrop}
|
onDrop={dragProps?.onDrop as React.ComponentProps<'div'>['onDrop']}
|
||||||
className={cn(
|
className={cn(
|
||||||
'group rounded-xl border border-border/80 bg-card/90 p-3 shadow-sm shadow-black/10',
|
'group rounded-xl border border-border/80 bg-card/90 p-3 shadow-sm shadow-black/10',
|
||||||
dragProps?.isDragging && 'opacity-45',
|
dragProps?.isDragging && 'opacity-45',
|
||||||
|
|
@ -103,18 +116,18 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
|
||||||
<span className={statusClass}>
|
<span className={statusClass}>
|
||||||
{bill.active ? 'Active' : 'Inactive'}
|
{bill.active ? 'Active' : 'Inactive'}
|
||||||
</span>
|
</span>
|
||||||
{bill.autopay_enabled && (
|
{bill.autopay_enabled ? (
|
||||||
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-300">AP</span>
|
<span className="rounded bg-emerald-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-300">AP</span>
|
||||||
)}
|
) : null}
|
||||||
{bill.has_2fa && (
|
{bill.has_2fa ? (
|
||||||
<span className="rounded bg-violet-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-violet-300">2FA</span>
|
<span className="rounded bg-violet-500/20 px-1.5 py-0.5 text-[10px] font-semibold text-violet-300">2FA</span>
|
||||||
)}
|
) : null}
|
||||||
{(bill.has_merchant_rule || bill.has_linked_transactions) && (
|
{(bill.has_merchant_rule || bill.has_linked_transactions) ? (
|
||||||
<span className="rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400" title="Linked to bank transactions">L</span>
|
<span className="rounded border border-emerald-500/25 bg-emerald-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-emerald-600 dark:text-emerald-400" title="Linked to bank transactions">L</span>
|
||||||
)}
|
) : null}
|
||||||
{bill.is_subscription && (
|
{bill.is_subscription ? (
|
||||||
<span className="rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-600 dark:text-indigo-300">S</span>
|
<span className="rounded border border-indigo-500/25 bg-indigo-500/10 px-1.5 py-0.5 text-[10px] font-semibold text-indigo-600 dark:text-indigo-300">S</span>
|
||||||
)}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -1,145 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { formatUSD } from '@/lib/money';
|
|
||||||
|
|
||||||
const W = 720;
|
|
||||||
const H = 300;
|
|
||||||
const PAD = { left: 68, right: 24, top: 20, bottom: 56 };
|
|
||||||
const CW = W - PAD.left - PAD.right;
|
|
||||||
const CH = H - PAD.top - PAD.bottom;
|
|
||||||
|
|
||||||
function money(v) {
|
|
||||||
const n = Number(v) || 0;
|
|
||||||
if (n >= 1000) return `$${(n / 1000).toFixed(0)}k`;
|
|
||||||
return `$${n.toFixed(0)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function fullMoney(v) {
|
|
||||||
return formatUSD(v);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPoints(track, startBalance, maxMonths) {
|
|
||||||
const all = [{ month: 0, balance: startBalance }, ...track];
|
|
||||||
return all.map(({ month, balance }) => ({
|
|
||||||
x: PAD.left + (month / maxMonths) * CW,
|
|
||||||
y: PAD.top + CH - (balance / startBalance) * CH,
|
|
||||||
month,
|
|
||||||
balance,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
function toLine(pts) {
|
|
||||||
return pts.map(p => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PayoffChart({ minTrack = [], currentTrack = [], simTrack = [], startBalance = 1 }) {
|
|
||||||
const maxMonths = Math.max(minTrack.length, currentTrack.length, simTrack.length, 12);
|
|
||||||
const bal = Math.max(startBalance, 1);
|
|
||||||
|
|
||||||
const minPts = buildPoints(minTrack, bal, maxMonths);
|
|
||||||
const currentPts = buildPoints(currentTrack, bal, maxMonths);
|
|
||||||
const simPts = buildPoints(simTrack, bal, maxMonths);
|
|
||||||
|
|
||||||
const xStep = maxMonths <= 24 ? 6 : maxMonths <= 60 ? 12 : 24;
|
|
||||||
const xLabels = [];
|
|
||||||
for (let m = xStep; m <= maxMonths; m += xStep) {
|
|
||||||
xLabels.push(m);
|
|
||||||
}
|
|
||||||
|
|
||||||
const yTicks = [0, 0.25, 0.5, 0.75, 1];
|
|
||||||
|
|
||||||
const showCurrent = currentTrack.length > 0 &&
|
|
||||||
currentTrack.some((c, i) => (minTrack[i]?.balance ?? null) !== c.balance);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full overflow-hidden rounded-xl border border-border/60 bg-background/60">
|
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} role="img" aria-label="Loan payoff chart" className="h-auto w-full">
|
|
||||||
|
|
||||||
{/* Grid + Y axis */}
|
|
||||||
{yTicks.map(tick => {
|
|
||||||
const y = PAD.top + CH - tick * CH;
|
|
||||||
return (
|
|
||||||
<g key={tick}>
|
|
||||||
<line x1={PAD.left} x2={PAD.left + CW} y1={y} y2={y}
|
|
||||||
stroke="currentColor" opacity="0.08" strokeWidth="1" />
|
|
||||||
<text x={PAD.left - 6} y={y + 4} fontSize="11" fill="currentColor"
|
|
||||||
opacity="0.5" textAnchor="end">
|
|
||||||
{money(bal * tick)}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* X axis labels */}
|
|
||||||
{xLabels.map(m => {
|
|
||||||
const x = PAD.left + (m / maxMonths) * CW;
|
|
||||||
return (
|
|
||||||
<text key={m} x={x} y={H - 38} fontSize="11" fill="currentColor"
|
|
||||||
opacity="0.5" textAnchor="middle">
|
|
||||||
{m}mo
|
|
||||||
</text>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* X axis baseline */}
|
|
||||||
<line x1={PAD.left} x2={PAD.left + CW} y1={PAD.top + CH} y2={PAD.top + CH}
|
|
||||||
stroke="currentColor" opacity="0.12" strokeWidth="1" />
|
|
||||||
|
|
||||||
{/* Min-only track (slate dashed) */}
|
|
||||||
{minPts.length > 1 && (
|
|
||||||
<polyline points={toLine(minPts)} fill="none"
|
|
||||||
stroke="#94a3b8" strokeWidth="1.5"
|
|
||||||
strokeDasharray="6,4" strokeLinecap="round" strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Current snowball plan (indigo dashed) */}
|
|
||||||
{showCurrent && currentPts.length > 1 && (
|
|
||||||
<polyline points={toLine(currentPts)} fill="none"
|
|
||||||
stroke="#818cf8" strokeWidth="1.5"
|
|
||||||
strokeDasharray="9,5" strokeLinecap="round" strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Simulation track (amber solid, prominent) */}
|
|
||||||
{simPts.length > 1 && (
|
|
||||||
<>
|
|
||||||
<polyline points={toLine(simPts)} fill="none"
|
|
||||||
stroke="#f59e0b" strokeWidth="3"
|
|
||||||
strokeLinecap="round" strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
{/* Endpoint dot */}
|
|
||||||
{(() => {
|
|
||||||
const last = simPts[simPts.length - 1];
|
|
||||||
return <circle cx={last.x} cy={last.y} r="4" fill="#f59e0b" />;
|
|
||||||
})()}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tooltips at 6-month intervals on sim track */}
|
|
||||||
{simPts.filter(p => p.month > 0 && p.month % 6 === 0).map(p => (
|
|
||||||
<circle key={p.month} cx={p.x} cy={p.y} r="3" fill="#f59e0b" opacity="0.7">
|
|
||||||
<title>{`Month ${p.month}: ${fullMoney(p.balance)} remaining`}</title>
|
|
||||||
</circle>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Legend */}
|
|
||||||
<g transform={`translate(${PAD.left}, ${H - 22})`} fontSize="11" fill="currentColor" opacity="0.7">
|
|
||||||
<line x1="0" x2="16" y1="0" y2="0" stroke="#94a3b8" strokeWidth="1.5" strokeDasharray="4,3" />
|
|
||||||
<text x="20" y="4">Min only</text>
|
|
||||||
|
|
||||||
{showCurrent && (
|
|
||||||
<>
|
|
||||||
<line x1="80" x2="96" y1="0" y2="0" stroke="#818cf8" strokeWidth="1.5" strokeDasharray="6,4" />
|
|
||||||
<text x="100" y="4">Snowball plan</text>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<line x1={showCurrent ? 198 : 80} x2={showCurrent ? 214 : 96} y1="0" y2="0"
|
|
||||||
stroke="#f59e0b" strokeWidth="2.5" />
|
|
||||||
<text x={showCurrent ? 218 : 100} y="4">Simulation</text>
|
|
||||||
</g>
|
|
||||||
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue