feat(tracker): status row border-left indicators, refined metric cards, search filter, and CSS polish
This commit is contained in:
parent
df1f61d6cc
commit
c41f1a8a7b
|
|
@ -37,18 +37,21 @@ export default function SearchFilterPanel({
|
||||||
<section className={cn(
|
<section className={cn(
|
||||||
embedded
|
embedded
|
||||||
? 'rounded-lg'
|
? 'rounded-lg'
|
||||||
: 'surface-premium',
|
: 'overflow-hidden rounded-xl border border-border/70 bg-card/80 shadow-sm shadow-black/5 backdrop-blur-sm',
|
||||||
className,
|
className,
|
||||||
)}>
|
)}>
|
||||||
<div className={cn('flex flex-wrap items-center gap-3', embedded ? 'py-1' : 'px-4 py-3')}>
|
<div className={cn(
|
||||||
|
'flex flex-wrap items-center gap-3',
|
||||||
|
embedded ? 'py-1' : 'bg-muted/25 px-3 py-2.5 sm:px-4',
|
||||||
|
)}>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onCollapsedChange?.(!collapsed)}
|
onClick={() => onCollapsedChange?.(!collapsed)}
|
||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
className="group inline-flex min-w-0 flex-1 items-center gap-3 rounded-md text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
className="group inline-flex min-w-0 flex-1 items-center gap-2.5 rounded-lg text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
>
|
>
|
||||||
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border border-primary/15 bg-primary/10 text-primary">
|
||||||
<Search className="h-4 w-4" />
|
<Search className="h-3.5 w-3.5" />
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0">
|
<span className="min-w-0">
|
||||||
<span className="block text-sm font-semibold text-foreground">{title}</span>
|
<span className="block text-sm font-semibold text-foreground">{title}</span>
|
||||||
|
|
@ -60,7 +63,7 @@ export default function SearchFilterPanel({
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{hasFilters && onClear && (
|
{hasFilters && onClear && (
|
||||||
<Button type="button" variant="ghost" onClick={onClear} className="h-8 gap-2 px-2.5 text-xs">
|
<Button type="button" variant="outline" onClick={onClear} className="h-8 gap-2 px-2.5 text-xs">
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
Clear
|
Clear
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -71,7 +74,7 @@ export default function SearchFilterPanel({
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'space-y-3',
|
'space-y-3',
|
||||||
embedded ? 'pt-3' : 'border-t border-border/60 px-4 py-4',
|
embedded ? 'pt-3' : 'border-t border-border/60 bg-card/45 px-3 py-3 sm:px-4',
|
||||||
)}>
|
)}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { ReactNode } from 'react';
|
import type { ReactNode } from 'react';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface FilterChipProps {
|
interface FilterChipProps {
|
||||||
|
|
@ -12,13 +13,15 @@ export function FilterChip({ active, children, onClick }: FilterChipProps) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
aria-pressed={!!active}
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-8 rounded-full border px-3 text-xs font-medium transition-colors',
|
'inline-flex h-8 items-center gap-1.5 rounded-full border px-3 text-xs font-medium transition-colors',
|
||||||
active
|
active
|
||||||
? 'border-primary/50 bg-primary/15 text-primary'
|
? 'border-primary/50 bg-primary/15 text-primary'
|
||||||
: 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground',
|
: 'border-border/70 bg-card/70 text-muted-foreground hover:bg-accent hover:text-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{active && <Check className="h-3 w-3" aria-hidden="true" />}
|
||||||
{children}
|
{children}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -176,7 +176,7 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
|
||||||
onDragEnd={dragProps?.onDragEnd as React.ComponentProps<typeof motion.div>['onDragEnd']}
|
onDragEnd={dragProps?.onDragEnd as React.ComponentProps<typeof motion.div>['onDragEnd']}
|
||||||
onDrop={dragProps?.onDrop}
|
onDrop={dragProps?.onDrop}
|
||||||
className={cn(
|
className={cn(
|
||||||
'rounded-lg border border-border/60 bg-background/60 p-3 shadow-sm',
|
'rounded-lg border border-border/60 bg-card/70 p-3 shadow-sm shadow-black/5',
|
||||||
'space-y-3 transition-colors',
|
'space-y-3 transition-colors',
|
||||||
isSkipped ? 'opacity-55' : rowBg,
|
isSkipped ? 'opacity-55' : rowBg,
|
||||||
dragProps?.isDragging && 'opacity-45',
|
dragProps?.isDragging && 'opacity-45',
|
||||||
|
|
@ -325,17 +325,17 @@ export function MobileTrackerRow({ row, year, month, refresh, index, onEditBill,
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-md border border-border/50 bg-muted/20">
|
<div className="rounded-md border border-border/50 bg-muted/15">
|
||||||
<PaymentProgress row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} compact />
|
<PaymentProgress row={row} threshold={threshold} onOpen={() => setPaymentLedgerOpen(true)} compact />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
|
<div className="grid grid-cols-2 gap-2 text-xs sm:flex sm:items-center">
|
||||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
<div className="rounded-md border border-border/45 bg-muted/30 px-2 py-1.5">
|
||||||
<span className="text-muted-foreground">Paid </span>
|
<span className="text-muted-foreground">Paid </span>
|
||||||
<span className="tracker-number font-semibold text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
|
<span className="tracker-number font-semibold text-emerald-600 dark:text-emerald-300">{row.total_paid > 0 ? fmt(row.total_paid) : '—'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md bg-muted/45 px-2 py-1.5">
|
<div className="rounded-md border border-border/45 bg-muted/30 px-2 py-1.5">
|
||||||
<span className="text-muted-foreground">Date </span>
|
<span className="text-muted-foreground">Date </span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { AlertCircle, ChevronDown, ChevronUp, BellOff, SkipForward, CreditCard, EyeOff } from 'lucide-react';
|
import { BellOff, CalendarClock, ChevronDown, ChevronUp, Clock3, CreditCard, EyeOff, SkipForward } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { api } from '@/api';
|
import { api } from '@/api';
|
||||||
import { fmt, localDateString } from '@/lib/utils';
|
import { fmt, localDateString } from '@/lib/utils';
|
||||||
|
|
@ -42,6 +42,7 @@ interface OverdueRowProps {
|
||||||
function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps) {
|
function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps) {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const threshold = row.actual_amount ?? row.expected_amount;
|
const threshold = row.actual_amount ?? row.expected_amount;
|
||||||
|
const remaining = Math.max(0, threshold - (row.total_paid ?? 0));
|
||||||
|
|
||||||
async function handleSkip() {
|
async function handleSkip() {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
@ -82,7 +83,7 @@ function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 py-2.5 sm:flex-nowrap">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-2 rounded-lg px-2 py-2.5 transition-colors hover:bg-card/55 sm:flex-nowrap">
|
||||||
{/* Bill info */}
|
{/* Bill info */}
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
|
@ -93,27 +94,33 @@ function OverdueRow({ row, year, month, onPayNow, onRefresh }: OverdueRowProps)
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<span className="text-xs font-medium text-rose-600 dark:text-rose-300">
|
<span className="inline-flex items-center gap-1 text-xs font-medium text-amber-700 dark:text-amber-200">
|
||||||
|
<Clock3 className="h-3 w-3" aria-hidden="true" />
|
||||||
{daysOverdueLabel(row.due_date)}
|
{daysOverdueLabel(row.due_date)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Amount */}
|
{/* Amount */}
|
||||||
<span className="shrink-0 font-mono text-sm font-semibold text-rose-600 dark:text-rose-300">
|
<div className="flex shrink-0 flex-col items-end leading-tight">
|
||||||
{fmt(threshold)}
|
<span className="tracker-number text-sm font-semibold text-foreground">
|
||||||
</span>
|
{fmt(asDollars(remaining > 0 ? remaining : threshold))}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/65">
|
||||||
|
remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="h-7 gap-1.5 border-rose-500/40 px-2.5 text-xs text-rose-600 hover:border-rose-500/70 hover:bg-rose-500/[0.08] hover:text-rose-700 dark:text-rose-300 dark:hover:text-rose-200"
|
className="h-7 gap-1.5 px-2.5 text-xs"
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
onClick={() => onPayNow(row)}
|
onClick={() => onPayNow(row)}
|
||||||
>
|
>
|
||||||
<CreditCard className="h-3 w-3" />
|
<CreditCard className="h-3 w-3" />
|
||||||
Pay Now
|
Pay now
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -198,25 +205,32 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Collapsible open={isOpen} onOpenChange={setOpen}>
|
<Collapsible open={isOpen} onOpenChange={setOpen}>
|
||||||
<div className="surface-premium overflow-hidden border-rose-500/30 bg-rose-500/[0.055] dark:border-rose-300/25 dark:bg-rose-400/[0.05]">
|
<div className="surface-premium overflow-hidden border-amber-500/30 bg-amber-500/[0.055] dark:border-amber-300/25 dark:bg-amber-400/[0.05]">
|
||||||
|
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center gap-2 px-4 py-3">
|
<div className="flex items-center gap-2 px-3 py-3 sm:px-4">
|
||||||
<CollapsibleTrigger asChild>
|
<CollapsibleTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-md text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-400/40"
|
className="group flex min-w-0 flex-1 items-center gap-2.5 rounded-lg text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-400/45"
|
||||||
>
|
>
|
||||||
<AlertCircle className="h-4 w-4 shrink-0 text-rose-600 dark:text-rose-300" />
|
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-amber-500/25 bg-amber-500/10 text-amber-700 dark:text-amber-200">
|
||||||
<span className="text-sm font-semibold text-foreground">
|
<CalendarClock className="h-4 w-4" aria-hidden="true" />
|
||||||
{`${overdueRows.length} overdue ${overdueRows.length === 1 ? 'bill' : 'bills'}`}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-sm font-semibold text-rose-600 dark:text-rose-300">
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold text-foreground">
|
||||||
|
Review overdue bills
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{`${overdueRows.length} ${overdueRows.length === 1 ? 'item' : 'items'} ready for pay, skip, or snooze`}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="tracker-number shrink-0 text-sm font-semibold text-foreground">
|
||||||
{fmt(asDollars(totalOverdue))}
|
{fmt(asDollars(totalOverdue))}
|
||||||
</span>
|
</span>
|
||||||
{snoozedRows.length > 0 && (
|
{snoozedRows.length > 0 && (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="hidden text-xs text-muted-foreground sm:inline">
|
||||||
({snoozedRows.length} snoozed)
|
({snoozedRows.length} snoozed)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -240,7 +254,7 @@ export default function OverdueCommandCenter({ rows, year, month, refresh, onPay
|
||||||
|
|
||||||
{/* Bill rows */}
|
{/* Bill rows */}
|
||||||
<CollapsibleContent>
|
<CollapsibleContent>
|
||||||
<div className="divide-y divide-border/40 px-4 pb-2">
|
<div className="border-t border-border/50 px-2 pb-2 pt-1 sm:px-3">
|
||||||
{overdueRows.map(row => (
|
{overdueRows.map(row => (
|
||||||
<OverdueRow
|
<OverdueRow
|
||||||
key={row.id}
|
key={row.id}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Loader2, AlertCircle } from 'lucide-react';
|
import { AlertCircle, CheckCircle2, Circle, Clock3, Loader2, SkipForward } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { STATUS_META, isPaidStatus } from '@/lib/trackerUtils';
|
import { STATUS_META, isPaidStatus } from '@/lib/trackerUtils';
|
||||||
import type { TrackerStatus } from '@/types';
|
import type { TrackerStatus } from '@/types';
|
||||||
|
|
@ -13,9 +13,24 @@ interface StatusBadgeProps {
|
||||||
|
|
||||||
export const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }: StatusBadgeProps) {
|
export const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }: StatusBadgeProps) {
|
||||||
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
|
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
|
||||||
|
const StatusIcon = useMemo(() => {
|
||||||
|
switch (status) {
|
||||||
|
case 'paid':
|
||||||
|
case 'autodraft':
|
||||||
|
return CheckCircle2;
|
||||||
|
case 'late':
|
||||||
|
case 'missed':
|
||||||
|
return AlertCircle;
|
||||||
|
case 'due_soon':
|
||||||
|
return Clock3;
|
||||||
|
case 'skipped':
|
||||||
|
return SkipForward;
|
||||||
|
default:
|
||||||
|
return Circle;
|
||||||
|
}
|
||||||
|
}, [status]);
|
||||||
|
|
||||||
const isSkipped = status === 'skipped';
|
const isSkipped = status === 'skipped';
|
||||||
const isUrgent = status === 'late' || status === 'missed';
|
|
||||||
const canClick = clickable && !isSkipped && !loading;
|
const canClick = clickable && !isSkipped && !loading;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -24,26 +39,27 @@ export const StatusBadge = React.memo(function StatusBadge({ status, clickable,
|
||||||
disabled={!canClick || loading}
|
disabled={!canClick || loading}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={cn(
|
||||||
'inline-flex items-center px-2.5 py-0.5 text-[11px] rounded-md font-semibold',
|
'inline-flex items-center gap-1.5 px-2.5 py-0.5 text-[11px] rounded-full font-semibold',
|
||||||
'uppercase tracking-wide whitespace-nowrap',
|
'uppercase tracking-wide whitespace-nowrap',
|
||||||
'transition-all duration-150',
|
'transition-colors duration-150',
|
||||||
isUrgent && 'gap-1.5 px-2.5 py-1 text-xs',
|
(status === 'late' || status === 'missed') && 'px-2.5 py-1 text-xs',
|
||||||
canClick && 'cursor-pointer hover:scale-105 hover:shadow-sm',
|
canClick && 'cursor-pointer hover:shadow-sm',
|
||||||
canClick && status === 'paid' && 'hover:bg-red-500/20 hover:text-red-600 hover:border-red-500/40',
|
canClick && status === 'paid' && 'hover:bg-red-500/20 hover:text-red-600 hover:border-red-500/40',
|
||||||
canClick && status !== 'paid' && 'hover:bg-emerald-500/20 hover:text-emerald-600 hover:border-emerald-500/40',
|
canClick && status !== 'paid' && 'hover:bg-emerald-500/20 hover:text-emerald-600 hover:border-emerald-500/40',
|
||||||
loading && 'opacity-60 cursor-wait',
|
loading && 'opacity-60 cursor-wait',
|
||||||
meta.cls,
|
meta.cls,
|
||||||
)}
|
)}
|
||||||
title={canClick ? (isPaidStatus(status) ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
|
title={canClick ? (isPaidStatus(status) ? 'Click to mark unpaid' : 'Click to mark paid') : undefined}
|
||||||
|
aria-label={`${meta.label}${canClick ? (isPaidStatus(status) ? ', click to mark unpaid' : ', click to mark paid') : ''}`}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
<Loader2 className="h-3 w-3 animate-spin" aria-hidden="true" />
|
||||||
{meta.label}
|
{meta.label}
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{isUrgent && <AlertCircle className="h-3.5 w-3.5" />}
|
<StatusIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||||
{meta.label}
|
{meta.label}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -15,16 +15,16 @@ interface CardDef {
|
||||||
|
|
||||||
const CARD_DEFS: Record<CardType, CardDef> = {
|
const CARD_DEFS: Record<CardType, CardDef> = {
|
||||||
starting: {
|
starting: {
|
||||||
label: 'Starting',
|
label: 'Starting cash',
|
||||||
icon: TrendingUp,
|
icon: TrendingUp,
|
||||||
valueClass: 'text-foreground',
|
valueClass: 'text-foreground',
|
||||||
tone: 'neutral',
|
tone: 'neutral',
|
||||||
activateWhen: () => true,
|
activateWhen: () => true,
|
||||||
},
|
},
|
||||||
paid: {
|
paid: {
|
||||||
label: 'Total Paid',
|
label: 'Paid',
|
||||||
icon: CheckCircle2,
|
icon: CheckCircle2,
|
||||||
valueClass: 'text-emerald-600 dark:text-emerald-200',
|
valueClass: 'text-foreground',
|
||||||
tone: 'good',
|
tone: 'good',
|
||||||
activateWhen: (v) => v > 0,
|
activateWhen: (v) => v > 0,
|
||||||
},
|
},
|
||||||
|
|
@ -32,13 +32,13 @@ const CARD_DEFS: Record<CardType, CardDef> = {
|
||||||
label: 'Remaining',
|
label: 'Remaining',
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
valueClass: 'text-foreground',
|
valueClass: 'text-foreground',
|
||||||
tone: 'info',
|
tone: 'warn',
|
||||||
activateWhen: () => true,
|
activateWhen: (v) => v > 0,
|
||||||
},
|
},
|
||||||
overdue: {
|
overdue: {
|
||||||
label: 'Overdue',
|
label: 'Overdue',
|
||||||
icon: AlertCircle,
|
icon: AlertCircle,
|
||||||
valueClass: 'text-red-500 dark:text-rose-200',
|
valueClass: 'text-foreground',
|
||||||
tone: 'danger',
|
tone: 'danger',
|
||||||
activateWhen: (v) => v > 0,
|
activateWhen: (v) => v > 0,
|
||||||
},
|
},
|
||||||
|
|
@ -58,12 +58,12 @@ export function TrendIndicator({ trend }: { trend?: TrendInfo | null }) {
|
||||||
switch (direction) {
|
switch (direction) {
|
||||||
case 'up':
|
case 'up':
|
||||||
icon = '↑';
|
icon = '↑';
|
||||||
color = 'text-emerald-500';
|
color = 'text-emerald-600 dark:text-emerald-300';
|
||||||
text = `${icon} ${percent_change}%`;
|
text = `${icon} ${percent_change}%`;
|
||||||
break;
|
break;
|
||||||
case 'down':
|
case 'down':
|
||||||
icon = '↓';
|
icon = '↓';
|
||||||
color = 'text-red-500';
|
color = 'text-rose-600 dark:text-rose-300';
|
||||||
text = `${icon} ${Math.abs(percent_change)}%`;
|
text = `${icon} ${Math.abs(percent_change)}%`;
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|
@ -73,8 +73,8 @@ export function TrendIndicator({ trend }: { trend?: TrendInfo | null }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex flex-wrap items-baseline gap-x-1.5 gap-y-1">
|
||||||
<span className={`text-lg font-bold ${color}`}>
|
<span className={`tracker-number text-lg font-bold ${color}`}>
|
||||||
{text}
|
{text}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
<span className="text-[10px] text-muted-foreground whitespace-nowrap">
|
||||||
|
|
@ -105,7 +105,7 @@ export function SummaryCard({ type, value, onEdit, hint, label }: SummaryCardPro
|
||||||
icon={Icon}
|
icon={Icon}
|
||||||
label={displayLabel}
|
label={displayLabel}
|
||||||
value={(
|
value={(
|
||||||
<span className={cn(isActive ? def.valueClass : 'text-foreground')}>
|
<span className={cn('block tabular-nums', isActive ? def.valueClass : 'text-foreground')}>
|
||||||
{fmt(value)}
|
{fmt(value)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
@ -113,11 +113,11 @@ export function SummaryCard({ type, value, onEdit, hint, label }: SummaryCardPro
|
||||||
action={type === 'starting' && onEdit ? (
|
action={type === 'starting' && onEdit ? (
|
||||||
<button
|
<button
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
className="ml-auto h-4 w-4 text-muted-foreground hover:text-foreground transition-colors"
|
className="ml-auto grid h-7 w-7 place-items-center rounded-lg border border-border/60 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50"
|
||||||
title="Edit monthly starting amounts"
|
title="Edit monthly starting amounts"
|
||||||
aria-label="Edit monthly starting amounts"
|
aria-label="Edit monthly starting amounts"
|
||||||
>
|
>
|
||||||
<Settings2 className="h-4 w-4" />
|
<Settings2 className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
) : undefined}
|
) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
@ -134,8 +134,8 @@ export function TrendCard({ trend }: { trend?: TrendInfo | null }) {
|
||||||
icon={TrendingUp}
|
icon={TrendingUp}
|
||||||
label="3-Month Trend"
|
label="3-Month Trend"
|
||||||
value={(
|
value={(
|
||||||
<span className="flex min-h-10 items-center justify-center">
|
<span className="flex min-h-8 items-center">
|
||||||
<TrendIndicator trend={trend} />
|
<TrendIndicator trend={trend} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -223,10 +223,10 @@ export function TrackerBucket({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="surface-premium overflow-hidden">
|
<div className="table-surface overflow-hidden">
|
||||||
|
|
||||||
{/* Bucket header */}
|
{/* Bucket header */}
|
||||||
<div className="flex flex-col gap-2 bg-muted/35 px-3 py-3 border-b border-border/80 sm:px-5 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-2 border-b border-border/70 bg-card/60 px-3 py-3 sm:px-5 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5">
|
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1.5">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<span className="truncate text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
<span className="truncate text-[11px] font-bold uppercase tracking-[0.12em] text-muted-foreground">
|
||||||
|
|
@ -239,7 +239,7 @@ export function TrackerBucket({
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex min-w-[7rem] items-center gap-2">
|
<div className="flex min-w-[7rem] items-center gap-2">
|
||||||
<div className="h-1.5 w-16 rounded-full bg-border overflow-hidden sm:w-24">
|
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-border sm:w-24">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'h-full rounded-full transition-all duration-700',
|
'h-full rounded-full transition-all duration-700',
|
||||||
|
|
@ -254,7 +254,7 @@ export function TrackerBucket({
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground md:justify-end">
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-mono text-muted-foreground md:justify-end">
|
||||||
<span className="whitespace-nowrap">
|
<span className="tracker-number whitespace-nowrap">
|
||||||
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
|
<span className={cn(allPaid ? 'text-emerald-500' : 'text-foreground')}>
|
||||||
{fmt(asDollars(totalPaidTowardDue))}
|
{fmt(asDollars(totalPaidTowardDue))}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -308,10 +308,10 @@ export function TrackerBucket({
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
<LayoutGroup id={`tracker-bucket-mobile-${label}`}>
|
<LayoutGroup id={`tracker-bucket-mobile-${label}`}>
|
||||||
<div className="grid gap-3 p-3 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
|
<div className="grid gap-2.5 bg-background/20 p-2.5 lg:hidden" aria-busy={loading ? 'true' : 'false'}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
Array.from({ length: 3 }).map((_, i) => (
|
Array.from({ length: 3 }).map((_, i) => (
|
||||||
<div key={i} className="rounded-lg border border-border/60 bg-background/60 p-3 animate-pulse">
|
<div key={i} className="animate-pulse rounded-lg border border-border/60 bg-card/65 p-3">
|
||||||
<div className="flex min-w-0 items-start justify-between gap-3">
|
<div className="flex min-w-0 items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
|
|
@ -334,7 +334,7 @@ export function TrackerBucket({
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : rows.length === 0 ? (
|
) : rows.length === 0 ? (
|
||||||
<div className="rounded-lg bg-muted/15 px-4 py-8 text-center text-sm text-muted-foreground">
|
<div className="rounded-lg border border-dashed border-border/70 bg-muted/15 px-4 py-8 text-center text-sm text-muted-foreground">
|
||||||
No bills match — try adjusting your filters.
|
No bills match — try adjusting your filters.
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
@ -361,7 +361,7 @@ export function TrackerBucket({
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<Table className={cn(visibleColumns.length <= 4 ? 'min-w-[720px]' : 'min-w-[1120px]')}>
|
<Table className={cn(visibleColumns.length <= 4 ? 'min-w-[720px]' : 'min-w-[1120px]')}>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="border-border/80 bg-background/30 hover:bg-background/30">
|
<TableRow className="border-border/80 bg-muted/20 hover:bg-muted/20">
|
||||||
<SortableHead sortKey="name" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[18%]">Bill</SortableHead>
|
<SortableHead sortKey="name" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[18%]">Bill</SortableHead>
|
||||||
{showColumn('due') && (
|
{showColumn('due') && (
|
||||||
<SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead>
|
<SortableHead sortKey="due" activeSortKey={sortKey} sortDir={sortDir} onSort={onSort} className="w-[10%]">Due</SortableHead>
|
||||||
|
|
|
||||||
|
|
@ -120,14 +120,18 @@ export function MetricCard({
|
||||||
<div className={cn('metric-card', tone !== 'neutral' && toneClasses[tone], className)}>
|
<div className={cn('metric-card', tone !== 'neutral' && toneClasses[tone], className)}>
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex min-w-0 items-center gap-2">
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
{Icon && <Icon className="h-4 w-4 shrink-0 text-current opacity-80" />}
|
{Icon && (
|
||||||
|
<span className="grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/[0.08] text-current">
|
||||||
|
<Icon className="h-3.5 w-3.5 opacity-90" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
{label}
|
{label}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{action}
|
{action}
|
||||||
</div>
|
</div>
|
||||||
<p className="metric-value mt-3 text-2xl leading-none sm:text-[1.75rem]">{value}</p>
|
<p className="metric-value mt-4 text-2xl leading-none sm:text-[1.72rem]">{value}</p>
|
||||||
{hint && <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p>}
|
{hint && <p className="mt-2 text-xs leading-5 text-muted-foreground">{hint}</p>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -162,16 +162,16 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card {
|
.metric-card {
|
||||||
@apply relative overflow-hidden rounded-xl border border-border/75 bg-card/95 px-5 py-4 shadow-sm shadow-black/10;
|
@apply relative min-h-[7.75rem] overflow-hidden rounded-xl border border-border/75 bg-card/95 px-4 py-4 shadow-sm shadow-black/10 sm:px-5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.metric-card::before {
|
.metric-card::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0 0 auto;
|
inset: 0 0 auto;
|
||||||
height: 3px;
|
height: 2px;
|
||||||
background: linear-gradient(90deg, oklch(var(--primary) / 0.85), oklch(var(--accent) / 0.68));
|
background: currentColor;
|
||||||
opacity: 0.76;
|
opacity: 0.24;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Stat cards */
|
/* Stat cards */
|
||||||
|
|
@ -194,6 +194,7 @@
|
||||||
|
|
||||||
.metric-value {
|
.metric-value {
|
||||||
@apply tracker-number font-bold tracking-tight text-foreground;
|
@apply tracker-number font-bold tracking-tight text-foreground;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -41,20 +41,20 @@ export const TRACKER_SORT_DEFAULT_DIRS = Object.fromEntries(
|
||||||
);
|
);
|
||||||
|
|
||||||
export const ROW_STATUS_CLS: Record<string, string> = {
|
export const ROW_STATUS_CLS: Record<string, string> = {
|
||||||
paid: 'bg-emerald-500/[0.04] dark:bg-emerald-400/[0.02]',
|
paid: 'border-l-2 border-l-emerald-500/45 bg-emerald-500/[0.025] dark:bg-emerald-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-emerald-500/45',
|
||||||
autodraft: 'bg-sky-500/[0.04] dark:bg-sky-400/[0.018]',
|
autodraft: 'border-l-2 border-l-teal-500/45 bg-teal-500/[0.025] dark:bg-teal-400/[0.018] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-teal-500/45',
|
||||||
upcoming: '',
|
upcoming: 'border-l-2 border-l-transparent [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-transparent',
|
||||||
due_soon: 'bg-amber-400/[0.07] dark:bg-amber-300/[0.016]',
|
due_soon: 'border-l-2 border-l-amber-400/65 bg-amber-400/[0.055] dark:bg-amber-300/[0.02] [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-amber-400/65',
|
||||||
late: 'border-l-4 border-l-orange-400 bg-orange-500/[0.16] ring-1 ring-inset ring-orange-400/25 dark:bg-orange-400/[0.11] dark:ring-orange-300/25',
|
late: 'border-l-2 border-l-orange-400/80 bg-orange-500/[0.09] ring-1 ring-inset ring-orange-400/20 dark:bg-orange-400/[0.07] dark:ring-orange-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-orange-400/80',
|
||||||
missed: 'border-l-4 border-l-rose-400 bg-rose-500/[0.18] ring-1 ring-inset ring-rose-400/30 dark:bg-rose-400/[0.13] dark:ring-rose-300/30',
|
missed: 'border-l-2 border-l-rose-400/80 bg-rose-500/[0.1] ring-1 ring-inset ring-rose-400/20 dark:bg-rose-400/[0.075] dark:ring-rose-300/20 [&>td:first-child]:border-l-2 [&>td:first-child]:border-l-rose-400/80',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const STATUS_META = {
|
export const STATUS_META = {
|
||||||
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' },
|
paid: { label: 'Paid', cls: 'bg-emerald-500/15 text-emerald-700 border border-emerald-500/30 dark:bg-emerald-300/10 dark:text-emerald-200 dark:border-emerald-300/30' },
|
||||||
upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
|
upcoming: { label: 'Upcoming', cls: 'bg-secondary text-muted-foreground border border-border' },
|
||||||
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-700 border border-amber-400/35 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' },
|
due_soon: { label: 'Due Soon', cls: 'bg-amber-400/15 text-amber-700 border border-amber-400/35 dark:bg-amber-300/10 dark:text-amber-200 dark:border-amber-300/28' },
|
||||||
late: { label: 'Late', cls: 'bg-orange-500/20 text-orange-700 border border-orange-500/50 shadow-sm shadow-orange-950/10 dark:bg-orange-400/25 dark:text-orange-100 dark:border-orange-300/60' },
|
late: { label: 'Late', cls: 'bg-orange-500/15 text-orange-700 border border-orange-500/45 shadow-sm shadow-orange-950/10 dark:bg-orange-400/20 dark:text-orange-100 dark:border-orange-300/50' },
|
||||||
missed: { label: 'Missed', cls: 'bg-rose-500/20 text-rose-700 border border-rose-500/55 shadow-sm shadow-rose-950/10 dark:bg-rose-400/25 dark:text-rose-100 dark:border-rose-300/60' },
|
missed: { label: 'Missed', cls: 'bg-rose-500/15 text-rose-700 border border-rose-500/45 shadow-sm shadow-rose-950/10 dark:bg-rose-400/20 dark:text-rose-100 dark:border-rose-300/50' },
|
||||||
autodraft: { label: 'Autodraft', cls: 'bg-teal-400/15 text-teal-700 border border-teal-400/35 dark:bg-teal-300/10 dark:text-teal-200 dark:border-teal-300/28' },
|
autodraft: { label: 'Autodraft', cls: 'bg-teal-400/15 text-teal-700 border border-teal-400/35 dark:bg-teal-300/10 dark:text-teal-200 dark:border-teal-300/28' },
|
||||||
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
|
skipped: { label: 'Skipped', cls: 'bg-muted text-muted-foreground border border-border' },
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1135,7 +1135,7 @@ export default function TrackerPage() {
|
||||||
|
|
||||||
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
{/* ── Summary cards (backend already excludes skipped from totals) ── */}
|
||||||
{showSummaryCards && loading ? (
|
{showSummaryCards && loading ? (
|
||||||
<div className="grid grid-cols-2 gap-3 lg:flex" aria-busy="true">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:flex" aria-busy="true">
|
||||||
<Skeleton variant="card" className="h-32" />
|
<Skeleton variant="card" className="h-32" />
|
||||||
<Skeleton variant="card" className="h-32" />
|
<Skeleton variant="card" className="h-32" />
|
||||||
<Skeleton variant="card" className="h-32" />
|
<Skeleton variant="card" className="h-32" />
|
||||||
|
|
@ -1144,7 +1144,7 @@ export default function TrackerPage() {
|
||||||
{summary.trend ? <Skeleton variant="card" className="h-32" /> : null}
|
{summary.trend ? <Skeleton variant="card" className="h-32" /> : null}
|
||||||
</div>
|
</div>
|
||||||
) : showSummaryCards ? (
|
) : showSummaryCards ? (
|
||||||
<div className="grid grid-cols-2 gap-3 lg:flex">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:flex">
|
||||||
{bankTracking?.enabled ? (
|
{bankTracking?.enabled ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -1158,24 +1158,31 @@ export default function TrackerPage() {
|
||||||
)}
|
)}
|
||||||
title="Click to see income breakdown"
|
title="Click to see income breakdown"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 mb-3">
|
<div className="mb-3 flex items-start justify-between gap-3">
|
||||||
<Landmark className={cn('h-4 w-4', Number(bankTracking.remaining ?? 0) >= 0 ? 'text-emerald-500' : 'text-destructive')} />
|
<div className="flex min-w-0 items-center gap-2">
|
||||||
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
<span className={cn(
|
||||||
{bankTracking.account_name}
|
'grid h-7 w-7 shrink-0 place-items-center rounded-lg border border-current/15 bg-current/[0.08]',
|
||||||
</p>
|
Number(bankTracking.remaining ?? 0) >= 0 ? 'text-emerald-600 dark:text-emerald-300' : 'text-destructive',
|
||||||
|
)}>
|
||||||
|
<Landmark className="h-3.5 w-3.5" />
|
||||||
|
</span>
|
||||||
|
<p className="truncate text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
|
||||||
|
{bankTracking.account_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
<span className="ml-auto flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
|
<span className="ml-auto flex items-center gap-1 text-[10px] text-emerald-600 dark:text-emerald-400 font-medium">
|
||||||
<StatusDot tone="good" pulse className="h-1.5 w-1.5 [&_span]:h-1.5 [&_span]:w-1.5" />
|
<StatusDot tone="good" pulse className="h-1.5 w-1.5 [&_span]:h-1.5 [&_span]:w-1.5" />
|
||||||
Live
|
Live
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[1.75rem] font-bold tracking-tight font-mono leading-none text-foreground">
|
<p className="metric-value mt-4 text-2xl leading-none sm:text-[1.72rem]">
|
||||||
{fmt(bankTracking.effective_balance ?? 0)}
|
{fmt(bankTracking.effective_balance ?? 0)}
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-2 text-[11px] text-muted-foreground">
|
<p className="mt-2 text-xs leading-5 text-muted-foreground">
|
||||||
{Number(bankTracking.remaining ?? 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected after bills
|
{Number(bankTracking.remaining ?? 0) < 0 ? '−' : ''}{fmt(Math.abs(Number(bankTracking.remaining ?? 0)))} projected after bills
|
||||||
</p>
|
</p>
|
||||||
{bankTracking.last_updated && (
|
{bankTracking.last_updated && (
|
||||||
<p className="mt-1 text-[10px] text-muted-foreground/60">
|
<p className="mt-1 text-[10px] leading-4 text-muted-foreground/70">
|
||||||
as of {fmtBalanceAge(bankTracking.last_updated)}
|
as of {fmtBalanceAge(bankTracking.last_updated)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
@ -1218,14 +1225,23 @@ export default function TrackerPage() {
|
||||||
{/* Compact month-progress line — quick "how far through the month am I?" */}
|
{/* Compact month-progress line — quick "how far through the month am I?" */}
|
||||||
{showSummaryCards && !loading && !isError && Number(summary?.total_expected ?? 0) > 0 && (() => {
|
{showSummaryCards && !loading && !isError && Number(summary?.total_expected ?? 0) > 0 && (() => {
|
||||||
const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0);
|
const billsLeft = (summary.count_upcoming ?? 0) + (summary.count_late ?? 0);
|
||||||
|
const progress = Math.min(100, Math.max(0, (Number(summary.paid_toward_due || 0) / Number(summary.total_expected || 1)) * 100));
|
||||||
return (
|
return (
|
||||||
<p className="px-1 -mt-1 text-[11px] text-muted-foreground">
|
<div className="-mt-1 rounded-xl border border-border/65 bg-card/55 px-3 py-2 shadow-sm shadow-black/5">
|
||||||
<span className="font-mono font-semibold text-foreground">{fmt(summary.paid_toward_due)}</span>
|
<div className="flex flex-wrap items-center justify-between gap-2 text-[11px] text-muted-foreground">
|
||||||
{' of '}
|
<p>
|
||||||
<span className="font-mono">{fmt(summary.total_expected)}</span>
|
<span className="tracker-number font-semibold text-foreground">{fmt(summary.paid_toward_due)}</span>
|
||||||
{' paid'}
|
{' of '}
|
||||||
{billsLeft > 0 && <> · {billsLeft} bill{billsLeft === 1 ? '' : 's'} left</>}
|
<span className="tracker-number">{fmt(summary.total_expected)}</span>
|
||||||
</p>
|
{' paid'}
|
||||||
|
{billsLeft > 0 && <> · {billsLeft} bill{billsLeft === 1 ? '' : 's'} left</>}
|
||||||
|
</p>
|
||||||
|
<span className="tracker-number font-semibold text-foreground">{Math.round(progress)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div className="h-full rounded-full bg-primary/75" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue