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 { CalendarDays, Copy, Eye, KeyRound, RefreshCw, ShieldOff, Settings2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { api } from '@/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cn, errMessage } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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 (
|
||||
<div className="rounded-md bg-muted/35 p-3">
|
||||
<p className="font-medium text-foreground">{title}</p>
|
||||
|
|
@ -16,25 +30,25 @@ function PlatformNote({ title, children }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function CalendarFeedManager({ compact = false, showManageLink = false }) {
|
||||
const [feed, setFeed] = useState(null);
|
||||
const [preview, setPreview] = useState([]);
|
||||
export function CalendarFeedManager({ compact = false, showManageLink = false }: { compact?: boolean; showManageLink?: boolean }) {
|
||||
const [feed, setFeed] = useState<CalFeed | null>(null);
|
||||
const [preview, setPreview] = useState<CalEvent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [busy, setBusy] = useState(null);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
|
||||
const loadFeed = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.calendarFeed();
|
||||
const data = await api.calendarFeed() as CalFeed;
|
||||
setFeed(data);
|
||||
if (data?.active) {
|
||||
const nextPreview = await api.calendarFeedPreview(10);
|
||||
const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] };
|
||||
setPreview(nextPreview.events || []);
|
||||
} else {
|
||||
setPreview([]);
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to load calendar feed.');
|
||||
toast.error(errMessage(err, 'Failed to load calendar feed.'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
|
@ -47,13 +61,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
|
|||
async function createFeed() {
|
||||
setBusy('create');
|
||||
try {
|
||||
const data = await api.createCalendarFeed();
|
||||
const data = await api.createCalendarFeed() as CalFeed;
|
||||
setFeed(data);
|
||||
const nextPreview = await api.calendarFeedPreview(10);
|
||||
const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] };
|
||||
setPreview(nextPreview.events || []);
|
||||
toast.success('Calendar feed created.');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to create calendar feed.');
|
||||
toast.error(errMessage(err, 'Failed to create calendar feed.'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
|
@ -72,13 +86,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
|
|||
async function regenerateFeed() {
|
||||
setBusy('regenerate');
|
||||
try {
|
||||
const data = await api.regenerateCalendarFeed();
|
||||
const data = await api.regenerateCalendarFeed() as CalFeed;
|
||||
setFeed(data);
|
||||
const nextPreview = await api.calendarFeedPreview(10);
|
||||
const nextPreview = await api.calendarFeedPreview(10) as { events?: CalEvent[] };
|
||||
setPreview(nextPreview.events || []);
|
||||
toast.success('Calendar feed regenerated. Update any subscribed calendars with the new URL.');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to regenerate calendar feed.');
|
||||
toast.error(errMessage(err, 'Failed to regenerate calendar feed.'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
|
@ -87,12 +101,12 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
|
|||
async function revokeFeed() {
|
||||
setBusy('revoke');
|
||||
try {
|
||||
const data = await api.revokeCalendarFeed();
|
||||
const data = await api.revokeCalendarFeed() as CalFeed;
|
||||
setFeed(data);
|
||||
setPreview([]);
|
||||
toast.success('Calendar feed revoked.');
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to revoke calendar feed.');
|
||||
toast.error(errMessage(err, 'Failed to revoke calendar feed.'));
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
|
|
@ -150,13 +164,13 @@ export function CalendarFeedManager({ compact = false, showManageLink = false })
|
|||
</div>
|
||||
|
||||
<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">
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy URL
|
||||
</Button>
|
||||
<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" />
|
||||
Preview ICS
|
||||
</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">
|
||||
<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">
|
||||
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>
|
||||
</div>
|
||||
<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 { formatUSD } from '@/lib/money';
|
||||
import { TrendingUp, EyeOff, Eye, ArrowRight, Loader2 } from 'lucide-react';
|
||||
import { formatUSD, asDollars, type Dollars } from '@/lib/money';
|
||||
import { TrendingUp, EyeOff, Eye, Loader2 } from 'lucide-react';
|
||||
import { api } from '@/api';
|
||||
import { errMessage } from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
|
||||
function fmt(n) {
|
||||
function fmt(n: Parameters<typeof formatUSD>[0]): string {
|
||||
return formatUSD(n);
|
||||
}
|
||||
|
||||
export default function IncomeBreakdownModal({ open, onClose, year, month, bankTracking }) {
|
||||
const [transactions, setTransactions] = useState([]);
|
||||
interface IncomeTx {
|
||||
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 [loadError, setLoadError] = useState('');
|
||||
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 () => {
|
||||
if (!open) return;
|
||||
setLoading(true);
|
||||
setLoadError('');
|
||||
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 || []);
|
||||
} catch (err) {
|
||||
const msg = err.message || 'Failed to load income transactions';
|
||||
const msg = errMessage(err, 'Failed to load income transactions');
|
||||
setLoadError(msg);
|
||||
toast.error(msg);
|
||||
} finally {
|
||||
|
|
@ -37,13 +64,13 @@ export default function IncomeBreakdownModal({ open, onClose, year, month, bankT
|
|||
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const handleIgnore = async (tx) => {
|
||||
const handleIgnore = async (tx: IncomeTx) => {
|
||||
setActionId(tx.id);
|
||||
try {
|
||||
await api.ignoreTransaction(tx.id);
|
||||
toast.success(`"${tx.payee}" marked as excluded`);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to exclude transaction');
|
||||
toast.error(errMessage(err, 'Failed to exclude transaction'));
|
||||
setActionId(null);
|
||||
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
|
||||
};
|
||||
|
||||
const handleUnignore = async (tx) => {
|
||||
const handleUnignore = async (tx: IncomeTx) => {
|
||||
setActionId(tx.id);
|
||||
try {
|
||||
await api.unignoreTransaction(tx.id);
|
||||
toast.success(`"${tx.payee}" restored as income`);
|
||||
} catch (err) {
|
||||
toast.error(err.message || 'Failed to restore transaction');
|
||||
toast.error(errMessage(err, 'Failed to restore transaction'));
|
||||
setActionId(null);
|
||||
return;
|
||||
}
|
||||
|
|
@ -67,7 +94,7 @@ export default function IncomeBreakdownModal({ open, onClose, year, month, bankT
|
|||
|
||||
const active = 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 || {};
|
||||
|
||||
|
|
@ -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="font-mono font-semibold">{fmt(bt.balance)}</span>
|
||||
</div>
|
||||
{bt.pending_payments > 0 && (
|
||||
{(bt.pending_payments ?? 0) > 0 && (
|
||||
<div className="flex items-center justify-between text-muted-foreground">
|
||||
<span>Pending payments ({bt.pending_days}d window)</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 { cn } from '@/lib/utils';
|
||||
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;
|
||||
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 statusClass = useMemo(() => {
|
||||
|
|
@ -33,11 +46,11 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
|
|||
return (
|
||||
<div
|
||||
draggable={dragProps?.draggable}
|
||||
onDragStart={dragProps?.onDragStart}
|
||||
onDragEnter={dragProps?.onDragEnter}
|
||||
onDragOver={dragProps?.onDragOver}
|
||||
onDragEnd={dragProps?.onDragEnd}
|
||||
onDrop={dragProps?.onDrop}
|
||||
onDragStart={dragProps?.onDragStart as React.ComponentProps<'div'>['onDragStart']}
|
||||
onDragEnter={dragProps?.onDragEnter as React.ComponentProps<'div'>['onDragEnter']}
|
||||
onDragOver={dragProps?.onDragOver as React.ComponentProps<'div'>['onDragOver']}
|
||||
onDragEnd={dragProps?.onDragEnd as React.ComponentProps<'div'>['onDragEnd']}
|
||||
onDrop={dragProps?.onDrop as React.ComponentProps<'div'>['onDrop']}
|
||||
className={cn(
|
||||
'group rounded-xl border border-border/80 bg-card/90 p-3 shadow-sm shadow-black/10',
|
||||
dragProps?.isDragging && 'opacity-45',
|
||||
|
|
@ -103,18 +116,18 @@ export const MobileBillRow = React.memo(function MobileBillRow({ bill, onEdit, o
|
|||
<span className={statusClass}>
|
||||
{bill.active ? 'Active' : 'Inactive'}
|
||||
</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>
|
||||
)}
|
||||
{bill.has_2fa && (
|
||||
) : null}
|
||||
{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>
|
||||
)}
|
||||
{(bill.has_merchant_rule || bill.has_linked_transactions) && (
|
||||
) : null}
|
||||
{(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>
|
||||
)}
|
||||
{bill.is_subscription && (
|
||||
) : null}
|
||||
{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>
|
||||
)}
|
||||
) : null}
|
||||
</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