Milestone 2: the full picture (20261007.12)
Budgets: one amount per category for every month with any month set on its own, filled from recent spending in one step and copied into the next year. Home shows what's left to spend, year pacing, insights, balances and upcoming charges. Insights covers year pacing, money in and out, budget against actual, categories by month, merchants, balances over time and tags. Subscriptions and bills are found across every account (rhythm and price matching plus a built-in catalog), confirmed with one tap, and watched for price changes, missed charges, charges after cancelling, double billing, renewals and trials. Alerts are worked out again whenever the data changes. Also: category, rule, tag and alert settings; Excel and CSV export from a small built-in writer; the AI module designed in and switched off. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,9 @@
|
||||
"radix-ui": "^1.7.0",
|
||||
"react": "^19.3.0",
|
||||
"react-dom": "^19.3.0",
|
||||
"react-is": "^19.3.0",
|
||||
"react-router": "^8.4.0",
|
||||
"recharts": "^3.10.1",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.7.0"
|
||||
},
|
||||
|
||||
46
web/src/components/AlertCard.tsx
Normal file
46
web/src/components/AlertCard.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import type { AlertItem } from '@spendlight/shared';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowRight, CircleAlert, Lightbulb, PartyPopper, TriangleAlert, X } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { api } from '@/lib/api';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const LOOK = {
|
||||
bad: { icon: CircleAlert, tone: 'bg-bad/12 text-bad' },
|
||||
warn: { icon: TriangleAlert, tone: 'bg-warn/14 text-warn' },
|
||||
good: { icon: PartyPopper, tone: 'bg-good/12 text-good' },
|
||||
info: { icon: Lightbulb, tone: 'bg-accent text-accent-foreground' },
|
||||
} as const;
|
||||
|
||||
/** One insight or alert: what happened, why it matters, and a way to see the transactions. */
|
||||
export function AlertCard({ alert, compact }: { alert: AlertItem; compact?: boolean }) {
|
||||
const client = useQueryClient();
|
||||
const dismiss = useMutation({
|
||||
mutationFn: () => api(`/alerts/${alert.id}/dismiss`, { method: 'POST' }),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['alerts'] }),
|
||||
});
|
||||
const { icon: Icon, tone } = LOOK[alert.severity];
|
||||
return (
|
||||
<div className={cn('group relative flex gap-3.5 rounded-2xl border bg-card p-4 shadow-soft', compact && 'p-3.5')}>
|
||||
<span className={cn('flex size-9 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]', tone)}>
|
||||
<Icon strokeWidth={2} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 pr-6">
|
||||
<p className="text-[14.5px] font-medium leading-snug">{alert.title}</p>
|
||||
{alert.body ? <p className="mt-0.5 text-[13px] leading-relaxed text-muted-foreground">{alert.body}</p> : null}
|
||||
{alert.link ? (
|
||||
<Link to={alert.link} className="mt-1.5 inline-flex items-center gap-1 text-[13px] font-medium text-primary">
|
||||
See {alert.link.startsWith('/activity') ? 'transactions' : 'details'} <ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => dismiss.mutate()}
|
||||
className="absolute right-2.5 top-2.5 flex size-8 items-center justify-center rounded-lg text-muted-foreground opacity-70 transition hover:bg-muted hover:opacity-100"
|
||||
aria-label="Dismiss"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { Ellipsis, Upload } from 'lucide-react';
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { useReviewCount } from '@/lib/data';
|
||||
import { useAlertCounts, useReviewCount } from '@/lib/data';
|
||||
import { queueFiles } from '@/lib/pendingFiles';
|
||||
import { useConfig, useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -55,8 +55,14 @@ function SideLink({ item, badge }: { item: NavItem; badge?: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
function useBadges(): Record<string, number | undefined> {
|
||||
const { data: toReview } = useReviewCount();
|
||||
const { data: alerts } = useAlertCounts();
|
||||
return { '/review': toReview, '/subscriptions': alerts?.subscriptions, '/more': alerts?.subscriptions };
|
||||
}
|
||||
|
||||
function Sidebar() {
|
||||
const badges = useBadges();
|
||||
return (
|
||||
<aside className="fixed inset-y-0 left-0 z-40 hidden flex-col border-r bg-card/70 backdrop-blur-xl md:flex md:w-[84px] lg:w-64">
|
||||
<Link to="/" className="flex h-16 items-center gap-2.5 px-6 md:justify-center lg:justify-start" aria-label="Spendlight home">
|
||||
@@ -65,7 +71,7 @@ function Sidebar() {
|
||||
</Link>
|
||||
<nav className="flex flex-1 flex-col gap-1 px-3 py-2" aria-label="Main">
|
||||
{NAV.map((item) => (
|
||||
<SideLink key={item.to} item={item} badge={item.to === '/review' ? toReview : undefined} />
|
||||
<SideLink key={item.to} item={item} badge={badges[item.to]} />
|
||||
))}
|
||||
</nav>
|
||||
<div className="px-3 pb-4">
|
||||
@@ -80,7 +86,7 @@ function Sidebar() {
|
||||
|
||||
function PhoneTabBar() {
|
||||
const { pathname } = useLocation();
|
||||
const { data: toReview } = useReviewCount();
|
||||
const badges = useBadges();
|
||||
const inMore = pathname.startsWith('/more') || pathname.startsWith('/settings') || pathname.startsWith('/import') || NAV.some((n) => !n.phone && pathname.startsWith(n.to));
|
||||
const tab = 'flex flex-col items-center gap-1 pt-2 pb-1 text-[11px] font-medium text-muted-foreground transition-colors';
|
||||
return (
|
||||
@@ -90,13 +96,16 @@ function PhoneTabBar() {
|
||||
<NavLink key={to} to={to} end={to === '/'} className={({ isActive }) => cn(tab, isActive && 'text-primary')}>
|
||||
<span className="relative">
|
||||
<Icon className="size-[22px]" strokeWidth={1.9} />
|
||||
{to === '/review' ? <CountBadge count={toReview} className="absolute -right-3 -top-1.5 h-4 min-w-4 px-1 text-[10px]" /> : null}
|
||||
<CountBadge count={badges[to]} className="absolute -right-3 -top-1.5 h-4 min-w-4 px-1 text-[10px]" />
|
||||
</span>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
<NavLink to="/more" className={cn(tab, inMore && 'text-primary')}>
|
||||
<Ellipsis className="size-[22px]" strokeWidth={1.9} />
|
||||
<span className="relative">
|
||||
<Ellipsis className="size-[22px]" strokeWidth={1.9} />
|
||||
<CountBadge count={badges['/more']} className="absolute -right-3 -top-1.5 h-4 min-w-4 px-1 text-[10px]" />
|
||||
</span>
|
||||
More
|
||||
</NavLink>
|
||||
</div>
|
||||
|
||||
108
web/src/components/BudgetEditor.tsx
Normal file
108
web/src/components/BudgetEditor.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import { monthName, type BudgetScope } from '@spendlight/shared';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { centsToInput, parseDollars } from '@/lib/money';
|
||||
import { CategoryIcon } from './money';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent } from './ui/dialog';
|
||||
import { Input } from './ui/form';
|
||||
import { Segmented } from './ui/misc';
|
||||
import type { Category } from '@spendlight/shared';
|
||||
|
||||
export interface EditTarget {
|
||||
category: Category;
|
||||
month: string;
|
||||
/** The amount in effect for that month now. */
|
||||
current: number;
|
||||
/** Whether that category already has any budget this year. */
|
||||
hasBudget: boolean;
|
||||
}
|
||||
|
||||
/** Set one category's budget: just this month, from this month on, or every month. */
|
||||
export function BudgetEditor({ target, onClose }: { target: EditTarget | null; onClose: () => void }) {
|
||||
return (
|
||||
<Dialog open={!!target} onOpenChange={(open) => !open && onClose()}>
|
||||
{target ? <EditorBody key={`${target.category.id}-${target.month}`} target={target} onClose={onClose} /> : null}
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EditorBody({ target, onClose }: { target: EditTarget; onClose: () => void }) {
|
||||
const client = useQueryClient();
|
||||
const year = Number(target.month.slice(0, 4));
|
||||
const month = Number(target.month.slice(5, 7));
|
||||
const [amount, setAmount] = useState(target.current ? centsToInput(target.current) : '');
|
||||
const [scope, setScope] = useState<BudgetScope>(target.hasBudget ? (month === 12 ? 'month' : 'rest') : 'year');
|
||||
const income = target.category.kind === 'income';
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const cents = parseDollars(amount);
|
||||
if (cents === null) throw new Error('Please enter an amount like 250 or 249.99');
|
||||
return api('/budgets', { method: 'PUT', body: { categoryId: target.category.id, year, month, amountCents: cents, scope } });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void client.invalidateQueries({ queryKey: ['budgets'] });
|
||||
void client.invalidateQueries({ queryKey: ['reports'] });
|
||||
void client.invalidateQueries({ queryKey: ['alerts'] });
|
||||
onClose();
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error)),
|
||||
});
|
||||
const name = monthName(target.month);
|
||||
const options: { value: BudgetScope; label: string }[] = [
|
||||
{ value: 'month', label: `Just ${monthName(target.month, 'short')}` },
|
||||
...(month < 12 ? [{ value: 'rest' as const, label: `${monthName(target.month, 'short')} – Dec` }] : []),
|
||||
{ value: 'year', label: `All of ${year}` },
|
||||
];
|
||||
|
||||
return (
|
||||
<DialogContent title={`${target.category.name}`} description={income ? `How much you expect to receive in ${name}.` : `How much you plan to spend in ${name}.`}>
|
||||
<form
|
||||
onSubmit={(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
className="grid gap-5"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CategoryIcon icon={target.category.icon} color={target.category.color} size="lg" />
|
||||
<div className="relative flex-1">
|
||||
<span className="pointer-events-none absolute left-3.5 top-1/2 -translate-y-1/2 text-[18px] text-muted-foreground">$</span>
|
||||
<Input
|
||||
value={amount}
|
||||
onChange={(e) => setAmount(e.target.value)}
|
||||
inputMode="decimal"
|
||||
placeholder="0"
|
||||
autoFocus
|
||||
aria-label="Monthly amount"
|
||||
className="tabular h-14 pl-8 text-[24px] font-semibold sm:h-14 sm:text-[24px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<span className="text-[13px] font-medium">Applies to</span>
|
||||
<div className="overflow-x-auto">
|
||||
<Segmented label="Which months" value={scope} onChange={setScope} options={options} />
|
||||
</div>
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
{scope === 'month'
|
||||
? `Only ${name} changes; every other month keeps its amount.`
|
||||
: scope === 'rest'
|
||||
? `${name} through December. Earlier months keep what they had.`
|
||||
: `The same amount every month of ${year}. Set any single month differently later.`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" loading={save.isPending}>
|
||||
Save budget
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
);
|
||||
}
|
||||
@@ -175,17 +175,19 @@ export function CategoryButton({
|
||||
onPick,
|
||||
className,
|
||||
placeholder = 'Choose category',
|
||||
noTransfers,
|
||||
}: {
|
||||
categoryId: number | null;
|
||||
suggestions?: number[];
|
||||
onPick: (category: Category) => void;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
noTransfers?: boolean;
|
||||
}) {
|
||||
const { byId } = useCategories();
|
||||
const c = categoryId ? byId.get(categoryId) : undefined;
|
||||
return (
|
||||
<CategoryPicker value={categoryId} suggestions={suggestions} onPick={onPick}>
|
||||
<CategoryPicker value={categoryId} suggestions={suggestions} onPick={onPick} noTransfers={noTransfers}>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { formatCents, type Transaction } from '@spendlight/shared';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeftRight, Plus, Split as SplitIcon, Trash2, X } from 'lucide-react';
|
||||
import { ArrowLeftRight, ArrowRight, Plus, Repeat, Split as SplitIcon, Trash2, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { MONEY_KEYS, useAccounts, useCategories, useTags } from '@/lib/data';
|
||||
import { MONEY_KEYS, useAccounts, useCategories, useSubscriptions, useTags } from '@/lib/data';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CategoryButton } from './CategoryPicker';
|
||||
@@ -191,6 +192,7 @@ export function TransactionSheet({
|
||||
}, [transaction, startWithSplit]);
|
||||
|
||||
const account = useMemo(() => accounts.data?.find((a) => a.id === t?.accountId), [accounts.data, t]);
|
||||
const subscriptions = useSubscriptions();
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api<{ transaction: Transaction }>(`/transactions/${t!.id}`, { method: 'PATCH', body }),
|
||||
@@ -219,6 +221,7 @@ export function TransactionSheet({
|
||||
|
||||
if (!t) return null;
|
||||
const transfer = t.transferPeer;
|
||||
const subscription = t.subscriptionId ? subscriptions.data?.find((x) => x.id === t.subscriptionId && x.status !== 'suggested') : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -255,6 +258,16 @@ export function TransactionSheet({
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{subscription ? (
|
||||
<Link to={`/subscriptions?open=${subscription.id}`} onClick={onClose} className="flex items-center gap-2 rounded-xl border px-3.5 py-3 text-[14px] hover:bg-muted/60">
|
||||
<Repeat className="size-4 text-muted-foreground" />
|
||||
<span className="flex-1">
|
||||
Part of your <span className="font-medium">{subscription.name}</span> {subscription.kind === 'bill' ? 'bill' : 'subscription'}
|
||||
</span>
|
||||
<ArrowRight className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{t.isSplit ? (
|
||||
<div className="grid gap-2">
|
||||
<p className="text-[13px] font-medium">Split between</p>
|
||||
|
||||
79
web/src/components/meter.tsx
Normal file
79
web/src/components/meter.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { CircleAlert, CircleCheck, TriangleAlert } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { budgetStatus, whole, type BudgetStatus } from '@/lib/money';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// Progress bars and rings for "spent against budget". Status always comes
|
||||
// with an icon and a word, never color alone.
|
||||
|
||||
export const STATUS_TONE: Record<BudgetStatus, { bar: string; text: string; soft: string }> = {
|
||||
none: { bar: 'bg-primary/70', text: 'text-muted-foreground', soft: 'bg-muted' },
|
||||
under: { bar: 'bg-good', text: 'text-good', soft: 'bg-good/12' },
|
||||
close: { bar: 'bg-warn', text: 'text-warn', soft: 'bg-warn/14' },
|
||||
over: { bar: 'bg-bad', text: 'text-bad', soft: 'bg-bad/12' },
|
||||
};
|
||||
|
||||
export function ProgressBar({ spent, budget, className, pace, scale }: { spent: number; budget: number; className?: string; pace?: number; scale?: number }) {
|
||||
const status = budgetStatus(spent, budget);
|
||||
// Without a budget, the bar compares categories with each other (scale is the largest).
|
||||
const pct = budget > 0 ? Math.min(100, (spent / budget) * 100) : scale ? Math.max(0, Math.min(100, (spent / scale) * 100)) : 0;
|
||||
return (
|
||||
<div className={cn('relative h-2 overflow-hidden rounded-full bg-muted', className)} role="presentation">
|
||||
<div className={cn('h-full rounded-full transition-[width] duration-500 ease-out', STATUS_TONE[status].bar)} style={{ width: `${pct}%` }} />
|
||||
{pace !== undefined && budget > 0 && pace > 0 && pace < 1 ? (
|
||||
// Where spending "should" be by today, if it were spread evenly over the month.
|
||||
<div className="absolute inset-y-0 w-0.5 bg-foreground/35" style={{ left: `${pace * 100}%` }} aria-hidden />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** "$42 left", "$18 over" or "no budget", with an icon. */
|
||||
export function BudgetNote({ spent, budget, className, income }: { spent: number; budget: number; className?: string; income?: boolean }) {
|
||||
if (budget <= 0) return <span className={cn('text-[12.5px] text-muted-foreground', className)}>No budget</span>;
|
||||
if (income) {
|
||||
const short = budget - spent;
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1 text-[12.5px] font-medium', short <= 0 ? 'text-good' : 'text-muted-foreground', className)}>
|
||||
{short <= 0 ? <CircleCheck className="size-3.5" /> : null}
|
||||
{short <= 0 ? `${whole(-short)} above plan` : `${whole(short)} still to come`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const status = budgetStatus(spent, budget);
|
||||
const Icon = status === 'over' ? CircleAlert : status === 'close' ? TriangleAlert : CircleCheck;
|
||||
return (
|
||||
<span className={cn('inline-flex items-center gap-1 text-[12.5px] font-medium', STATUS_TONE[status].text, className)}>
|
||||
<Icon className="size-3.5" />
|
||||
{status === 'over' ? `${whole(spent - budget)} over` : `${whole(budget - spent)} left`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** A ring that fills as the month's budget is spent. */
|
||||
export function Ring({ spent, budget, size = 168, stroke = 14, children, className }: { spent: number; budget: number; size?: number; stroke?: number; children?: ReactNode; className?: string }) {
|
||||
const status = budgetStatus(spent, budget);
|
||||
const r = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * r;
|
||||
const share = budget > 0 ? Math.min(1, spent / budget) : 0;
|
||||
const color = { none: 'stroke-primary', under: 'stroke-good', close: 'stroke-warn', over: 'stroke-bad' }[status];
|
||||
return (
|
||||
<div className={cn('relative shrink-0', className)} style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`} className="-rotate-90" aria-hidden>
|
||||
<circle cx={size / 2} cy={size / 2} r={r} fill="none" strokeWidth={stroke} className="stroke-current opacity-15" />
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
fill="none"
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference * (1 - share)}
|
||||
className={cn('transition-[stroke-dashoffset] duration-700 ease-out', color)}
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -42,6 +42,38 @@ import {
|
||||
Wifi,
|
||||
Wrench,
|
||||
Zap,
|
||||
Apple,
|
||||
Beer,
|
||||
Bike,
|
||||
BookOpen,
|
||||
Briefcase,
|
||||
Building2,
|
||||
Bus,
|
||||
Cake,
|
||||
Camera,
|
||||
Cat,
|
||||
Church,
|
||||
Clapperboard,
|
||||
Dog,
|
||||
Film,
|
||||
Flower2,
|
||||
Gamepad2,
|
||||
GraduationCap,
|
||||
Hammer,
|
||||
HandCoins,
|
||||
HeartPulse,
|
||||
Hotel,
|
||||
Music,
|
||||
Package,
|
||||
PiggyBank,
|
||||
Pizza,
|
||||
School,
|
||||
Train,
|
||||
TreePine,
|
||||
Trophy,
|
||||
Umbrella,
|
||||
Wallet,
|
||||
Wine,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -106,8 +138,43 @@ const ICONS: Record<string, LucideIcon> = {
|
||||
scale: Scale,
|
||||
'credit-card': CreditCard,
|
||||
'arrow-left-right': ArrowLeftRight,
|
||||
'apple': Apple,
|
||||
'beer': Beer,
|
||||
'bike': Bike,
|
||||
'book-open': BookOpen,
|
||||
'briefcase': Briefcase,
|
||||
'building-2': Building2,
|
||||
'bus': Bus,
|
||||
'cake': Cake,
|
||||
'camera': Camera,
|
||||
'cat': Cat,
|
||||
'church': Church,
|
||||
'clapperboard': Clapperboard,
|
||||
'dog': Dog,
|
||||
'film': Film,
|
||||
'flower-2': Flower2,
|
||||
'gamepad-2': Gamepad2,
|
||||
'graduation-cap': GraduationCap,
|
||||
'hammer': Hammer,
|
||||
'hand-coins': HandCoins,
|
||||
'heart-pulse': HeartPulse,
|
||||
'hotel': Hotel,
|
||||
'music': Music,
|
||||
'package': Package,
|
||||
'piggy-bank': PiggyBank,
|
||||
'pizza': Pizza,
|
||||
'school': School,
|
||||
'train': Train,
|
||||
'tree-pine': TreePine,
|
||||
'trophy': Trophy,
|
||||
'umbrella': Umbrella,
|
||||
'wallet': Wallet,
|
||||
'wine': Wine,
|
||||
};
|
||||
|
||||
/** Every icon a category can use, for the icon picker. */
|
||||
export const ICON_NAMES = Object.keys(ICONS);
|
||||
|
||||
export function CategoryIcon({
|
||||
icon,
|
||||
color,
|
||||
|
||||
@@ -1,4 +1,15 @@
|
||||
import type { Account, Category, CategoryGroup } from '@spendlight/shared';
|
||||
import type {
|
||||
Account,
|
||||
AlertItem,
|
||||
BudgetYear,
|
||||
Category,
|
||||
CategoryGroup,
|
||||
MonthsReport,
|
||||
Overview,
|
||||
Rule,
|
||||
Subscription,
|
||||
TagInfo,
|
||||
} from '@spendlight/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { api } from './api';
|
||||
@@ -8,7 +19,8 @@ export function useAccounts() {
|
||||
}
|
||||
|
||||
export function useCategories() {
|
||||
const query = useQuery({ queryKey: ['categories'], queryFn: () => api<CategoryGroup[]>('/categories'), staleTime: 5 * 60_000 });
|
||||
// Archived categories are included so old transactions still show theirs; pickers leave them out.
|
||||
const query = useQuery({ queryKey: ['categories'], queryFn: () => api<CategoryGroup[]>('/categories?includeArchived=true'), staleTime: 5 * 60_000 });
|
||||
const lookup = useMemo(() => {
|
||||
const all: Category[] = query.data?.flatMap((g) => g.categories) ?? [];
|
||||
return { all, byId: new Map(all.map((c) => [c.id, c])) };
|
||||
@@ -26,11 +38,65 @@ export function useReviewCount() {
|
||||
}
|
||||
|
||||
export function useTags() {
|
||||
return useQuery({ queryKey: ['tags'], queryFn: () => api<{ name: string; count: number }[]>('/tags') });
|
||||
return useQuery({ queryKey: ['tags'], queryFn: () => api<TagInfo[]>('/tags') });
|
||||
}
|
||||
|
||||
export function useOverview(month?: string) {
|
||||
return useQuery({ queryKey: ['reports', 'overview', month ?? 'auto'], queryFn: () => api<Overview>(`/reports/overview${month ? `?month=${month}` : ''}`) });
|
||||
}
|
||||
|
||||
export function useMonthsReport(from: string, to: string, enabled = true) {
|
||||
return useQuery({ queryKey: ['reports', 'months', from, to], queryFn: () => api<MonthsReport>(`/reports/months?from=${from}&to=${to}`), enabled });
|
||||
}
|
||||
|
||||
export function useBudgets(year: number) {
|
||||
return useQuery({ queryKey: ['budgets', year], queryFn: () => api<BudgetYear>(`/budgets?year=${year}`) });
|
||||
}
|
||||
|
||||
export function useSubscriptions() {
|
||||
return useQuery({ queryKey: ['subscriptions'], queryFn: () => api<Subscription[]>('/subscriptions') });
|
||||
}
|
||||
|
||||
export interface Upcoming {
|
||||
subscriptionId: number;
|
||||
name: string;
|
||||
kind: 'subscription' | 'bill';
|
||||
date: string;
|
||||
amountCents: number;
|
||||
accountId: number | null;
|
||||
trial: boolean;
|
||||
}
|
||||
|
||||
export function useUpcoming(days = 30) {
|
||||
return useQuery({ queryKey: ['subscriptions', 'upcoming', days], queryFn: () => api<Upcoming[]>(`/subscriptions/upcoming?days=${days}`) });
|
||||
}
|
||||
|
||||
export function useAlerts(area?: 'money' | 'subscriptions', limit = 50) {
|
||||
return useQuery({ queryKey: ['alerts', area ?? 'all', limit], queryFn: () => api<AlertItem[]>(`/alerts?limit=${limit}${area ? `&area=${area}` : ''}`) });
|
||||
}
|
||||
|
||||
export function useAlertCounts() {
|
||||
return useQuery({ queryKey: ['alerts', 'counts'], queryFn: () => api<{ money: number; subscriptions: number }>('/alerts/counts'), refetchOnWindowFocus: true });
|
||||
}
|
||||
|
||||
export function useRules() {
|
||||
return useQuery({ queryKey: ['rules'], queryFn: () => api<Rule[]>('/rules') });
|
||||
}
|
||||
|
||||
/** Everything that changes when transactions change. */
|
||||
export const MONEY_KEYS = [['transactions'], ['review'], ['review-count'], ['accounts'], ['imports'], ['tags']] as const;
|
||||
export const MONEY_KEYS = [
|
||||
['transactions'],
|
||||
['review'],
|
||||
['review-count'],
|
||||
['accounts'],
|
||||
['imports'],
|
||||
['tags'],
|
||||
['reports'],
|
||||
['budgets'],
|
||||
['subscriptions'],
|
||||
['alerts'],
|
||||
['rules'],
|
||||
] as const;
|
||||
|
||||
/** Remember recent category picks on this device, for the top of the picker. */
|
||||
const RECENT_KEY = 'sl-recent-categories';
|
||||
|
||||
54
web/src/lib/money.ts
Normal file
54
web/src/lib/money.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { formatCents } from '@spendlight/shared';
|
||||
|
||||
/** "1,234.50", "$12", "12.5" → cents; null when it isn't a number. */
|
||||
export function parseDollars(text: string): number | null {
|
||||
const clean = text.replace(/[$,\s]/g, '');
|
||||
if (!clean) return 0;
|
||||
if (!/^\d*(\.\d{0,2})?$/.test(clean)) return null;
|
||||
return Math.round(Number(clean) * 100);
|
||||
}
|
||||
|
||||
/** Cents as a plain number for an input box: 12345 → "123.45", 10000 → "100". */
|
||||
export const centsToInput = (cents: number) => (cents % 100 === 0 ? String(cents / 100) : (cents / 100).toFixed(2));
|
||||
|
||||
export const whole = (cents: number) => formatCents(cents, { whole: true });
|
||||
|
||||
export const thisMonth = () => new Date().toLocaleDateString('en-CA').slice(0, 7);
|
||||
|
||||
export const isoDay = (d: Date) => d.toLocaleDateString('en-CA');
|
||||
|
||||
export function monthBounds(month: string): { from: string; to: string } {
|
||||
const [y, m] = month.split('-').map(Number);
|
||||
return { from: `${month}-01`, to: isoDay(new Date(y!, m!, 0)) };
|
||||
}
|
||||
|
||||
export type BudgetStatus = 'none' | 'under' | 'close' | 'over';
|
||||
|
||||
export function budgetStatus(spent: number, budget: number, warnPct = 80): BudgetStatus {
|
||||
if (budget <= 0) return 'none';
|
||||
if (spent > budget) return 'over';
|
||||
if (spent >= (budget * warnPct) / 100) return 'close';
|
||||
return 'under';
|
||||
}
|
||||
|
||||
/** The next date a charge is due on or after today, stepping forward from the last expected one. */
|
||||
export function nextDue(date: string | null, cadence: 'weekly' | 'monthly' | 'quarterly' | 'yearly'): string | null {
|
||||
if (!date) return null;
|
||||
const today = isoDay(new Date());
|
||||
const [y, m, d] = date.split('-').map(Number);
|
||||
let step = 0;
|
||||
let next = date;
|
||||
while (next < today && step < 600) {
|
||||
step++;
|
||||
const dt =
|
||||
cadence === 'weekly'
|
||||
? new Date(y!, m! - 1, d! + 7 * step)
|
||||
: (() => {
|
||||
const months = (cadence === 'monthly' ? 1 : cadence === 'quarterly' ? 3 : 12) * step;
|
||||
const last = new Date(y!, m! - 1 + months + 1, 0).getDate();
|
||||
return new Date(y!, m! - 1 + months, Math.min(d!, last));
|
||||
})();
|
||||
next = isoDay(dt);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Field, Input, Select } from '@/components/ui/form';
|
||||
import { Skeleton } from '@/components/ui/misc';
|
||||
import { Badge, Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useAccounts } from '@/lib/data';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
@@ -155,16 +155,27 @@ function BalanceForm({ account, onDone }: { account: Account; onDone: () => void
|
||||
/** A card's balance means nothing until someone has entered it once. */
|
||||
const needsBalance = (a: Account) => isLiability(a.kind) && !a.balanceConfirmedOn;
|
||||
|
||||
/** Statements arrive monthly; after about 40 days without one, a nudge. */
|
||||
function statementDue(a: Account): boolean {
|
||||
if (a.status !== 'open' || !a.lastTransactionDate || a.kind === 'cash') return false;
|
||||
const [y, m, d] = a.lastTransactionDate.split('-').map(Number);
|
||||
return (Date.now() - new Date(y!, m! - 1, d!).getTime()) / 86_400_000 > 40;
|
||||
}
|
||||
|
||||
function AccountRow({ account, onOpen }: { account: Account; onOpen: () => void }) {
|
||||
const liability = isLiability(account.kind);
|
||||
const missing = needsBalance(account);
|
||||
const due = statementDue(account);
|
||||
return (
|
||||
<button onClick={onOpen} className="flex w-full items-center gap-3.5 px-4 py-3.5 text-left transition-colors hover:bg-muted/60 sm:px-5">
|
||||
<span className={cn('flex size-10 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]', PALETTE_CLASS[account.color].soft, PALETTE_CLASS[account.color].text)}>
|
||||
{account.kind === 'credit_card' ? <CreditCard /> : account.kind === 'cash' ? <Wallet /> : <Landmark />}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-[15px] font-medium">{account.name}</p>
|
||||
<p className="flex items-center gap-2 truncate text-[15px] font-medium">
|
||||
<span className="truncate">{account.name}</span>
|
||||
{due ? <Badge tone="warn">Statement due</Badge> : null}
|
||||
</p>
|
||||
<p className="truncate text-[12.5px] text-muted-foreground">
|
||||
{[account.institution, account.last4 ? `••${account.last4}` : null].filter(Boolean).join(' ') || ACCOUNT_KIND_LABEL[account.kind]}
|
||||
{account.lastImportAt ? ` · imported ${timeAgo(account.lastImportAt)}` : account.transactionCount === 0 ? ' · no statements yet' : ''}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { formatCents, type Transaction, type TransactionPage } from '@spendlight/shared';
|
||||
import { formatCents, monthName, type Transaction, type TransactionPage } from '@spendlight/shared';
|
||||
import { keepPreviousData, useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeftRight, Plus, Search, SlidersHorizontal, Split as SplitIcon, Tag, X } from 'lucide-react';
|
||||
import { ArrowLeftRight, Download, Plus, Search, SlidersHorizontal, Split as SplitIcon, Tag, X } from 'lucide-react';
|
||||
import { useMemo, useState, type FormEvent } from 'react';
|
||||
import { useSearchParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
@@ -12,9 +12,11 @@ import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Field, Input, Select } from '@/components/ui/form';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/menu';
|
||||
import { Badge, Segmented, Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useAccounts, useCategories } from '@/lib/data';
|
||||
import { useAccounts, useCategories, useSubscriptions } from '@/lib/data';
|
||||
import { monthBounds } from '@/lib/money';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -164,29 +166,44 @@ export default function ActivityPage() {
|
||||
const accountId = params.get('accountId') ?? '';
|
||||
const categoryId = params.get('categoryId') ?? '';
|
||||
const tag = params.get('tag') ?? '';
|
||||
const period = params.get('period') ?? 'all';
|
||||
const subscriptionId = params.get('subscriptionId') ?? '';
|
||||
const month = params.get('month') ?? '';
|
||||
const customFrom = params.get('from') ?? '';
|
||||
const customTo = params.get('to') ?? '';
|
||||
// A month or date range from a link (Home, Budget, Insights) replaces the quick periods.
|
||||
const custom = month ? monthBounds(month) : customFrom || customTo ? { from: customFrom, to: customTo } : null;
|
||||
const customLabel = month
|
||||
? monthName(month, 'long', true)
|
||||
: custom
|
||||
? `${custom.from ? formatDay(custom.from, true) : 'Start'} – ${custom.to ? formatDay(custom.to, true) : 'today'}${custom.from && custom.to && custom.from.slice(0, 4) === custom.to.slice(0, 4) ? ` ${custom.from.slice(0, 4)}` : ''}`
|
||||
: '';
|
||||
const period = custom ? 'custom' : (params.get('period') ?? 'all');
|
||||
const q = params.get('q') ?? '';
|
||||
const subscriptions = useSubscriptions();
|
||||
const subscription = subscriptionId ? subscriptions.data?.find((x) => String(x.id) === subscriptionId) : undefined;
|
||||
|
||||
const setParam = (key: string, value: string) => {
|
||||
const next = new URLSearchParams(params);
|
||||
if (value && value !== 'all') next.set(key, value);
|
||||
else next.delete(key);
|
||||
if (key === 'period') ['month', 'from', 'to'].forEach((k) => next.delete(k));
|
||||
setParams(next, { replace: true });
|
||||
setLimit(PAGE);
|
||||
};
|
||||
|
||||
const query = useMemo(() => {
|
||||
const filters = useMemo(() => {
|
||||
const s = new URLSearchParams();
|
||||
if (q) s.set('q', q);
|
||||
if (accountId) s.set('accountId', accountId);
|
||||
if (categoryId) s.set('categoryId', categoryId);
|
||||
if (tag) s.set('tag', tag);
|
||||
const r = periodRange(period);
|
||||
if (subscriptionId) s.set('subscriptionId', subscriptionId);
|
||||
const r = custom ?? periodRange(period);
|
||||
if (r.from) s.set('from', r.from);
|
||||
if (r.to) s.set('to', r.to);
|
||||
s.set('limit', String(limit));
|
||||
return s.toString();
|
||||
}, [q, accountId, categoryId, tag, period, limit]);
|
||||
}, [q, accountId, categoryId, tag, subscriptionId, period, custom?.from, custom?.to]);
|
||||
const query = `${filters}${filters ? '&' : ''}limit=${limit}`;
|
||||
|
||||
const list = useQuery({
|
||||
queryKey: ['transactions', query],
|
||||
@@ -204,7 +221,7 @@ export default function ActivityPage() {
|
||||
return out;
|
||||
}, [list.data]);
|
||||
|
||||
const filterCount = [accountId, categoryId, tag].filter(Boolean).length;
|
||||
const filterCount = [accountId, categoryId, tag, subscriptionId].filter(Boolean).length;
|
||||
const category = categoryId && categoryId !== 'none' ? byId.get(Number(categoryId)) : undefined;
|
||||
|
||||
return (
|
||||
@@ -214,11 +231,33 @@ export default function ActivityPage() {
|
||||
titleOnPhone={false}
|
||||
description="Every transaction from every account. Tap one to change its category, split it, tag it or add a note."
|
||||
actions={
|
||||
me.access === 'full' ? (
|
||||
<Button variant="outline" onClick={() => setAdding(true)}>
|
||||
<Plus /> Cash entry
|
||||
</Button>
|
||||
) : undefined
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Download /> <span className="hidden sm:inline">Export</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={`/api/v1/export/transactions.xlsx?${filters}`} download>
|
||||
Excel workbook
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<a href={`/api/v1/export/transactions.csv?${filters}`} download>
|
||||
CSV file
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
<p className="px-2.5 pb-1.5 pt-1 text-[12px] text-muted-foreground">Exactly what’s listed, with the same search and filters.</p>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{me.access === 'full' ? (
|
||||
<Button variant="outline" onClick={() => setAdding(true)}>
|
||||
<Plus /> Cash entry
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -253,18 +292,25 @@ export default function ActivityPage() {
|
||||
['month', 'This month'],
|
||||
['last', 'Last month'],
|
||||
['year', 'This year'],
|
||||
...(custom ? [['custom', customLabel]] : []),
|
||||
].map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setParam('period', value!)}
|
||||
onClick={() => setParam('period', value === 'custom' ? 'all' : value!)}
|
||||
className={cn(
|
||||
'h-9 shrink-0 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
|
||||
period === value ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
{value === 'custom' ? <X className="-mr-1 ml-1 inline size-3.5" aria-label="Clear dates" /> : null}
|
||||
</button>
|
||||
))}
|
||||
{subscriptionId ? (
|
||||
<button onClick={() => setParam('subscriptionId', '')} className="flex h-9 shrink-0 items-center gap-1 rounded-full border border-transparent bg-accent px-3.5 text-[13px] font-medium text-accent-foreground">
|
||||
{subscription ? `${subscription.name} charges` : 'Subscription charges'} <X className="size-3.5" aria-label="Clear" />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{showFilters ? (
|
||||
<Card className="grid gap-3 p-4 sm:grid-cols-3">
|
||||
|
||||
466
web/src/pages/BudgetPage.tsx
Normal file
466
web/src/pages/BudgetPage.tsx
Normal file
@@ -0,0 +1,466 @@
|
||||
import { addMonths, monthName, type BudgetLine, type Category, type CategoryGroup } from '@spendlight/shared';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { CalendarRange, ChevronLeft, ChevronRight, Copy, Inbox, LayoutList, Sparkles, Table2, Wand2 } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { BudgetEditor, type EditTarget } from '@/components/BudgetEditor';
|
||||
import { BudgetNote, ProgressBar, STATUS_TONE } from '@/components/meter';
|
||||
import { CategoryIcon } from '@/components/money';
|
||||
import { PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/menu';
|
||||
import { Segmented, Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useBudgets, useCategories, useMonthsReport, useOverview } from '@/lib/data';
|
||||
import { budgetStatus, thisMonth, whole } from '@/lib/money';
|
||||
import { useMediaQuery } from '@/lib/useMediaQuery';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const effective = (line: BudgetLine | undefined, i: number) => (line ? (line.months[i] ?? line.monthly ?? 0) : 0);
|
||||
const VIEW_KEY = 'sl-budget-view';
|
||||
|
||||
interface Row {
|
||||
category: Category;
|
||||
budget: number[];
|
||||
actual: number[];
|
||||
line: BudgetLine | undefined;
|
||||
}
|
||||
|
||||
function useBudgetRows(year: number) {
|
||||
const budgets = useBudgets(year);
|
||||
const report = useMonthsReport(`${year}-01`, `${year}-12`);
|
||||
const { data: groups } = useCategories();
|
||||
const sections = useMemo(() => {
|
||||
if (!groups || !budgets.data || !report.data) return null;
|
||||
const lines = new Map(budgets.data.items.map((l) => [l.categoryId, l]));
|
||||
const actuals = new Map(report.data.categories.map((c) => [c.categoryId, c.actual]));
|
||||
const zero = Array(12).fill(0) as number[];
|
||||
const build = (g: CategoryGroup) => {
|
||||
const rows: Row[] = g.categories
|
||||
.filter((c) => c.kind !== 'transfer')
|
||||
.map((c) => ({ category: c, line: lines.get(c.id), budget: Array.from({ length: 12 }, (_, i) => effective(lines.get(c.id), i)), actual: actuals.get(c.id) ?? zero }))
|
||||
.filter((r) => !r.category.archived || r.budget.some(Boolean) || r.actual.some(Boolean));
|
||||
return { group: g, rows };
|
||||
};
|
||||
const all = groups.map(build).filter((s) => s.rows.length);
|
||||
const kindOf = (s: (typeof all)[number]) => (s.rows.every((r) => r.category.kind === 'income') ? 'income' : 'expense');
|
||||
return { expense: all.filter((s) => kindOf(s) === 'expense'), income: all.filter((s) => kindOf(s) === 'income') };
|
||||
}, [groups, budgets.data, report.data]);
|
||||
return { sections, report: report.data, budgets: budgets.data, loading: budgets.isPending || report.isPending };
|
||||
}
|
||||
|
||||
const sum = (rows: Row[], key: 'budget' | 'actual', i: number) => rows.reduce((s, r) => s + r[key][i]!, 0);
|
||||
|
||||
function MonthSwitcher({ month, onChange }: { month: string; onChange: (m: string) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => onChange(addMonths(month, -1))} aria-label="Previous month">
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<span className="tabular min-w-36 text-center text-[16px] font-semibold">{monthName(month, 'long', true)}</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => onChange(addMonths(month, 1))} aria-label="Next month">
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Summary({ spend, spent, income, received }: { spend: number; spent: number; income: number; received: number }) {
|
||||
const items = [
|
||||
{ label: 'Planned spending', value: whole(spend), sub: spend ? `${whole(spent)} spent` : 'Not set yet' },
|
||||
{ label: 'Planned income', value: whole(income), sub: income ? `${whole(received)} received` : 'Optional' },
|
||||
{ label: 'Planned savings', value: whole(income - spend), sub: income ? `${Math.round(((income - spend) / income) * 100)}% of income` : 'Add income to see it' },
|
||||
];
|
||||
return (
|
||||
<div className="mb-5 grid grid-cols-3 gap-2.5 sm:gap-4">
|
||||
{items.map((it) => (
|
||||
<Card key={it.label} className="p-3.5 sm:p-5">
|
||||
<p className="text-[11.5px] font-medium text-muted-foreground sm:text-[12.5px]">{it.label}</p>
|
||||
<p className="tabular mt-1 truncate text-[17px] font-semibold tracking-tight sm:text-[24px]">{it.value}</p>
|
||||
<p className="mt-0.5 truncate text-[11.5px] text-muted-foreground sm:text-[12.5px]">{it.sub}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MonthList({ month, sections, canEdit, onEdit, unfiled, pace }: {
|
||||
month: string;
|
||||
sections: NonNullable<ReturnType<typeof useBudgetRows>['sections']>;
|
||||
canEdit: boolean;
|
||||
onEdit: (row: Row) => void;
|
||||
unfiled: number;
|
||||
pace?: number;
|
||||
}) {
|
||||
const i = Number(month.slice(5, 7)) - 1;
|
||||
// Categories with neither a budget nor spending this month wait behind "show more",
|
||||
// unless nothing is budgeted yet (then every category is there to set).
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const all = [...sections.expense, ...sections.income].flatMap((s) => s.rows);
|
||||
const active = (r: Row) => r.budget[i]! > 0 || r.actual[i]! !== 0;
|
||||
const hiddenCount = all.filter((r) => !active(r)).length;
|
||||
const filtering = !showAll && all.some((r) => r.budget[i]! > 0) && hiddenCount > 0;
|
||||
const block = (whole_: (typeof sections.expense)[number], income: boolean) => {
|
||||
const s = { ...whole_, rows: filtering ? whole_.rows.filter(active) : whole_.rows };
|
||||
if (!s.rows.length) return null;
|
||||
const budget = sum(s.rows, 'budget', i);
|
||||
const actual = sum(s.rows, 'actual', i);
|
||||
return (
|
||||
<section key={s.group.id}>
|
||||
<div className="mb-1.5 flex items-baseline justify-between px-1">
|
||||
<h3 className="text-[13px] font-semibold text-muted-foreground">{s.group.name}</h3>
|
||||
<span className="tabular text-[12.5px] text-muted-foreground">
|
||||
{whole(actual)} {budget ? `of ${whole(budget)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{s.rows.map((r) => (
|
||||
<div key={r.category.id} className="flex items-center gap-3 px-4 py-3 sm:px-5">
|
||||
<Link to={`/activity?categoryId=${r.category.id}&month=${month}`} className="flex min-w-0 flex-1 items-center gap-3">
|
||||
<CategoryIcon icon={r.category.icon} color={r.category.color} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-[14.5px] font-medium">{r.category.name}</span>
|
||||
<span className="tabular shrink-0 text-[13.5px]">{whole(r.actual[i]!)}</span>
|
||||
</div>
|
||||
<ProgressBar spent={r.actual[i]!} budget={r.budget[i]!} pace={income ? undefined : pace} className="mt-1.5" />
|
||||
<BudgetNote spent={r.actual[i]!} budget={r.budget[i]!} income={income} className="mt-1" />
|
||||
</div>
|
||||
</Link>
|
||||
<button
|
||||
disabled={!canEdit}
|
||||
onClick={() => onEdit(r)}
|
||||
className={cn(
|
||||
'tabular h-10 min-w-20 shrink-0 rounded-xl border px-3 text-right text-[14px] font-medium transition-colors',
|
||||
canEdit ? 'bg-card hover:border-primary/40 hover:bg-accent' : 'cursor-default',
|
||||
!r.budget[i] && 'border-dashed text-muted-foreground',
|
||||
)}
|
||||
aria-label={`Budget for ${r.category.name}`}
|
||||
>
|
||||
{r.budget[i] ? whole(r.budget[i]!) : canEdit ? 'Set' : '—'}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
{unfiled > 0 ? (
|
||||
<Link to="/review" className="flex items-center gap-3 rounded-2xl border border-dashed px-4 py-3 text-[13.5px] text-muted-foreground hover:bg-muted/60">
|
||||
<Inbox className="size-4" />
|
||||
<span className="flex-1">
|
||||
<span className="font-medium text-foreground">{whole(unfiled)}</span> spent in {monthName(month)} isn’t filed in a category yet.
|
||||
</span>
|
||||
<span className="font-medium text-primary">Review</span>
|
||||
</Link>
|
||||
) : null}
|
||||
{sections.expense.map((s) => block(s, false))}
|
||||
{sections.income.length && sections.income.some((s) => !filtering || s.rows.some(active)) ? <h2 className="mt-2 px-1 text-[15px] font-semibold">Income</h2> : null}
|
||||
{sections.income.map((s) => block(s, true))}
|
||||
{hiddenCount && all.some((r) => r.budget[i]! > 0) ? (
|
||||
<Button variant="ghost" className="justify-self-center text-muted-foreground" onClick={() => setShowAll((v) => !v)}>
|
||||
{showAll ? 'Hide categories without a budget' : `Show ${hiddenCount} more categor${hiddenCount === 1 ? 'y' : 'ies'} without a budget`}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearGrid({ year, sections, canEdit, onEdit, onMonth, through }: {
|
||||
year: number;
|
||||
/** The last month the statements reach; later months have no actuals to show yet. */
|
||||
through: string;
|
||||
sections: NonNullable<ReturnType<typeof useBudgetRows>['sections']>;
|
||||
canEdit: boolean;
|
||||
onEdit: (row: Row, monthIndex: number) => void;
|
||||
onMonth: (month: string) => void;
|
||||
}) {
|
||||
const months = Array.from({ length: 12 }, (_, i) => `${year}-${String(i + 1).padStart(2, '0')}`);
|
||||
const now = thisMonth();
|
||||
const cell = 'tabular px-2 py-2 text-right text-[13px]';
|
||||
const groupYear = (rows: Row[]) => rows.reduce((t, r) => t + r.budget.reduce((a, b) => a + b, 0), 0);
|
||||
const groupRows = (s: (typeof sections.expense)[number], income: boolean) => [
|
||||
<tr key={`g${s.group.id}`} className="bg-muted/50">
|
||||
<th scope="rowgroup" className="sticky left-0 z-10 bg-muted px-4 py-2 text-left text-[12.5px] font-semibold text-muted-foreground">
|
||||
{s.group.name}
|
||||
</th>
|
||||
{months.map((_, i) => (
|
||||
<td key={i} className={cn(cell, 'text-[12px] font-medium text-muted-foreground')}>
|
||||
{sum(s.rows, 'budget', i) ? whole(sum(s.rows, 'budget', i)) : ''}
|
||||
</td>
|
||||
))}
|
||||
<td className={cn(cell, 'text-[12px] font-semibold text-muted-foreground')}>{groupYear(s.rows) ? whole(groupYear(s.rows)) : ''}</td>
|
||||
</tr>,
|
||||
...s.rows.map((r) => {
|
||||
const yearBudget = r.budget.reduce((a, b) => a + b, 0);
|
||||
const yearActual = r.actual.reduce((a, b) => a + b, 0);
|
||||
return (
|
||||
<tr key={r.category.id} className="border-t">
|
||||
<th scope="row" className="sticky left-0 z-10 max-w-56 bg-card px-4 py-2 text-left font-normal">
|
||||
<span className="flex items-center gap-2.5">
|
||||
<CategoryIcon icon={r.category.icon} color={r.category.color} size="sm" className="size-6 rounded-md [&_svg]:size-3.5" />
|
||||
<span className="truncate text-[13.5px] font-medium">{r.category.name}</span>
|
||||
</span>
|
||||
</th>
|
||||
{months.map((m, i) => {
|
||||
const status = income ? 'none' : budgetStatus(r.actual[i]!, r.budget[i]!);
|
||||
const past = m <= through;
|
||||
const own = r.line?.months[i] != null;
|
||||
return (
|
||||
<td key={m} className="p-0.5">
|
||||
<button
|
||||
disabled={!canEdit}
|
||||
onClick={() => onEdit(r, i)}
|
||||
className={cn(
|
||||
'flex w-full flex-col items-end rounded-lg px-2 py-1.5 transition-colors',
|
||||
canEdit && 'hover:bg-accent',
|
||||
past && status === 'over' && 'bg-bad/8',
|
||||
)}
|
||||
title={own ? 'Set for this month only' : undefined}
|
||||
>
|
||||
<span className={cn('tabular text-[13px] font-medium', !r.budget[i] && 'text-muted-foreground/50')}>
|
||||
{r.budget[i] ? whole(r.budget[i]!) : '–'}
|
||||
{own ? <span className="ml-0.5 align-super text-[9px] text-primary">●</span> : null}
|
||||
</span>
|
||||
{past && r.actual[i] ? <span className={cn('tabular text-[11px]', status === 'over' ? 'font-medium text-bad' : 'text-muted-foreground')}>{whole(r.actual[i]!)}</span> : null}
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className={cn(cell, 'border-l')}>
|
||||
<div className="font-semibold">{yearBudget ? whole(yearBudget) : '–'}</div>
|
||||
{yearActual ? <div className={cn('text-[11px]', !income && yearBudget && yearActual > yearBudget ? 'font-medium text-bad' : 'text-muted-foreground')}>{whole(yearActual)}</div> : null}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}),
|
||||
];
|
||||
const expenseRows = sections.expense.flatMap((s) => s.rows);
|
||||
return (
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[1080px] border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="sticky left-0 z-10 bg-card px-4 py-3 text-left text-[12.5px] font-semibold text-muted-foreground">Category</th>
|
||||
{months.map((m) => (
|
||||
<th key={m} className="px-2 py-3 text-right text-[12.5px] font-semibold">
|
||||
<button onClick={() => onMonth(m)} className={cn('rounded-md px-1.5 py-0.5 hover:bg-muted', m === now && 'bg-accent text-accent-foreground')}>
|
||||
{monthName(m, 'short')}
|
||||
</button>
|
||||
</th>
|
||||
))}
|
||||
<th className="border-l px-2 py-3 text-right text-[12.5px] font-semibold">Year</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sections.expense.flatMap((s) => groupRows(s, false))}
|
||||
<tr className="border-t-2 bg-muted/40">
|
||||
<th scope="row" className="sticky left-0 z-10 bg-muted px-4 py-2.5 text-left text-[13px] font-semibold">
|
||||
Total spending
|
||||
</th>
|
||||
{months.map((m, i) => (
|
||||
<td key={m} className={cell}>
|
||||
<div className="font-semibold">{whole(sum(expenseRows, 'budget', i))}</div>
|
||||
{m <= through ? <div className="text-[11px] text-muted-foreground">{whole(sum(expenseRows, 'actual', i))}</div> : null}
|
||||
</td>
|
||||
))}
|
||||
<td className={cn(cell, 'border-l font-semibold')}>{whole(expenseRows.reduce((t, r) => t + r.budget.reduce((a, b) => a + b, 0), 0))}</td>
|
||||
</tr>
|
||||
{sections.income.flatMap((s) => groupRows(s, true))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="border-t px-4 py-2.5 text-[12px] text-muted-foreground">
|
||||
Each cell shows the budget, with what was actually spent beneath it. <span className="text-primary">●</span> marks a month set on its own. Tap any amount to change it.
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BudgetPage() {
|
||||
const me = useCurrentUser();
|
||||
const canEdit = me.access === 'full';
|
||||
const client = useQueryClient();
|
||||
const wide = useMediaQuery('(min-width: 1024px)');
|
||||
const overview = useOverview();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const month = params.get('month') ?? overview.data?.month ?? thisMonth();
|
||||
const setMonth = (m: string) => setParams((p) => (p.set('month', m), p), { replace: true });
|
||||
const year = Number(month.slice(0, 4));
|
||||
const i = Number(month.slice(5, 7)) - 1;
|
||||
const [view, setViewState] = useState<'month' | 'year'>(() => {
|
||||
try {
|
||||
return (localStorage.getItem(VIEW_KEY) as 'month' | 'year' | null) ?? 'year';
|
||||
} catch {
|
||||
return 'year';
|
||||
}
|
||||
});
|
||||
const setView = (v: 'month' | 'year') => {
|
||||
setViewState(v);
|
||||
try {
|
||||
localStorage.setItem(VIEW_KEY, v);
|
||||
} catch {
|
||||
/* fine */
|
||||
}
|
||||
};
|
||||
const showYear = wide && view === 'year';
|
||||
const { sections, report, budgets, loading } = useBudgetRows(year);
|
||||
const previous = useBudgets(year - 1);
|
||||
const [editing, setEditing] = useState<EditTarget | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!params.get('month') && overview.data) setParams((p) => (p.set('month', overview.data!.month), p), { replace: true });
|
||||
}, [overview.data, params, setParams]);
|
||||
|
||||
const refresh = () => {
|
||||
void client.invalidateQueries({ queryKey: ['budgets'] });
|
||||
void client.invalidateQueries({ queryKey: ['reports'] });
|
||||
void client.invalidateQueries({ queryKey: ['alerts'] });
|
||||
};
|
||||
const fill = useMutation({
|
||||
mutationFn: (source: 'last_budget' | 'last_actual' | 'average3') => api<{ filled: number }>('/budgets/fill', { method: 'POST', body: { month, source } }),
|
||||
onSuccess: ({ filled }) => {
|
||||
refresh();
|
||||
toast.success(filled ? `Filled in ${filled} categor${filled === 1 ? 'y' : 'ies'}` : 'Nothing to fill in', {
|
||||
description: filled ? `As an amount for every month of ${year}. Categories you’d already set kept their amounts.` : 'Every category with recent spending already has a budget.',
|
||||
});
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const copy = useMutation({
|
||||
mutationFn: () => api<{ copied: number }>('/budgets/copy-year', { method: 'POST', body: { from: year - 1, to: year } }),
|
||||
onSuccess: ({ copied }) => {
|
||||
refresh();
|
||||
toast.success(`Copied ${copied} budget${copied === 1 ? '' : 's'} from ${year - 1}`);
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
|
||||
const edit = (row: Row, monthIndex = i) =>
|
||||
setEditing({
|
||||
category: row.category,
|
||||
month: `${year}-${String(monthIndex + 1).padStart(2, '0')}`,
|
||||
current: row.budget[monthIndex]!,
|
||||
hasBudget: !!row.line,
|
||||
});
|
||||
|
||||
const empty = budgets && budgets.items.length === 0;
|
||||
const canCopy = empty && (previous.data?.items.length ?? 0) > 0;
|
||||
const offerNextYear = month.slice(5, 7) === '12' && !empty;
|
||||
const expenseRows = sections?.expense.flatMap((s) => s.rows) ?? [];
|
||||
const incomeRows = sections?.income.flatMap((s) => s.rows) ?? [];
|
||||
const pace = month === thisMonth() ? new Date().getDate() / new Date(year, i + 1, 0).getDate() : undefined;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageIntro
|
||||
title="Budget"
|
||||
titleOnPhone={false}
|
||||
description="One amount per category for every month, with any month set differently when you need it. No rollover: each month starts fresh, and the year view keeps the running score."
|
||||
actions={
|
||||
canEdit ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline">
|
||||
<Wand2 /> Fill in
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-72">
|
||||
<DropdownMenuItem onSelect={() => fill.mutate('average3')}>
|
||||
<Sparkles /> From my 3-month average
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => fill.mutate('last_actual')}>
|
||||
<CalendarRange /> From last month’s spending
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => fill.mutate('last_budget')}>
|
||||
<Copy /> From last month’s budget
|
||||
</DropdownMenuItem>
|
||||
<p className="px-2.5 pb-1.5 pt-1 text-[12px] leading-snug text-muted-foreground">Fills only categories without a budget, rounded to friendly amounts.</p>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
{showYear ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setMonth(`${year - 1}-${month.slice(5)}`)} aria-label="Previous year">
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<span className="tabular min-w-20 text-center text-[16px] font-semibold">{year}</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => setMonth(`${year + 1}-${month.slice(5)}`)} aria-label="Next year">
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<MonthSwitcher month={month} onChange={setMonth} />
|
||||
)}
|
||||
{wide ? (
|
||||
<Segmented
|
||||
label="View"
|
||||
value={view}
|
||||
onChange={setView}
|
||||
options={[
|
||||
{ value: 'month', label: 'Month', icon: <LayoutList /> },
|
||||
{ value: 'year', label: 'Year', icon: <Table2 /> },
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && canCopy ? (
|
||||
<Card className="mb-5 flex flex-col gap-3 p-5 sm:flex-row sm:items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-[15px] font-semibold">Start {year} from {year - 1}’s budgets?</p>
|
||||
<p className="text-[13.5px] text-muted-foreground">Every category and month comes across; change anything afterwards.</p>
|
||||
</div>
|
||||
<Button onClick={() => copy.mutate()} loading={copy.isPending}>
|
||||
<Copy /> Copy {year - 1}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
{canEdit && offerNextYear ? (
|
||||
<Card className="mb-5 flex flex-col gap-3 p-5 sm:flex-row sm:items-center">
|
||||
<div className="flex-1">
|
||||
<p className="text-[15px] font-semibold">Plan {year + 1}</p>
|
||||
<p className="text-[13.5px] text-muted-foreground">Bring this year’s budgets into next year, then adjust.</p>
|
||||
</div>
|
||||
<Button variant="outline" onClick={() => setMonth(`${year + 1}-01`)}>
|
||||
Go to {year + 1}
|
||||
</Button>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{loading || !sections ? (
|
||||
<Skeleton className="h-96 rounded-2xl" />
|
||||
) : (
|
||||
<>
|
||||
{!showYear ? (
|
||||
<Summary spend={sum(expenseRows, 'budget', i)} spent={sum(expenseRows, 'actual', i)} income={sum(incomeRows, 'budget', i)} received={sum(incomeRows, 'actual', i)} />
|
||||
) : null}
|
||||
{empty && canEdit && !canCopy ? (
|
||||
<div className={cn('mb-5 flex items-start gap-3 rounded-2xl border px-4 py-3.5 text-[13.5px]', STATUS_TONE.none.soft)}>
|
||||
<Wand2 className="mt-0.5 size-4 shrink-0 text-primary" />
|
||||
<p>
|
||||
<span className="font-medium">No budgets for {year} yet.</span> Use <span className="font-medium">Fill in</span> to start every category from your recent spending, or tap any amount to set it.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
{showYear ? (
|
||||
<YearGrid year={year} sections={sections} canEdit={canEdit} onEdit={edit} onMonth={(m) => (setMonth(m), setView('month'))} through={report?.dataThrough?.slice(0, 7) ?? thisMonth()} />
|
||||
) : (
|
||||
<MonthList month={month} sections={sections} canEdit={canEdit} onEdit={(r) => edit(r)} unfiled={report?.unfiled.out[i] ?? 0} pace={pace} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<BudgetEditor target={editing} onClose={() => setEditing(null)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
import { formatCents, type TransactionPage } from '@spendlight/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRight, Check, Inbox, Landmark, ScanFace, Share, Smartphone, Target, Upload } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { daysInMonth, formatCents, LIABILITY_KINDS, monthName } from '@spendlight/shared';
|
||||
import { ArrowRight, CalendarClock, Check, Inbox, Landmark, ScanFace, Share, Smartphone, Sparkles, Target, TrendingDown, TrendingUp, Upload } from 'lucide-react';
|
||||
import { useState, type ReactNode } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { AlertCard } from '@/components/AlertCard';
|
||||
import { BudgetNote, ProgressBar, Ring } from '@/components/meter';
|
||||
import { AccountDot, CategoryIcon, formatDay } from '@/components/money';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge, Skeleton } from '@/components/ui/misc';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAccounts, useReviewCount } from '@/lib/data';
|
||||
import { useAccounts, useAlerts, useCategories, useOverview, useUpcoming } from '@/lib/data';
|
||||
import { whole } from '@/lib/money';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { cn, isIos, isStandalone } from '@/lib/utils';
|
||||
|
||||
@@ -15,36 +18,19 @@ function greeting(): string {
|
||||
return hour < 12 ? 'Good morning' : hour < 18 ? 'Good afternoon' : 'Good evening';
|
||||
}
|
||||
|
||||
function Step({
|
||||
done,
|
||||
icon,
|
||||
title,
|
||||
detail,
|
||||
action,
|
||||
later,
|
||||
}: {
|
||||
done?: boolean;
|
||||
icon: ReactNode;
|
||||
title: string;
|
||||
detail: ReactNode;
|
||||
action?: ReactNode;
|
||||
later?: string;
|
||||
}) {
|
||||
function Step({ done, icon, title, detail, action }: { done?: boolean; icon: ReactNode; title: string; detail: ReactNode; action?: ReactNode }) {
|
||||
return (
|
||||
<li className="flex gap-4 py-4 first:pt-1 last:pb-1">
|
||||
<span
|
||||
className={cn(
|
||||
'mt-0.5 flex size-9 shrink-0 items-center justify-center rounded-xl [&_svg]:size-[18px]',
|
||||
done ? 'bg-good/12 text-good' : later ? 'bg-muted text-muted-foreground' : 'bg-accent text-accent-foreground',
|
||||
done ? 'bg-good/12 text-good' : 'bg-accent text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{done ? <Check strokeWidth={2.5} /> : icon}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<p className={cn('text-[15px] font-medium', done && 'text-muted-foreground line-through decoration-muted-foreground/40')}>{title}</p>
|
||||
{later ? <Badge>{later}</Badge> : null}
|
||||
</div>
|
||||
<p className={cn('text-[15px] font-medium', done && 'text-muted-foreground line-through decoration-muted-foreground/40')}>{title}</p>
|
||||
{!done ? <p className="mt-0.5 text-[13.5px] leading-relaxed text-muted-foreground">{detail}</p> : null}
|
||||
</div>
|
||||
{!done && action ? <div className="shrink-0 self-center">{action}</div> : null}
|
||||
@@ -52,145 +38,327 @@ function Step({
|
||||
);
|
||||
}
|
||||
|
||||
const go = (to: string, label: string) => (
|
||||
<Link to={to} className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
|
||||
{label} <ArrowRight className="size-4" />
|
||||
</Link>
|
||||
);
|
||||
|
||||
const HIDE_KEY = 'sl-hide-getting-started';
|
||||
const touch = () => window.matchMedia('(pointer: coarse)').matches;
|
||||
|
||||
function GettingStarted({ hasAccounts, hasMoney, toReview, hasBudgets, onHide }: { hasAccounts: boolean; hasMoney: boolean; toReview: number; hasBudgets: boolean; onHide?: () => void }) {
|
||||
const me = useCurrentUser();
|
||||
// Adding to the home screen is for phones and tablets.
|
||||
const installed = isStandalone() || !touch();
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start justify-between gap-3">
|
||||
<div className="grid gap-1">
|
||||
<CardTitle>Getting started</CardTitle>
|
||||
<CardDescription>A few steps now, and Spendlight does the bookkeeping from then on.</CardDescription>
|
||||
</div>
|
||||
{onHide ? (
|
||||
<Button variant="ghost" size="sm" onClick={onHide}>
|
||||
Hide
|
||||
</Button>
|
||||
) : null}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="divide-y">
|
||||
<Step done={me.hasPasskey} icon={<ScanFace />} title="Turn on Face ID or fingerprint sign-in" detail="One glance instead of a password, if this device is ever signed out." action={go('/settings/security', 'Set up')} />
|
||||
<Step
|
||||
done={installed}
|
||||
icon={isIos() ? <Share /> : <Smartphone />}
|
||||
title="Add Spendlight to your home screen"
|
||||
detail={isIos() ? 'In Safari, tap Share, then “Add to Home Screen”. It opens like an app and stays signed in.' : 'In your browser’s menu, choose “Install app” or “Add to Home screen”.'}
|
||||
/>
|
||||
<Step done={hasAccounts} icon={<Landmark />} title="Add your accounts" detail="Checking, savings and every card. Or skip ahead: importing a statement offers to create its account." action={go('/accounts', 'Add')} />
|
||||
<Step done={hasMoney} icon={<Upload />} title="Drop in your statements" detail="Download a CSV from each bank and drop them in together. Spendlight recognizes the bank and skips duplicates." action={go('/import', 'Import')} />
|
||||
<Step done={hasMoney && toReview === 0} icon={<Inbox />} title="Review what came in" detail="Confirm Spendlight’s guesses. It learns from every choice." action={toReview ? go('/review', 'Review') : undefined} />
|
||||
<Step done={hasBudgets} icon={<Target />} title="Set your monthly budgets" detail="Start from your recent spending in one tap, then adjust." action={go('/budget', 'Budget')} />
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Tile({ label, value, sub, to }: { label: string; value: string | null; sub?: ReactNode; to?: string }) {
|
||||
const body = (
|
||||
<>
|
||||
<p className="text-[11.5px] font-medium text-white/55">{label}</p>
|
||||
{value !== null ? <p className="tabular mt-1 truncate text-[18px] font-semibold tracking-tight sm:text-[21px]">{value}</p> : <Skeleton className="mt-2 h-6 w-16 bg-white/10" />}
|
||||
{sub ? <p className="mt-0.5 truncate text-[11.5px] text-white/50">{sub}</p> : null}
|
||||
</>
|
||||
);
|
||||
const cls = 'min-w-0 rounded-2xl border border-white/10 bg-white/5 p-3.5 backdrop-blur';
|
||||
return to ? (
|
||||
<Link to={to} className={cn(cls, 'transition-colors hover:bg-white/10')}>
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div className={cls}>{body}</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const me = useCurrentUser();
|
||||
const firstName = me.displayName.split(' ')[0];
|
||||
const installed = isStandalone();
|
||||
const today = new Date().toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' });
|
||||
const now = new Date();
|
||||
const monthStart = new Date(now.getFullYear(), now.getMonth(), 1).toLocaleDateString('en-CA');
|
||||
const monthName = now.toLocaleDateString(undefined, { month: 'long' });
|
||||
const overview = useOverview();
|
||||
const accounts = useAccounts();
|
||||
const { data: toReview } = useReviewCount();
|
||||
const everything = useQuery({ queryKey: ['transactions', 'home-any'], queryFn: () => api<TransactionPage>('/transactions?limit=1') });
|
||||
// Statements usually arrive after a month ends, so until this month has
|
||||
// transactions, Home shows the most recent month that does.
|
||||
const latest = everything.data?.items[0]?.date;
|
||||
const showStart = latest && latest < monthStart ? `${latest.slice(0, 7)}-01` : monthStart;
|
||||
const showEnd = new Date(Number(showStart.slice(0, 4)), Number(showStart.slice(5, 7)), 0).toLocaleDateString('en-CA');
|
||||
const shownName = new Date(Number(showStart.slice(0, 4)), Number(showStart.slice(5, 7)) - 1, 1).toLocaleDateString(undefined, { month: 'long' });
|
||||
const month = useQuery({
|
||||
queryKey: ['transactions', 'home-month', showStart],
|
||||
queryFn: () => api<TransactionPage>(`/transactions?from=${showStart}&to=${showEnd}&limit=1`),
|
||||
enabled: everything.isSuccess,
|
||||
});
|
||||
const alerts = useAlerts(undefined, 3);
|
||||
const upcoming = useUpcoming(30);
|
||||
const { byId } = useCategories();
|
||||
const o = overview.data;
|
||||
const hasAccounts = (accounts.data?.length ?? 0) > 0;
|
||||
const hasMoney = (everything.data?.total ?? 0) > 0;
|
||||
const hasMoney = !!o?.dataThrough;
|
||||
const left = o ? o.budgetSpend - o.spent : 0;
|
||||
const month = o ? monthName(o.month) : '';
|
||||
const saved = o ? o.income - o.spent : 0;
|
||||
const pace = o && o.isCurrent ? 1 - o.daysLeft / daysInMonth(o.month) : undefined;
|
||||
|
||||
const open = accounts.data?.filter((a) => a.status === 'open' && a.balanceConfirmedOn) ?? [];
|
||||
const cash = open.filter((a) => !LIABILITY_KINDS.includes(a.kind)).reduce((s, a) => s + a.balanceCents, 0);
|
||||
const owed = open.filter((a) => LIABILITY_KINDS.includes(a.kind)).reduce((s, a) => s + a.balanceCents, 0);
|
||||
const yearGap = o ? o.year.budgetYear - o.year.projected : 0;
|
||||
const [hidden, setHidden] = useState(() => {
|
||||
try {
|
||||
return localStorage.getItem(HIDE_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
const hide = () => {
|
||||
setHidden(true);
|
||||
try {
|
||||
localStorage.setItem(HIDE_KEY, '1');
|
||||
} catch {
|
||||
/* fine */
|
||||
}
|
||||
};
|
||||
const setupDone = hidden || (me.hasPasskey && (isStandalone() || !touch()) && hasAccounts && hasMoney && o?.toReview === 0 && o.hasBudgets);
|
||||
|
||||
let headline: ReactNode = null;
|
||||
if (o && hasMoney) {
|
||||
headline = o.hasBudgets ? (
|
||||
<>
|
||||
<p className="text-[13px] font-medium text-white/60">{left >= 0 ? `Left to spend in ${month}` : `Over budget in ${month}`}</p>
|
||||
<p className={cn('tabular mt-1 text-[44px] font-semibold leading-none tracking-tight sm:text-[52px]', left < 0 && 'text-[#fca5a5]')}>{whole(Math.abs(left))}</p>
|
||||
<p className="mt-3 text-[14px] text-white/70">
|
||||
{whole(o.spent)} spent of {whole(o.budgetSpend)}
|
||||
{o.isCurrent ? ` · ${o.daysLeft} day${o.daysLeft === 1 ? '' : 's'} to go` : ''}
|
||||
</p>
|
||||
{o.unbudgeted > 0 ? (
|
||||
<Link to={`/budget?month=${o.month}`} className="mt-1 block text-[13px] text-white/55 underline-offset-2 hover:text-white/80 hover:underline">
|
||||
Includes {whole(o.unbudgeted)} in categories without a budget
|
||||
</Link>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-[13px] font-medium text-white/60">Spent in {month}</p>
|
||||
<p className="tabular mt-1 text-[44px] font-semibold leading-none tracking-tight sm:text-[52px]">{whole(o.spent)}</p>
|
||||
<p className="mt-3 max-w-md text-[14px] text-white/70">Set budgets and this becomes what’s left to spend, with the year’s pace below.</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid gap-5 sm:gap-6">
|
||||
<section className="relative overflow-hidden rounded-3xl bg-night px-6 dark:bg-card dark:ring-1 dark:ring-border py-8 text-white shadow-lift sm:px-9 sm:py-10">
|
||||
<section className="relative overflow-hidden rounded-3xl bg-night px-6 py-7 text-white shadow-lift dark:bg-card dark:ring-1 dark:ring-border sm:px-9 sm:py-9">
|
||||
<div className="pointer-events-none absolute -top-24 left-1/2 h-72 w-[38rem] -translate-x-1/2 rounded-full bg-[radial-gradient(closest-side,rgb(251_191_36/0.45),rgb(249_115_98/0.18),transparent)] blur-2xl" aria-hidden />
|
||||
<div className="relative">
|
||||
<p className="text-[13px] font-medium text-white/60">{today}</p>
|
||||
<h2 className="mt-1 text-[26px] font-semibold tracking-tight sm:text-3xl">
|
||||
{greeting()}, {firstName}.
|
||||
</h2>
|
||||
<p className="mt-2 max-w-lg text-[15px] leading-relaxed text-white/75">
|
||||
{hasMoney
|
||||
? showStart === monthStart
|
||||
? `Here’s ${monthName} so far. Budgets arrive next, and with them what’s left to spend and how the year is pacing.`
|
||||
: `Here’s ${shownName}, your latest month in Spendlight. Budgets arrive next, and with them what’s left to spend and how the year is pacing.`
|
||||
: 'Spendlight is ready for your accounts. Once your statements are in, this is where you’ll see what’s left to spend this month and how the year is pacing.'}
|
||||
<p className="text-[13px] font-medium text-white/60">
|
||||
{greeting()}, {firstName}
|
||||
{o && hasMoney && !o.isCurrent ? ` · Showing ${month}, your latest statements` : ''}
|
||||
</p>
|
||||
<div className="mt-7 grid max-w-xl grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: 'Spent', value: month.data ? formatCents(-month.data.sums.outCents, { whole: true }) : null },
|
||||
{ label: 'Money in', value: month.data ? formatCents(month.data.sums.inCents, { whole: true }) : null },
|
||||
{ label: 'To review', value: toReview !== undefined ? String(toReview) : null, to: '/review' },
|
||||
].map(({ label, value, to }) => {
|
||||
const body = (
|
||||
<>
|
||||
<p className="text-[11.5px] font-medium text-white/55">{label}</p>
|
||||
{value !== null && hasMoney ? (
|
||||
<p className="tabular mt-1 truncate text-[18px] font-semibold tracking-tight sm:text-[22px]">{value}</p>
|
||||
) : (
|
||||
<Skeleton className="mt-2 h-6 w-16 bg-white/10" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
return to && hasMoney ? (
|
||||
<Link key={label} to={to} className="rounded-2xl border border-white/10 bg-white/5 p-3.5 backdrop-blur transition-colors hover:bg-white/10">
|
||||
{body}
|
||||
</Link>
|
||||
) : (
|
||||
<div key={label} className="rounded-2xl border border-white/10 bg-white/5 p-3.5 backdrop-blur">
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{overview.isPending ? (
|
||||
<Skeleton className="mt-4 h-24 w-64 bg-white/10" />
|
||||
) : headline ? (
|
||||
<div className="mt-4 flex items-center justify-between gap-6">
|
||||
<div className="min-w-0">{headline}</div>
|
||||
{o!.hasBudgets ? (
|
||||
<Ring spent={o!.spent} budget={o!.budgetSpend} size={132} stroke={12} className="hidden text-white sm:block">
|
||||
<span className="tabular text-[24px] font-semibold">{o!.budgetSpend ? Math.round((o!.spent / o!.budgetSpend) * 100) : 0}%</span>
|
||||
<span className="text-[11.5px] text-white/60">of budget</span>
|
||||
</Ring>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<h2 className="mt-1 text-[26px] font-semibold tracking-tight sm:text-3xl">Welcome to Spendlight.</h2>
|
||||
<p className="mt-2 max-w-lg text-[15px] leading-relaxed text-white/75">
|
||||
Once your statements are in, this is where you’ll see what’s left to spend this month and how the year is pacing.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{o && hasMoney && o.hasBudgets && o.year.budgetSoFar > 0 ? (
|
||||
<Link to="/insights" className="mt-5 inline-flex items-center gap-2 rounded-full bg-white/8 px-3.5 py-1.5 text-[13px] font-medium text-white/85 ring-1 ring-white/10 hover:bg-white/12">
|
||||
{yearGap >= 0 ? <TrendingDown className="size-4 text-[#86efac]" /> : <TrendingUp className="size-4 text-[#fca5a5]" />}
|
||||
On pace to finish {o.year.year} {whole(Math.abs(yearGap))} {yearGap >= 0 ? 'under' : 'over'} budget
|
||||
</Link>
|
||||
) : null}
|
||||
{hasMoney && o ? (
|
||||
<div className="mt-6 grid max-w-2xl grid-cols-3 gap-3">
|
||||
<Tile label="Money in" value={whole(o.income)} />
|
||||
<Tile label="Money out" value={whole(o.spent)} />
|
||||
<Tile
|
||||
label={saved >= 0 ? 'Saved' : 'Overspent'}
|
||||
value={whole(Math.abs(saved))}
|
||||
sub={
|
||||
o.income > 0 ? (
|
||||
<>
|
||||
{Math.round((saved / o.income) * 100)}%<span className="hidden sm:inline"> of income</span>
|
||||
</>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Getting started</CardTitle>
|
||||
<CardDescription>A few steps now, and Spendlight does the bookkeeping from then on.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ol className="divide-y">
|
||||
<Step done icon={<Check />} title="Create your sign-in" detail="" />
|
||||
<Step
|
||||
done={me.hasPasskey}
|
||||
icon={<ScanFace />}
|
||||
title="Turn on Face ID or fingerprint sign-in"
|
||||
detail="One glance instead of a password, if this device is ever signed out."
|
||||
action={
|
||||
<Link to="/settings/security" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
|
||||
Set up <ArrowRight className="size-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Step
|
||||
done={installed}
|
||||
icon={isIos() ? <Share /> : <Smartphone />}
|
||||
title="Add Spendlight to your home screen"
|
||||
detail={
|
||||
isIos()
|
||||
? 'In Safari, tap Share, then “Add to Home Screen”. It opens like an app and stays signed in.'
|
||||
: 'In your browser’s menu, choose “Install app” or “Add to Home screen”.'
|
||||
}
|
||||
/>
|
||||
<Step
|
||||
done={hasAccounts}
|
||||
icon={<Landmark />}
|
||||
title="Add your accounts"
|
||||
detail="Checking, savings and every card, each with its own color. Or skip ahead: importing a statement offers to create its account."
|
||||
action={
|
||||
<Link to="/accounts" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
|
||||
Add <ArrowRight className="size-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Step
|
||||
done={hasMoney}
|
||||
icon={<Upload />}
|
||||
title="Drop in your statements"
|
||||
detail="Download a CSV from each bank’s website and drop them in together. Spendlight recognizes the bank, skips duplicates and fills in categories."
|
||||
action={
|
||||
<Link to="/import" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
|
||||
Import <ArrowRight className="size-4" />
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<Step
|
||||
done={hasMoney && toReview === 0}
|
||||
icon={<Inbox />}
|
||||
title="Review what came in"
|
||||
detail={toReview ? `${toReview} transaction${toReview === 1 ? '' : 's'} waiting, most already filled in. Usually a few taps.` : 'Confirm Spendlight’s guesses. It learns from every choice.'}
|
||||
action={
|
||||
toReview ? (
|
||||
<Link to="/review" className="flex items-center gap-1 text-[13.5px] font-medium text-primary">
|
||||
Review <ArrowRight className="size-4" />
|
||||
{!hasMoney && o ? <GettingStarted hasAccounts={hasAccounts} hasMoney={hasMoney} toReview={o.toReview} hasBudgets={o.hasBudgets} /> : null}
|
||||
|
||||
{o && o.toReview > 0 ? (
|
||||
<Link to="/review" className="flex items-center gap-4 rounded-2xl border border-primary/25 bg-accent px-5 py-4 text-accent-foreground shadow-soft transition-colors hover:bg-accent/80">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground">
|
||||
<Inbox className="size-5" />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[15px] font-semibold">
|
||||
{o.toReview} transaction{o.toReview === 1 ? '' : 's'} need{o.toReview === 1 ? 's' : ''} a category
|
||||
</p>
|
||||
<p className="text-[13px] opacity-80">Most are already filled in. A few taps and the numbers here are exact.</p>
|
||||
</div>
|
||||
<ArrowRight className="size-5 shrink-0" />
|
||||
</Link>
|
||||
) : null}
|
||||
|
||||
{hasMoney && o ? (
|
||||
<div className="grid gap-5 lg:grid-cols-[1.25fr_1fr] lg:gap-6">
|
||||
<div className="grid content-start gap-5 lg:gap-6">
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<div className="grid gap-1">
|
||||
<CardTitle>Where it went in {month}</CardTitle>
|
||||
<CardDescription>{o.hasBudgets ? 'Each category against its budget.' : 'Your biggest categories this month.'}</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to="/budget">
|
||||
Budget <ArrowRight />
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<Step icon={<Target />} later="Coming in milestone 2" title="Set your monthly budgets" detail="Start from last month’s spending and adjust from there." />
|
||||
</ol>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{o.categories.length ? (
|
||||
<ul className="grid gap-4">
|
||||
{o.categories.slice(0, 7).map((c) => {
|
||||
const cat = byId.get(c.categoryId);
|
||||
return (
|
||||
<li key={c.categoryId}>
|
||||
<Link to={`/activity?categoryId=${c.categoryId}&month=${o.month}`} className="flex items-center gap-3">
|
||||
<CategoryIcon icon={cat?.icon} color={cat?.color} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<span className="truncate text-[14.5px] font-medium">{cat?.name}</span>
|
||||
<span className="tabular shrink-0 text-[14px] font-medium">
|
||||
{whole(c.spent)}
|
||||
{c.budget > 0 ? <span className="font-normal text-muted-foreground"> / {whole(c.budget)}</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar spent={c.spent} budget={c.budget} pace={pace} scale={o.categories[0]?.spent} className="mt-1.5" />
|
||||
{c.budget > 0 ? <BudgetNote spent={c.spent} budget={c.budget} className="mt-1" /> : null}
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-[14px] text-muted-foreground">Nothing spent yet this month.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid content-start gap-5 lg:gap-6">
|
||||
<section>
|
||||
<div className="mb-2.5 flex items-center justify-between px-1">
|
||||
<h2 className="flex items-center gap-2 text-[15px] font-semibold">
|
||||
<Sparkles className="size-4 text-warn" /> Insights
|
||||
</h2>
|
||||
<Link to="/insights" className="text-[13px] font-medium text-primary">
|
||||
See all
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid gap-2.5">
|
||||
{alerts.data?.length ? (
|
||||
alerts.data.map((a) => <AlertCard key={a.id} alert={a} compact />)
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed px-4 py-5 text-center text-[13.5px] text-muted-foreground">
|
||||
Nothing needs your attention. Insights appear here as the months fill in.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle>Balances</CardTitle>
|
||||
<Link to="/accounts" className="text-[13px] font-medium text-primary">
|
||||
Accounts
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-muted/60 p-3.5">
|
||||
<p className="text-[12px] font-medium text-muted-foreground">Cash</p>
|
||||
<p className="tabular mt-0.5 text-[19px] font-semibold">{formatCents(cash, { whole: true })}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted/60 p-3.5">
|
||||
<p className="text-[12px] font-medium text-muted-foreground">Owed on cards</p>
|
||||
<p className="tabular mt-0.5 text-[19px] font-semibold">{formatCents(Math.abs(owed), { whole: true })}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarClock className="size-4 text-muted-foreground" /> Coming up
|
||||
</CardTitle>
|
||||
<Link to="/subscriptions" className="text-[13px] font-medium text-primary">
|
||||
Subscriptions
|
||||
</Link>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{upcoming.data?.length ? (
|
||||
<ul className="grid gap-2.5">
|
||||
{upcoming.data.slice(0, 5).map((u) => {
|
||||
const account = accounts.data?.find((a) => a.id === u.accountId);
|
||||
return (
|
||||
<li key={`${u.subscriptionId}-${u.date}`} className="flex items-center gap-3 text-[14px]">
|
||||
<span className="w-14 shrink-0 text-[12.5px] font-medium text-muted-foreground">{formatDay(u.date, true)}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{u.name}
|
||||
{u.trial ? <Badge tone="warn" className="ml-2">Trial ends</Badge> : null}
|
||||
</span>
|
||||
{account ? <AccountDot color={account.color} /> : null}
|
||||
<span className="tabular shrink-0">{formatCents(u.amountCents)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-[13.5px] text-muted-foreground">Confirm your subscriptions and their next charges show up here.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{hasMoney && o && !setupDone ? <GettingStarted hasAccounts={hasAccounts} hasMoney={hasMoney} toReview={o.toReview} hasBudgets={o.hasBudgets} onHide={hide} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
613
web/src/pages/InsightsPage.tsx
Normal file
613
web/src/pages/InsightsPage.tsx
Normal file
@@ -0,0 +1,613 @@
|
||||
import { addMonths, formatCents, LIABILITY_KINDS, monthName, type BalanceSeries, type MerchantStat, type MonthsReport, type TagReport } from '@spendlight/shared';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowRight, ChevronLeft, ChevronRight, Sparkles, TrendingDown, TrendingUp } from 'lucide-react';
|
||||
import { useMemo, useState, type ReactNode } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { Area, Bar, CartesianGrid, ComposedChart, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts';
|
||||
import { AlertCard } from '@/components/AlertCard';
|
||||
import { BudgetNote, ProgressBar } from '@/components/meter';
|
||||
import { AccountDot, CategoryIcon } from '@/components/money';
|
||||
import { EmptyState, PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Segmented, Skeleton } from '@/components/ui/misc';
|
||||
import { api } from '@/lib/api';
|
||||
import { useAccounts, useAlerts, useCategories, useMonthsReport, useOverview } from '@/lib/data';
|
||||
import { budgetStatus, monthBounds, whole } from '@/lib/money';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type View = 'overview' | 'budget' | 'categories' | 'merchants' | 'balances' | 'tags';
|
||||
const VIEWS: { value: View; label: string }[] = [
|
||||
{ value: 'overview', label: 'Overview' },
|
||||
{ value: 'budget', label: 'Budget vs actual' },
|
||||
{ value: 'categories', label: 'Categories' },
|
||||
{ value: 'merchants', label: 'Merchants' },
|
||||
{ value: 'balances', label: 'Balances' },
|
||||
{ value: 'tags', label: 'Tags' },
|
||||
];
|
||||
|
||||
const compact = new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD', notation: 'compact', maximumFractionDigits: 1 });
|
||||
const axisMoney = (v: number) => compact.format(v / 100);
|
||||
const tick = { fontSize: 12, fill: 'var(--muted-foreground)' };
|
||||
|
||||
function ChartTip({ active, payload, label, labeler }: { active?: boolean; payload?: { name: string; value: number; color: string; dataKey: string }[]; label?: string; labeler?: (l: string) => string }) {
|
||||
if (!active || !payload?.length) return null;
|
||||
return (
|
||||
<div className="rounded-xl border bg-popover px-3 py-2 text-[12.5px] shadow-lift">
|
||||
<p className="mb-1 font-semibold">{labeler ? labeler(String(label)) : label}</p>
|
||||
{payload
|
||||
.filter((p) => p.value !== null && p.value !== undefined)
|
||||
.map((p) => (
|
||||
<p key={p.dataKey} className="tabular flex items-center gap-2">
|
||||
<span className="size-2 rounded-full" style={{ background: p.color }} />
|
||||
<span className="text-muted-foreground">{p.name}</span>
|
||||
<span className="ml-auto pl-3 font-medium">{formatCents(p.value, { whole: true })}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Headline({ children, tone }: { children: ReactNode; tone: 'good' | 'warn' | 'neutral' }) {
|
||||
const Icon = tone === 'good' ? TrendingDown : tone === 'warn' ? TrendingUp : Sparkles;
|
||||
return (
|
||||
<div className="mb-5 flex items-center gap-3 rounded-2xl border bg-card px-5 py-4 shadow-soft">
|
||||
<span className={cn('flex size-10 shrink-0 items-center justify-center rounded-xl', tone === 'good' ? 'bg-good/12 text-good' : tone === 'warn' ? 'bg-warn/14 text-warn' : 'bg-accent text-accent-foreground')}>
|
||||
<Icon className="size-5" />
|
||||
</span>
|
||||
<p className="text-[16px] font-semibold tracking-tight sm:text-[17px]">{children}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function YearSwitcher({ year, onChange }: { year: number; onChange: (y: number) => void }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => onChange(year - 1)} aria-label="Previous year">
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<span className="tabular min-w-14 text-center text-[15px] font-semibold">{year}</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => onChange(year + 1)} aria-label="Next year">
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Months of the year that have happened (by the statements' reach). */
|
||||
const monthsDone = (report: MonthsReport) => {
|
||||
const through = report.dataThrough?.slice(0, 7);
|
||||
return report.months.filter((m) => through && m <= through).length;
|
||||
};
|
||||
|
||||
function Overview({ report, year }: { report: MonthsReport; year: number }) {
|
||||
const overview = useOverview();
|
||||
const alerts = useAlerts('money');
|
||||
const done = monthsDone(report);
|
||||
const o = overview.data?.year.year === year ? overview.data : undefined;
|
||||
const budgetYear = report.totals.budgetSpend.reduce((a, b) => a + b, 0);
|
||||
|
||||
const pacing = useMemo(() => {
|
||||
let actual = 0;
|
||||
let budget = 0;
|
||||
const projectedEnd = o?.year.projected ?? 0;
|
||||
return report.months.map((m, i) => {
|
||||
budget += report.totals.budgetSpend[i]!;
|
||||
if (i < done) actual += report.totals.spent[i]!;
|
||||
const row: { month: string; actual: number | null; budget: number; projection: number | null } = { month: m, actual: i < done ? actual : null, budget, projection: null };
|
||||
if (o && i >= done - 1 && done > 0) {
|
||||
const start = report.totals.spent.slice(0, done).reduce((a, b) => a + b, 0);
|
||||
row.projection = done >= 12 ? null : Math.round(start + ((projectedEnd - start) * (i - (done - 1))) / (12 - done));
|
||||
}
|
||||
return row;
|
||||
});
|
||||
}, [report, done, o]);
|
||||
|
||||
const flow = report.months.slice(0, Math.max(done, 1)).map((m, i) => ({ month: m, in: report.totals.income[i]!, out: report.totals.spent[i]!, net: report.totals.income[i]! - report.totals.spent[i]! }));
|
||||
const totalIn = flow.reduce((s, f) => s + f.in, 0);
|
||||
const totalOut = flow.reduce((s, f) => s + f.out, 0);
|
||||
const gap = o ? o.year.budgetYear - o.year.projected : 0;
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
{o && budgetYear > 0 ? (
|
||||
<Headline tone={gap >= 0 ? 'good' : 'warn'}>
|
||||
On pace to finish {year} {whole(Math.abs(gap))} {gap >= 0 ? 'under' : 'over'} budget
|
||||
</Headline>
|
||||
) : totalIn || totalOut ? (
|
||||
<Headline tone={totalIn >= totalOut ? 'good' : 'warn'}>
|
||||
{totalIn >= totalOut ? `Saved ${whole(totalIn - totalOut)} so far in ${year}` : `Spent ${whole(totalOut - totalIn)} more than came in so far in ${year}`}
|
||||
</Headline>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Year pacing</CardTitle>
|
||||
<CardDescription>Spending so far against the budget so far{o ? ', with where the year is heading' : ''}.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-64 pl-1">
|
||||
{budgetYear > 0 ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={pacing} margin={{ left: 0, right: 8, top: 6, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" />
|
||||
<XAxis dataKey="month" tickFormatter={(m: string) => monthName(m, 'short').slice(0, 1)} tick={tick} axisLine={false} tickLine={false} />
|
||||
<YAxis tickFormatter={axisMoney} tick={tick} axisLine={false} tickLine={false} width={48} />
|
||||
<Tooltip content={<ChartTip labeler={(m) => monthName(m, 'long', true)} />} />
|
||||
<Area type="monotone" dataKey="budget" name="Budget" stroke="var(--muted-foreground)" strokeDasharray="4 4" fill="var(--muted)" fillOpacity={0.5} />
|
||||
<Line type="monotone" dataKey="actual" name="Spent" stroke="var(--primary)" strokeWidth={2.5} dot={false} connectNulls={false} />
|
||||
<Line type="monotone" dataKey="projection" name="Projected" stroke="var(--primary)" strokeWidth={2} strokeDasharray="2 5" dot={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart>Set budgets to see how the year is pacing.</EmptyChart>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Money in and out</CardTitle>
|
||||
<CardDescription>
|
||||
{totalIn > 0 ? `Saved ${Math.round(((totalIn - totalOut) / totalIn) * 100)}% of income so far in ${year}.` : 'Income and spending by month.'} Transfers between your accounts don’t count.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-64 pl-1">
|
||||
{done ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={flow} margin={{ left: 0, right: 8, top: 6, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" />
|
||||
<XAxis dataKey="month" tickFormatter={(m: string) => monthName(m, 'short')} tick={tick} axisLine={false} tickLine={false} />
|
||||
<YAxis tickFormatter={axisMoney} tick={tick} axisLine={false} tickLine={false} width={48} />
|
||||
<Tooltip content={<ChartTip labeler={(m) => monthName(m, 'long', true)} />} cursor={{ fill: 'var(--muted)', opacity: 0.5 }} />
|
||||
<Bar dataKey="in" name="Money in" fill="var(--good)" radius={[6, 6, 0, 0]} maxBarSize={22} />
|
||||
<Bar dataKey="out" name="Money out" fill="var(--primary)" radius={[6, 6, 0, 0]} maxBarSize={22} />
|
||||
<Line type="monotone" dataKey="net" name="Saved" stroke="var(--warn)" strokeWidth={2} dot={{ r: 3 }} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart>Import statements to see money in and out.</EmptyChart>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2.5 flex items-center gap-2 px-1 text-[15px] font-semibold">
|
||||
<Sparkles className="size-4 text-warn" /> What stands out
|
||||
</h2>
|
||||
{alerts.data?.length ? (
|
||||
<div className="grid gap-2.5 lg:grid-cols-2">
|
||||
{alerts.data.map((a) => (
|
||||
<AlertCard key={a.id} alert={a} compact />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-2xl border border-dashed px-4 py-5 text-center text-[13.5px] text-muted-foreground">Nothing unusual right now.</p>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyChart({ children }: { children: ReactNode }) {
|
||||
return <div className="flex h-full items-center justify-center rounded-xl bg-muted/50 px-6 text-center text-[13.5px] text-muted-foreground">{children}</div>;
|
||||
}
|
||||
|
||||
function BudgetVsActual({ report, year }: { report: MonthsReport; year: number }) {
|
||||
const { byId } = useCategories();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const done = Math.max(1, monthsDone(report));
|
||||
const month = params.get('month')?.startsWith(String(year)) ? params.get('month')! : report.months[done - 1]!;
|
||||
const i = report.months.indexOf(month);
|
||||
const [span, setSpan] = useState<'month' | 'ytd'>('month');
|
||||
const rows = report.categories
|
||||
.filter((c) => byId.get(c.categoryId)?.kind === 'expense')
|
||||
.map((c) => {
|
||||
const spent = span === 'month' ? c.actual[i]! : c.actual.slice(0, i + 1).reduce((a, b) => a + b, 0);
|
||||
const budget = span === 'month' ? c.budget[i]! : c.budget.slice(0, i + 1).reduce((a, b) => a + b, 0);
|
||||
return { c, spent, budget, gap: spent - budget };
|
||||
})
|
||||
.filter((r) => r.spent || r.budget)
|
||||
.sort((a, b) => b.gap - a.gap);
|
||||
const totalSpent = rows.reduce((s, r) => s + r.spent, 0);
|
||||
const totalBudget = rows.reduce((s, r) => s + r.budget, 0);
|
||||
const label = span === 'month' ? monthName(month) : `${monthName(`${year}-01`, 'short')}–${monthName(month, 'short')} ${year}`;
|
||||
const { from, to } = span === 'month' ? monthBounds(month) : { from: `${year}-01-01`, to: monthBounds(month).to };
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => setParams((p) => (p.set('month', addMonths(month, -1)), p), { replace: true })} disabled={i <= 0} aria-label="Previous month">
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
<span className="min-w-32 text-center text-[15px] font-semibold">{monthName(month, 'long', true)}</span>
|
||||
<Button variant="ghost" size="icon" onClick={() => setParams((p) => (p.set('month', addMonths(month, 1)), p), { replace: true })} disabled={i >= 11} aria-label="Next month">
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
<Segmented
|
||||
label="Period"
|
||||
value={span}
|
||||
onChange={setSpan}
|
||||
options={[
|
||||
{ value: 'month', label: 'This month' },
|
||||
{ value: 'ytd', label: 'Year to date' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{totalBudget > 0 ? (
|
||||
<Headline tone={totalSpent <= totalBudget ? 'good' : 'warn'}>
|
||||
{label}: {whole(Math.abs(totalBudget - totalSpent))} {totalSpent <= totalBudget ? 'under' : 'over'} budget ({Math.round((totalSpent / totalBudget) * 100)}% used)
|
||||
</Headline>
|
||||
) : null}
|
||||
{rows.length ? (
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{rows.map(({ c, spent, budget, gap }) => {
|
||||
const cat = byId.get(c.categoryId);
|
||||
const pct = budget ? Math.round((gap / budget) * 100) : null;
|
||||
return (
|
||||
<Link key={c.categoryId} to={`/activity?categoryId=${c.categoryId}&from=${from}&to=${to}`} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/60 sm:px-5">
|
||||
<CategoryIcon icon={cat?.icon} color={cat?.color} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-[14.5px] font-medium">{cat?.name}</span>
|
||||
<span className="tabular shrink-0 text-[13.5px]">
|
||||
{whole(spent)} <span className="text-muted-foreground">/ {budget ? whole(budget) : 'no budget'}</span>
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar spent={spent} budget={budget} className="mt-1.5" />
|
||||
<div className="mt-1 flex justify-between">
|
||||
<BudgetNote spent={spent} budget={budget} />
|
||||
{pct !== null && budgetStatus(spent, budget) === 'over' ? <span className="tabular text-[12px] font-medium text-bad">+{pct}%</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
) : (
|
||||
<EmptyState icon={Sparkles} title="Nothing to compare yet" description="Set budgets and import statements, and each category shows how it did." />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Categories({ report, year }: { report: MonthsReport; year: number }) {
|
||||
const { byId } = useCategories();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const done = Math.max(1, monthsDone(report));
|
||||
const rows = report.categories
|
||||
.filter((c) => byId.get(c.categoryId)?.kind === 'expense' && c.actual.some(Boolean))
|
||||
.sort((a, b) => b.actual.reduce((x, y) => x + y, 0) - a.actual.reduce((x, y) => x + y, 0));
|
||||
const selected = Number(params.get('categoryId')) || rows[0]?.categoryId;
|
||||
const current = rows.find((r) => r.categoryId === selected) ?? rows[0];
|
||||
const cat = current ? byId.get(current.categoryId) : undefined;
|
||||
const merchants = useQuery({
|
||||
queryKey: ['reports', 'merchants', year, current?.categoryId],
|
||||
queryFn: () => api<MerchantStat[]>(`/reports/merchants?from=${year}-01-01&to=${year}-12-31&categoryId=${current!.categoryId}&limit=6`),
|
||||
enabled: !!current,
|
||||
});
|
||||
if (!rows.length) return <EmptyState icon={Sparkles} title="No spending yet" description={`Import statements from ${year} and every category shows up here, month by month.`} />;
|
||||
const total = current!.actual.reduce((a, b) => a + b, 0);
|
||||
const average = Math.round(current!.actual.slice(0, done).reduce((a, b) => a + b, 0) / done);
|
||||
const chart = report.months.map((m, i) => ({ month: m, spent: current!.actual[i]!, budget: current!.budget[i]! || null }));
|
||||
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<Card>
|
||||
<CardHeader className="flex-row items-start gap-3">
|
||||
<CategoryIcon icon={cat?.icon} color={cat?.color} />
|
||||
<div className="grid flex-1 gap-1">
|
||||
<CardTitle>{cat?.name}</CardTitle>
|
||||
<CardDescription>
|
||||
{whole(total)} in {year} · about {whole(average)} a month
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" asChild>
|
||||
<Link to={`/activity?categoryId=${current!.categoryId}&from=${year}-01-01&to=${year}-12-31`}>
|
||||
Transactions <ArrowRight />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-6 lg:grid-cols-[1.6fr_1fr]">
|
||||
<div className="h-56">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<ComposedChart data={chart} margin={{ left: 0, right: 8, top: 6, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" />
|
||||
<XAxis dataKey="month" tickFormatter={(m: string) => monthName(m, 'short').slice(0, 1)} tick={tick} axisLine={false} tickLine={false} />
|
||||
<YAxis tickFormatter={axisMoney} tick={tick} axisLine={false} tickLine={false} width={44} />
|
||||
<Tooltip content={<ChartTip labeler={(m) => monthName(m, 'long', true)} />} cursor={{ fill: 'var(--muted)', opacity: 0.5 }} />
|
||||
<Bar dataKey="spent" name="Spent" fill="var(--primary)" radius={[6, 6, 0, 0]} maxBarSize={26} />
|
||||
<Line type="stepAfter" dataKey="budget" name="Budget" stroke="var(--warn)" strokeWidth={2} dot={false} />
|
||||
</ComposedChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-2 text-[13px] font-medium text-muted-foreground">Top merchants</p>
|
||||
<ul className="grid gap-2">
|
||||
{merchants.data?.map((m) => (
|
||||
<li key={m.merchant} className="flex items-center justify-between gap-3 text-[14px]">
|
||||
<Link to={`/activity?q=${encodeURIComponent(m.merchant)}&from=${year}-01-01&to=${year}-12-31`} className="truncate font-medium hover:underline">
|
||||
{m.merchant}
|
||||
</Link>
|
||||
<span className="tabular shrink-0 text-muted-foreground">
|
||||
{m.count}× · <span className="font-medium text-foreground">{whole(m.spent)}</span>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[920px] border-collapse text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b">
|
||||
<th className="sticky left-0 z-10 bg-card px-4 py-2.5 text-left font-semibold text-muted-foreground">Category</th>
|
||||
{report.months.map((m) => (
|
||||
<th key={m} className="px-2 py-2.5 text-right font-semibold">
|
||||
{monthName(m, 'short')}
|
||||
</th>
|
||||
))}
|
||||
<th className="border-l px-3 py-2.5 text-right font-semibold">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => {
|
||||
const c = byId.get(r.categoryId);
|
||||
return (
|
||||
<tr key={r.categoryId} className={cn('border-t', r.categoryId === current!.categoryId && 'bg-accent/50')}>
|
||||
<th scope="row" className="sticky left-0 z-10 bg-card px-4 py-1.5 text-left font-normal">
|
||||
<button onClick={() => setParams((p) => (p.set('categoryId', String(r.categoryId)), p), { replace: true })} className="flex items-center gap-2 text-left">
|
||||
<CategoryIcon icon={c?.icon} color={c?.color} size="sm" className="size-6 rounded-md [&_svg]:size-3.5" />
|
||||
<span className="truncate font-medium">{c?.name}</span>
|
||||
</button>
|
||||
</th>
|
||||
{r.actual.map((v, i) => {
|
||||
const over = r.budget[i]! > 0 && v > r.budget[i]!;
|
||||
return (
|
||||
<td key={i} className={cn('tabular px-2 py-1.5 text-right', over && 'bg-bad/8 font-medium text-bad', !v && 'text-muted-foreground/40')}>
|
||||
{v ? whole(v) : '–'}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
<td className="tabular border-l px-3 py-1.5 text-right font-semibold">{whole(r.actual.reduce((a, b) => a + b, 0))}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="border-t px-4 py-2.5 text-[12px] text-muted-foreground">Red cells ran over that month’s budget. Choose a category to see its trend and merchants.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Merchants({ year }: { year: number }) {
|
||||
const [sort, setSort] = useState<'spent' | 'count'>('spent');
|
||||
const { byId } = useCategories();
|
||||
const list = useQuery({ queryKey: ['reports', 'merchants', year, 'all'], queryFn: () => api<MerchantStat[]>(`/reports/merchants?from=${year}-01-01&to=${year}-12-31&limit=100`) });
|
||||
const rows = [...(list.data ?? [])].sort((a, b) => (sort === 'spent' ? b.spent - a.spent : b.count - a.count)).slice(0, 30);
|
||||
const top = rows[0] ? (sort === 'spent' ? rows[0].spent : rows[0].count) : 1;
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Segmented
|
||||
label="Sort"
|
||||
value={sort}
|
||||
onChange={setSort}
|
||||
options={[
|
||||
{ value: 'spent', label: 'By total' },
|
||||
{ value: 'count', label: 'By how often' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{list.isPending ? <Skeleton className="h-64 rounded-2xl" /> : null}
|
||||
{rows.length ? (
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{rows.map((m, i) => {
|
||||
const c = m.categoryId ? byId.get(m.categoryId) : undefined;
|
||||
const share = (sort === 'spent' ? m.spent : m.count) / top;
|
||||
return (
|
||||
<Link key={m.merchant} to={`/activity?q=${encodeURIComponent(m.merchant)}&from=${year}-01-01&to=${year}-12-31`} className="flex items-center gap-3 px-4 py-3 hover:bg-muted/60 sm:px-5">
|
||||
<span className="tabular w-5 shrink-0 text-right text-[12.5px] text-muted-foreground">{i + 1}</span>
|
||||
<CategoryIcon icon={c?.icon} color={c?.color} size="sm" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="truncate text-[14.5px] font-medium">{m.merchant}</span>
|
||||
<span className="tabular shrink-0 text-[13.5px]">
|
||||
<span className="text-muted-foreground">{m.count}× · </span>
|
||||
{whole(m.spent)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1.5 h-1.5 overflow-hidden rounded-full bg-muted">
|
||||
<div className="h-full rounded-full bg-primary/70" style={{ width: `${Math.max(2, share * 100)}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</Card>
|
||||
) : list.data ? (
|
||||
<EmptyState icon={Sparkles} title="No spending yet" description={`Merchants from ${year} show up here.`} />
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Balances() {
|
||||
const accounts = useAccounts();
|
||||
const series = useQuery({ queryKey: ['reports', 'balances'], queryFn: () => api<BalanceSeries>('/reports/balances?months=12') });
|
||||
if (!series.data || !accounts.data) return <Skeleton className="h-72 rounded-2xl" />;
|
||||
const kindOf = new Map(accounts.data.map((a) => [a.id, a.kind]));
|
||||
const data = series.data.months.map((m, i) => {
|
||||
let cash = 0;
|
||||
let owed = 0;
|
||||
let any = false;
|
||||
for (const a of series.data.accounts) {
|
||||
const v = a.balances[i];
|
||||
if (v === null || v === undefined) continue;
|
||||
any = true;
|
||||
if (LIABILITY_KINDS.includes(kindOf.get(a.accountId)!)) owed += -v;
|
||||
else cash += v;
|
||||
}
|
||||
return { month: m, cash: any ? cash : null, owed: any ? owed : null };
|
||||
});
|
||||
const known = accounts.data.filter((a) => a.balanceConfirmedOn);
|
||||
const unknown = accounts.data.filter((a) => !a.balanceConfirmedOn && a.status === 'open');
|
||||
return (
|
||||
<div className="grid gap-5">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Cash and cards over 12 months</CardTitle>
|
||||
<CardDescription>Month-end balances: cash in checking and savings, and what’s owed on cards.</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="h-64 pl-1">
|
||||
{known.length ? (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={data} margin={{ left: 0, right: 8, top: 6, bottom: 0 }}>
|
||||
<CartesianGrid vertical={false} stroke="var(--border)" />
|
||||
<XAxis dataKey="month" tickFormatter={(m: string) => monthName(m, 'short')} tick={tick} axisLine={false} tickLine={false} />
|
||||
<YAxis tickFormatter={axisMoney} tick={tick} axisLine={false} tickLine={false} width={52} />
|
||||
<Tooltip content={<ChartTip labeler={(m) => `End of ${monthName(m, 'long', true)}`} />} />
|
||||
<Line type="monotone" dataKey="cash" name="Cash" stroke="var(--good)" strokeWidth={2.5} dot={false} />
|
||||
<Line type="monotone" dataKey="owed" name="Owed on cards" stroke="var(--bad)" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<EmptyChart>Add a balance to an account and its history appears here.</EmptyChart>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{known.map((a) => (
|
||||
<div key={a.id} className="flex items-center gap-3 px-5 py-3">
|
||||
<AccountDot color={a.color} />
|
||||
<span className="flex-1 truncate text-[14.5px] font-medium">{a.name}</span>
|
||||
<span className="tabular text-[14.5px] font-medium">
|
||||
{formatCents(LIABILITY_KINDS.includes(a.kind) ? -a.balanceCents : a.balanceCents)}
|
||||
{LIABILITY_KINDS.includes(a.kind) ? <span className="ml-1 text-[12px] font-normal text-muted-foreground">owed</span> : null}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{unknown.length ? (
|
||||
<Link to="/accounts" className="block px-5 py-3 text-[13px] text-muted-foreground hover:bg-muted/60">
|
||||
{unknown.map((a) => a.name).join(', ')} {unknown.length === 1 ? 'has' : 'have'} no balance yet. <span className="font-medium text-primary">Add one in Accounts</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tags({ year }: { year: number }) {
|
||||
const { byId } = useCategories();
|
||||
const [span, setSpan] = useState<'year' | 'all'>('all');
|
||||
const q = span === 'year' ? `?from=${year}-01-01&to=${year}-12-31` : '';
|
||||
const list = useQuery({ queryKey: ['reports', 'tags', span, year], queryFn: () => api<TagReport[]>(`/reports/tags${q}`) });
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Segmented
|
||||
label="Period"
|
||||
value={span}
|
||||
onChange={setSpan}
|
||||
options={[
|
||||
{ value: 'all', label: 'All time' },
|
||||
{ value: 'year', label: String(year) },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{list.data?.length ? (
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{list.data.map((t) => (
|
||||
<Card key={t.tag}>
|
||||
<CardHeader className="flex-row items-start justify-between">
|
||||
<div className="grid gap-1">
|
||||
<CardTitle>{t.tag}</CardTitle>
|
||||
<CardDescription>
|
||||
{t.count} transaction{t.count === 1 ? '' : 's'}
|
||||
{t.income ? ` · ${whole(t.income)} came back` : ''}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<p className="tabular text-[20px] font-semibold">{whole(t.spent)}</p>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-2">
|
||||
{t.byCategory.slice(0, 5).map((b) => {
|
||||
const c = b.categoryId ? byId.get(b.categoryId) : undefined;
|
||||
return (
|
||||
<div key={String(b.categoryId)} className="flex items-center gap-2.5 text-[13.5px]">
|
||||
<CategoryIcon icon={c?.icon} color={c?.color} size="sm" className="size-6 rounded-md [&_svg]:size-3.5" />
|
||||
<span className="flex-1 truncate">{c?.name ?? 'Not filed yet'}</span>
|
||||
<span className="tabular">{whole(b.cents)}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<Link to={`/activity?tag=${encodeURIComponent(t.tag)}`} className="mt-1 inline-flex items-center gap-1 text-[13px] font-medium text-primary">
|
||||
See transactions <ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
) : list.data ? (
|
||||
<EmptyState icon={Sparkles} title="No tags yet" description="Tag transactions like “Vacation 2026” or “Reimbursable” in Activity, and each tag’s total shows up here." />
|
||||
) : (
|
||||
<Skeleton className="h-48 rounded-2xl" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function InsightsPage() {
|
||||
const [params, setParams] = useSearchParams();
|
||||
const overview = useOverview();
|
||||
const view = (params.get('view') as View) || 'overview';
|
||||
const focusYear = overview.data ? Number(overview.data.month.slice(0, 4)) : new Date().getFullYear();
|
||||
const year = Number(params.get('year')) || focusYear;
|
||||
const report = useMonthsReport(`${year}-01`, `${year}-12`, !!overview.data);
|
||||
const set = (key: string, value: string) => setParams((p) => (p.set(key, value), p), { replace: true });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageIntro title="Insights" titleOnPhone={false} description="Where the money goes over time, in plain words first. Tap any number to see the transactions behind it." />
|
||||
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="-mx-4 flex gap-1.5 overflow-x-auto px-4 [scrollbar-width:none] sm:mx-0 sm:px-0">
|
||||
{VIEWS.map((v) => (
|
||||
<button
|
||||
key={v.value}
|
||||
onClick={() => set('view', v.value)}
|
||||
className={cn(
|
||||
'h-9 shrink-0 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
|
||||
view === v.value ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{view !== 'balances' ? <YearSwitcher year={year} onChange={(y) => set('year', String(y))} /> : null}
|
||||
</div>
|
||||
{view === 'balances' ? (
|
||||
<Balances />
|
||||
) : view === 'merchants' ? (
|
||||
<Merchants year={year} />
|
||||
) : view === 'tags' ? (
|
||||
<Tags year={year} />
|
||||
) : !report.data ? (
|
||||
<Skeleton className="h-80 rounded-2xl" />
|
||||
) : view === 'budget' ? (
|
||||
<BudgetVsActual report={report.data} year={year} />
|
||||
) : view === 'categories' ? (
|
||||
<Categories report={report.data} year={year} />
|
||||
) : (
|
||||
<Overview report={report.data} year={year} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import { ChevronRight, LogOut } from 'lucide-react';
|
||||
import { Link } from 'react-router';
|
||||
import { Wordmark, VersionTag } from '@/components/brand';
|
||||
import { NAV, SETTINGS_NAV } from '@/components/nav';
|
||||
import { useAlertCounts } from '@/lib/data';
|
||||
import { useConfig, useSignOut } from '@/lib/session';
|
||||
|
||||
/** The phone's "More" tab: everything that doesn't fit on the bottom bar. */
|
||||
export default function MorePage() {
|
||||
const signOut = useSignOut();
|
||||
const { data: config } = useConfig();
|
||||
const { data: alerts } = useAlertCounts();
|
||||
const items = [...NAV.filter((n) => !n.phone), SETTINGS_NAV];
|
||||
|
||||
return (
|
||||
@@ -19,6 +21,9 @@ export default function MorePage() {
|
||||
<Icon className="size-[18px]" strokeWidth={1.9} />
|
||||
</span>
|
||||
<span className="flex-1 text-[15px] font-medium">{label}</span>
|
||||
{to === '/subscriptions' && alerts?.subscriptions ? (
|
||||
<span className="tabular flex h-5 min-w-5 items-center justify-center rounded-full bg-primary px-1.5 text-[11px] font-semibold text-primary-foreground">{alerts.subscriptions}</span>
|
||||
) : null}
|
||||
<ChevronRight className="size-4 text-muted-foreground" />
|
||||
</Link>
|
||||
))}
|
||||
|
||||
673
web/src/pages/SubscriptionsPage.tsx
Normal file
673
web/src/pages/SubscriptionsPage.tsx
Normal file
@@ -0,0 +1,673 @@
|
||||
import {
|
||||
CADENCE_LABEL,
|
||||
CADENCE_SHORT,
|
||||
formatCents,
|
||||
monthName,
|
||||
SUBSCRIPTION_STATUS_LABEL,
|
||||
type Cadence,
|
||||
type Subscription,
|
||||
type SubscriptionDetail,
|
||||
type SubscriptionKind,
|
||||
type SubscriptionStatus,
|
||||
} from '@spendlight/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Bell, BellOff, CalendarClock, Check, ExternalLink, Merge, Plus, Repeat, Trash2, X } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { AlertCard } from '@/components/AlertCard';
|
||||
import { CategoryButton } from '@/components/CategoryPicker';
|
||||
import { AccountDot, CategoryIcon, formatDay } from '@/components/money';
|
||||
import { EmptyState, PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Field, Input, Select, Textarea } from '@/components/ui/form';
|
||||
import { Badge, Segmented, Skeleton, Switch } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useAccounts, useAlerts, useCategories, useSubscriptions, useUpcoming } from '@/lib/data';
|
||||
import { centsToInput, nextDue, parseDollars } from '@/lib/money';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const LIVE: SubscriptionStatus[] = ['active', 'trial'];
|
||||
const STATUS_TONE: Partial<Record<SubscriptionStatus, 'warn' | 'neutral' | 'bad' | 'primary'>> = { trial: 'warn', paused: 'neutral', cancelled: 'neutral', suggested: 'primary' };
|
||||
|
||||
function useRefresh() {
|
||||
const client = useQueryClient();
|
||||
return () => {
|
||||
for (const key of ['subscriptions', 'alerts', 'transactions', 'reports', 'review', 'review-count']) void client.invalidateQueries({ queryKey: [key] });
|
||||
};
|
||||
}
|
||||
|
||||
function hostOf(url: string): string {
|
||||
try {
|
||||
return new URL(url).hostname.replace(/^www\./, '');
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function Found({ items }: { items: Subscription[] }) {
|
||||
const refresh = useRefresh();
|
||||
const accounts = useAccounts();
|
||||
const me = useCurrentUser();
|
||||
const answer = useMutation({
|
||||
mutationFn: ({ id, status }: { id: number; status: 'active' | 'dismissed' }) => api(`/subscriptions/${id}`, { method: 'PATCH', body: { status } }),
|
||||
onSuccess: (_, v) => {
|
||||
refresh();
|
||||
if (v.status === 'dismissed') toast('Got it, not a subscription', { description: 'Spendlight won’t ask about it again.' });
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<Card className="mb-6 overflow-hidden border-primary/25">
|
||||
<CardHeader>
|
||||
<CardTitle>Found in your statements</CardTitle>
|
||||
<p className="text-[13.5px] text-muted-foreground">Is each of these a regular charge? Your answer is remembered.</p>
|
||||
</CardHeader>
|
||||
<ul className="divide-y border-t">
|
||||
{items.map((s) => {
|
||||
const account = accounts.data?.find((a) => a.id === s.accountId);
|
||||
return (
|
||||
<li key={s.id} className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:px-6">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[15px] font-semibold">
|
||||
{s.name} <span className="tabular font-medium text-muted-foreground">· {formatCents(s.expectedCents)} {CADENCE_LABEL[s.cadence].toLowerCase()}</span>
|
||||
</p>
|
||||
<p className="mt-0.5 flex items-center gap-1.5 text-[12.5px] text-muted-foreground">
|
||||
{account ? <AccountDot color={account.color} /> : null}
|
||||
{account?.name ?? 'Several accounts'} · {s.chargeCount} charge{s.chargeCount === 1 ? '' : 's'}
|
||||
{s.firstChargeOn ? ` since ${monthName(s.firstChargeOn.slice(0, 7), 'short', true)}` : ''}
|
||||
{s.kind === 'bill' ? ' · looks like a bill' : ''}
|
||||
</p>
|
||||
</div>
|
||||
{me.access === 'full' ? (
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={() => answer.mutate({ id: s.id, status: 'active' })}>
|
||||
<Check /> Yes{s.kind === 'bill' ? ', a bill' : ''}
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={() => answer.mutate({ id: s.id, status: 'dismissed' })}>
|
||||
<X /> No
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ s, onOpen }: { s: Subscription; onOpen: () => void }) {
|
||||
const accounts = useAccounts();
|
||||
const { byId } = useCategories();
|
||||
const account = accounts.data?.find((a) => a.id === s.accountId);
|
||||
const category = s.categoryId ? byId.get(s.categoryId) : undefined;
|
||||
const live = LIVE.includes(s.status);
|
||||
return (
|
||||
<button onClick={onOpen} className={cn('flex w-full items-center gap-3 px-4 py-3.5 text-left transition-colors hover:bg-muted/60 sm:px-5', !live && 'opacity-70')}>
|
||||
{category ? (
|
||||
<CategoryIcon icon={category.icon} color={category.color} />
|
||||
) : (
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-xl bg-muted text-[14px] font-semibold text-muted-foreground">{s.name[0]}</span>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-2 truncate text-[15px] font-medium">
|
||||
<span className="truncate">{s.name}</span>
|
||||
{s.status !== 'active' ? <Badge tone={STATUS_TONE[s.status] ?? 'neutral'}>{SUBSCRIPTION_STATUS_LABEL[s.status]}</Badge> : null}
|
||||
{!s.alertsOn ? <BellOff className="size-3.5 shrink-0 text-muted-foreground" aria-label="Alerts off" /> : null}
|
||||
</p>
|
||||
<p className="flex items-center gap-1.5 truncate text-[12.5px] text-muted-foreground">
|
||||
{account ? <AccountDot color={account.color} /> : null}
|
||||
<span className="truncate">
|
||||
{account?.name ?? 'No card yet'}
|
||||
<span className="hidden sm:inline"> · {CADENCE_LABEL[s.cadence]}</span>
|
||||
{s.status === 'active' && s.nextExpectedOn ? ` · next ${formatDay(nextDue(s.nextExpectedOn, s.cadence)!, true)}` : ''}
|
||||
{s.status === 'trial' && s.trialEndsOn ? ` · trial ends ${formatDay(s.trialEndsOn, true)}` : ''}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="tabular text-[15px] font-medium">
|
||||
{formatCents(s.expectedCents)}
|
||||
<span className="text-[12px] font-normal text-muted-foreground">{CADENCE_SHORT[s.cadence]}</span>
|
||||
</p>
|
||||
{s.cadence !== 'monthly' ? <p className="tabular text-[11.5px] text-muted-foreground">{formatCents(s.monthlyCents)}/mo</p> : null}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SubscriptionForm({ initial, onDone }: { initial?: SubscriptionDetail; onDone: (s: Subscription) => void }) {
|
||||
const accounts = useAccounts();
|
||||
const [name, setName] = useState(initial?.name ?? '');
|
||||
const [amount, setAmount] = useState(initial ? centsToInput(initial.expectedCents) : '');
|
||||
const [cadence, setCadence] = useState<Cadence>(initial?.cadence ?? 'monthly');
|
||||
const [kind, setKind] = useState<SubscriptionKind>(initial?.kind ?? 'subscription');
|
||||
const [accountId, setAccountId] = useState<number | ''>(initial?.accountId ?? '');
|
||||
const [next, setNext] = useState(initial?.nextExpectedOn ?? '');
|
||||
const [categoryId, setCategoryId] = useState<number | null>(initial?.categoryId ?? null);
|
||||
const [manageUrl, setManageUrl] = useState(initial?.manageUrl ?? '');
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const cents = parseDollars(amount);
|
||||
if (!cents) throw new Error('Please enter the amount, like 15.99');
|
||||
const body = {
|
||||
name,
|
||||
kind,
|
||||
cadence,
|
||||
expectedCents: cents,
|
||||
accountId: accountId || null,
|
||||
nextExpectedOn: next || null,
|
||||
manageUrl: manageUrl.trim() ? (/^https?:\/\//i.test(manageUrl.trim()) ? manageUrl.trim() : `https://${manageUrl.trim()}`) : null,
|
||||
...(categoryId !== (initial?.categoryId ?? null) ? { categoryId } : {}),
|
||||
};
|
||||
return initial ? api<Subscription>(`/subscriptions/${initial.id}`, { method: 'PATCH', body }) : api<Subscription>('/subscriptions', { method: 'POST', body });
|
||||
},
|
||||
onSuccess: onDone,
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
<Segmented
|
||||
label="Kind"
|
||||
value={kind}
|
||||
onChange={setKind}
|
||||
options={[
|
||||
{ value: 'subscription', label: 'Subscription' },
|
||||
{ value: 'bill', label: 'Bill' },
|
||||
]}
|
||||
/>
|
||||
<Field label="Name">{(id) => <Input id={id} value={name} onChange={(e) => setName(e.target.value)} required maxLength={80} placeholder="Netflix" autoFocus={!initial} />}</Field>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Amount">{(id) => <Input id={id} value={amount} onChange={(e) => setAmount(e.target.value)} inputMode="decimal" placeholder="15.99" required />}</Field>
|
||||
<Field label="How often">
|
||||
{(id) => (
|
||||
<Select id={id} value={cadence} onChange={(e) => setCadence(e.target.value as Cadence)}>
|
||||
{(Object.keys(CADENCE_LABEL) as Cadence[]).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{CADENCE_LABEL[c]}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Card or account">
|
||||
{(id) => (
|
||||
<Select id={id} value={accountId} onChange={(e) => setAccountId(e.target.value ? Number(e.target.value) : '')}>
|
||||
<option value="">Not sure</option>
|
||||
{accounts.data
|
||||
?.filter((a) => a.status === 'open')
|
||||
.map((a) => (
|
||||
<option key={a.id} value={a.id}>
|
||||
{a.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Next charge">{(id) => <Input id={id} type="date" value={next} onChange={(e) => setNext(e.target.value)} />}</Field>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-[13px] font-medium">Category</span>
|
||||
<CategoryButton categoryId={categoryId} onPick={(c) => setCategoryId(c.id)} noTransfers />
|
||||
{initial ? <p className="text-[12.5px] text-muted-foreground">Changing it also files every charge from this subscription there.</p> : null}
|
||||
</div>
|
||||
<Field label="Where to manage or cancel it" hint="The service’s account page, so it’s one tap away.">
|
||||
{(id) => <Input id={id} value={manageUrl} onChange={(e) => setManageUrl(e.target.value)} placeholder="https://www.netflix.com/account" inputMode="url" />}
|
||||
</Field>
|
||||
<Button type="submit" loading={save.isPending}>
|
||||
{initial ? 'Save' : 'Add'}
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailSheet({ id, onClose, all }: { id: number | null; onClose: () => void; all: Subscription[] }) {
|
||||
const me = useCurrentUser();
|
||||
const canEdit = me.access === 'full';
|
||||
const refresh = useRefresh();
|
||||
const accounts = useAccounts();
|
||||
const { byId } = useCategories();
|
||||
const detail = useQuery({ queryKey: ['subscriptions', 'detail', id], queryFn: () => api<SubscriptionDetail>(`/subscriptions/${id}`), enabled: id !== null });
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
const [trialEnd, setTrialEnd] = useState('');
|
||||
const [note, setNote] = useState<string | null>(null);
|
||||
const s = detail.data;
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setMerging(false);
|
||||
setNote(null);
|
||||
}, [id]);
|
||||
|
||||
const patch = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api<Subscription>(`/subscriptions/${id}`, { method: 'PATCH', body }),
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const merge = useMutation({
|
||||
mutationFn: (intoId: number) => api<Subscription>(`/subscriptions/${id}/merge`, { method: 'POST', body: { intoId } }),
|
||||
onSuccess: (into) => {
|
||||
refresh();
|
||||
toast.success(`Merged into ${into.name}`);
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: () => api(`/subscriptions/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
onClose();
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
|
||||
const setStatus = (status: SubscriptionStatus) => {
|
||||
if (status === 'trial') {
|
||||
if (!trialEnd) return toast.error('Choose the date the free trial ends');
|
||||
patch.mutate({ status, trialEndsOn: trialEnd });
|
||||
} else patch.mutate({ status });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={id !== null} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent title={s?.name ?? 'Subscription'} description={s ? `${s.kind === 'bill' ? 'Bill' : 'Subscription'} · ${CADENCE_LABEL[s.cadence]}` : undefined} className="sm:max-w-lg">
|
||||
{!s ? (
|
||||
<Skeleton className="h-64" />
|
||||
) : editing ? (
|
||||
<SubscriptionForm
|
||||
initial={s}
|
||||
onDone={() => {
|
||||
refresh();
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-5">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="rounded-xl bg-muted/60 p-3.5">
|
||||
<p className="text-[12px] font-medium text-muted-foreground">{s.kind === 'bill' ? 'Usually' : 'Costs'}</p>
|
||||
<p className="tabular text-[20px] font-semibold">
|
||||
{formatCents(s.expectedCents)}
|
||||
<span className="text-[13px] font-normal text-muted-foreground">{CADENCE_SHORT[s.cadence]}</span>
|
||||
</p>
|
||||
<p className="tabular text-[12px] text-muted-foreground">{formatCents(s.monthlyCents * 12)} a year</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-muted/60 p-3.5">
|
||||
<p className="text-[12px] font-medium text-muted-foreground">Next charge</p>
|
||||
<p className="text-[16px] font-semibold">{s.nextExpectedOn && s.status === 'active' ? formatDay(nextDue(s.nextExpectedOn, s.cadence)!) : s.status === 'trial' && s.trialEndsOn ? `${formatDay(s.trialEndsOn)} (trial ends)` : '—'}</p>
|
||||
<p className="flex items-center gap-1.5 truncate text-[12px] text-muted-foreground">
|
||||
{(() => {
|
||||
const a = accounts.data?.find((x) => x.id === s.accountId);
|
||||
return a ? (
|
||||
<>
|
||||
<AccountDot color={a.color} /> {a.name}
|
||||
</>
|
||||
) : (
|
||||
'Card not known yet'
|
||||
);
|
||||
})()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{s.manageUrl ? (
|
||||
<Button variant="outline" asChild>
|
||||
<a href={s.manageUrl} target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink /> Manage or cancel at {hostOf(s.manageUrl)}
|
||||
</a>
|
||||
</Button>
|
||||
) : null}
|
||||
|
||||
{canEdit ? (
|
||||
<div className="grid gap-2">
|
||||
<span className="text-[13px] font-medium">Status</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{(['active', 'trial', 'paused', 'cancelled'] as SubscriptionStatus[]).map((st) => (
|
||||
<button
|
||||
key={st}
|
||||
onClick={() => setStatus(st)}
|
||||
className={cn(
|
||||
'h-9 rounded-full border px-3.5 text-[13px] font-medium transition-colors',
|
||||
s.status === st ? 'border-transparent bg-foreground text-background' : 'bg-card text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{SUBSCRIPTION_STATUS_LABEL[st]}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{s.status !== 'trial' ? (
|
||||
<div className="flex items-center gap-2 text-[12.5px] text-muted-foreground">
|
||||
For a free trial, choose when it ends:
|
||||
<Input type="date" value={trialEnd} onChange={(e) => setTrialEnd(e.target.value)} className="h-9 w-40 sm:h-9" aria-label="Trial ends" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[12.5px] text-muted-foreground">Trial ends {s.trialEndsOn ? formatDay(s.trialEndsOn) : 'soon'}; you’ll get a heads-up 3 days before.</p>
|
||||
)}
|
||||
{s.status === 'cancelled' && s.cancelledOn ? (
|
||||
<p className="text-[12.5px] text-muted-foreground">Marked cancelled on {formatDay(s.cancelledOn)}. Spendlight will speak up if it charges again.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{s.priceHistory.length > 1 ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-[13px] font-medium">Price history</p>
|
||||
<p className="text-[14px]">
|
||||
{s.priceHistory.map((p, i) => (
|
||||
<span key={p.from}>
|
||||
{i === 0 ? '' : ', then '}
|
||||
<span className="tabular font-medium">{formatCents(p.amountCents)}</span>
|
||||
{i === 0 ? '' : ` from ${monthName(p.from.slice(0, 7))}`}
|
||||
</span>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<p className="text-[13px] font-medium">Charges</p>
|
||||
{s.chargeCount ? (
|
||||
<Link to={`/activity?subscriptionId=${s.id}`} className="text-[12.5px] font-medium text-primary">
|
||||
See in Activity
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{s.charges.length ? (
|
||||
<ul className="max-h-48 divide-y overflow-y-auto rounded-xl border">
|
||||
{s.charges.slice(0, 24).map((c) => {
|
||||
const a = accounts.data?.find((x) => x.id === c.accountId);
|
||||
return (
|
||||
<li key={c.id} className="flex items-center gap-3 px-3.5 py-2 text-[13.5px]">
|
||||
<span className="w-24 text-muted-foreground">{formatDay(c.date, true)}</span>
|
||||
<span className="flex min-w-0 flex-1 items-center gap-1.5 truncate text-muted-foreground">
|
||||
{a ? <AccountDot color={a.color} /> : null}
|
||||
{a?.name}
|
||||
</span>
|
||||
<span className="tabular font-medium">{formatCents(-c.amountCents)}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-[13px] text-muted-foreground">No charges yet. They’ll attach themselves as statements come in.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Field label="Note">
|
||||
{(fid) => (
|
||||
<Textarea
|
||||
id={fid}
|
||||
className="min-h-20"
|
||||
value={note ?? s.note ?? ''}
|
||||
onChange={(e) => setNote(e.target.value)}
|
||||
onBlur={() => note !== null && note !== (s.note ?? '') && patch.mutate({ note: note || null })}
|
||||
placeholder="Shared with the family plan, renews with the phone bill…"
|
||||
maxLength={500}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
<label className="flex items-center justify-between gap-3 rounded-xl border px-4 py-3">
|
||||
<span className="flex items-center gap-2.5 text-[14px]">
|
||||
<Bell className="size-4 text-muted-foreground" /> Alerts for this {s.kind}
|
||||
</span>
|
||||
<Switch checked={s.alertsOn} onCheckedChange={(alertsOn) => patch.mutate({ alertsOn })} />
|
||||
</label>
|
||||
{merging ? (
|
||||
<Field label="Merge into" hint="For two entries that are really one service. Charges and names come along.">
|
||||
{(fid) => (
|
||||
<Select id={fid} defaultValue="" onChange={(e) => e.target.value && merge.mutate(Number(e.target.value))}>
|
||||
<option value="">Choose…</option>
|
||||
{all
|
||||
.filter((o) => o.id !== s.id)
|
||||
.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name} · {formatCents(o.expectedCents)}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
|
||||
Edit details
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={() => setMerging((m) => !m)}>
|
||||
<Merge /> Merge
|
||||
</Button>
|
||||
{s.source === 'manual' ? (
|
||||
<Button variant="ghost" size="sm" className="text-bad" onClick={() => setConfirmDelete(true)}>
|
||||
<Trash2 /> Remove
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
patch.mutate({ status: 'dismissed' });
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Not a subscription
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{s.note && !canEdit ? <p className="text-[14px] text-muted-foreground">{s.note}</p> : null}
|
||||
{s.categoryId && byId.get(s.categoryId) ? <p className="text-[12.5px] text-muted-foreground">Filed under {byId.get(s.categoryId)!.name}.</p> : null}
|
||||
</div>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={confirmDelete}
|
||||
onOpenChange={setConfirmDelete}
|
||||
title={`Remove ${s?.name}?`}
|
||||
description="It disappears from Subscriptions. Its charges stay in Activity."
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => remove.mutate()}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubscriptionsPage() {
|
||||
const me = useCurrentUser();
|
||||
const client = useQueryClient();
|
||||
const list = useSubscriptions();
|
||||
const upcoming = useUpcoming(30);
|
||||
const alerts = useAlerts('subscriptions');
|
||||
const accounts = useAccounts();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const [tab, setTab] = useState<SubscriptionKind>((params.get('tab') as SubscriptionKind) || 'subscription');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const openId = params.get('open') ? Number(params.get('open')) : null;
|
||||
const setOpen = (id: number | null) =>
|
||||
setParams(
|
||||
(p) => {
|
||||
if (id === null) p.delete('open');
|
||||
else p.set('open', String(id));
|
||||
return p;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
|
||||
// Opening this screen counts as having seen its alerts, which clears the menu badge.
|
||||
const read = useMutation({ mutationFn: () => api('/alerts/read', { method: 'POST', body: { area: 'subscriptions' } }), onSuccess: () => client.invalidateQueries({ queryKey: ['alerts', 'counts'] }) });
|
||||
const readMutate = read.mutate;
|
||||
useEffect(() => {
|
||||
if (alerts.data?.some((a) => !a.read)) readMutate();
|
||||
}, [alerts.data, readMutate]);
|
||||
|
||||
const all = list.data ?? [];
|
||||
const found = all.filter((s) => s.status === 'suggested');
|
||||
const mine = all.filter((s) => s.status !== 'suggested' && s.kind === tab);
|
||||
const live = mine.filter((s) => LIVE.includes(s.status));
|
||||
const stopped = mine.filter((s) => !LIVE.includes(s.status));
|
||||
const monthly = live.filter((s) => s.status === 'active').reduce((sum, s) => sum + s.monthlyCents, 0);
|
||||
const counts = useMemo(
|
||||
() => ({ subscription: all.filter((s) => s.status !== 'suggested' && s.kind === 'subscription').length, bill: all.filter((s) => s.status !== 'suggested' && s.kind === 'bill').length }),
|
||||
[all],
|
||||
);
|
||||
const shownAlerts = alerts.data?.filter((a) => a.kind !== 'sub_found') ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageIntro
|
||||
title="Subscriptions"
|
||||
titleOnPhone={false}
|
||||
description="Everything that charges you on a schedule, across every card and account. Spendlight finds them; you confirm, and it keeps watch."
|
||||
actions={
|
||||
me.access === 'full' ? (
|
||||
<Button variant="outline" onClick={() => setAdding(true)}>
|
||||
<Plus /> Add by hand
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
{shownAlerts.length ? (
|
||||
<div className="mb-6 grid gap-2.5 lg:grid-cols-2">
|
||||
{shownAlerts.map((a) => (
|
||||
<AlertCard key={a.id} alert={a} compact />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<Found items={found} />
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<Segmented
|
||||
label="Subscriptions or bills"
|
||||
value={tab}
|
||||
onChange={(v) => {
|
||||
setTab(v);
|
||||
setParams((p) => (p.set('tab', v), p), { replace: true });
|
||||
}}
|
||||
options={[
|
||||
{ value: 'subscription', label: `Subscriptions${counts.subscription ? ` · ${counts.subscription}` : ''}` },
|
||||
{ value: 'bill', label: `Bills${counts.bill ? ` · ${counts.bill}` : ''}` },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mb-5 grid grid-cols-3 gap-2.5 sm:gap-4">
|
||||
{[
|
||||
{ label: 'Each month', value: formatCents(monthly, { whole: monthly >= 100_000 }) },
|
||||
{ label: 'Each year', value: formatCents(monthly * 12, { whole: true }) },
|
||||
{ label: 'Active', value: String(live.filter((s) => s.status === 'active').length) },
|
||||
].map((x) => (
|
||||
<Card key={x.label} className="p-3.5 sm:p-5">
|
||||
<p className="text-[11.5px] font-medium text-muted-foreground sm:text-[12.5px]">{x.label}</p>
|
||||
<p className="tabular mt-1 truncate text-[18px] font-semibold tracking-tight sm:text-[24px]">{x.value}</p>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[1.4fr_1fr]">
|
||||
<div className="grid content-start gap-5">
|
||||
{list.isPending ? <Skeleton className="h-64 rounded-2xl" /> : null}
|
||||
{list.data && !live.length && !stopped.length ? (
|
||||
<EmptyState
|
||||
icon={Repeat}
|
||||
title={tab === 'bill' ? 'No bills yet' : 'No subscriptions yet'}
|
||||
description={
|
||||
found.length
|
||||
? 'Answer the questions above and they’ll appear here.'
|
||||
: tab === 'bill'
|
||||
? 'Bills such as electric, insurance and the mortgage appear after two months of statements.'
|
||||
: 'Import a few months of statements and Spendlight finds them. Known services show up from their first charge.'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{live.length ? (
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{live.map((s) => (
|
||||
<Row key={s.id} s={s} onOpen={() => setOpen(s.id)} />
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
{stopped.length ? (
|
||||
<section>
|
||||
<h3 className="mb-1.5 px-1 text-[13px] font-semibold text-muted-foreground">Paused or cancelled</h3>
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{stopped.map((s) => (
|
||||
<Row key={s.id} s={s} onOpen={() => setOpen(s.id)} />
|
||||
))}
|
||||
</Card>
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
<Card className="self-start">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<CalendarClock className="size-4 text-muted-foreground" /> Coming up in the next 30 days
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{upcoming.data?.length ? (
|
||||
<ul className="grid gap-3">
|
||||
{upcoming.data.map((u) => {
|
||||
const a = accounts.data?.find((x) => x.id === u.accountId);
|
||||
return (
|
||||
<li key={`${u.subscriptionId}-${u.date}`}>
|
||||
<button onClick={() => setOpen(u.subscriptionId)} className="flex w-full items-center gap-3 text-left text-[14px]">
|
||||
<span className="w-16 shrink-0 text-[12.5px] font-medium text-muted-foreground">{formatDay(u.date, true)}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{u.name}
|
||||
{u.trial ? <Badge tone="warn" className="ml-2">Trial ends</Badge> : null}
|
||||
</span>
|
||||
{a ? <AccountDot color={a.color} /> : null}
|
||||
<span className="tabular shrink-0">{formatCents(u.amountCents)}</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<li className="flex justify-between border-t pt-3 text-[13.5px] font-medium">
|
||||
<span>Total</span>
|
||||
<span className="tabular">{formatCents(upcoming.data.reduce((s, u) => s + u.amountCents, 0))}</span>
|
||||
</li>
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-[13.5px] text-muted-foreground">Nothing expected in the next 30 days.</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<DetailSheet id={openId} onClose={() => setOpen(null)} all={all.filter((s) => s.status !== 'suggested')} />
|
||||
<Dialog open={adding} onOpenChange={setAdding}>
|
||||
<DialogContent title="Add a subscription" description="Handy for yearly plans that haven’t charged yet. Charges attach themselves when they arrive.">
|
||||
<SubscriptionForm
|
||||
onDone={() => {
|
||||
for (const key of ['subscriptions', 'alerts']) void client.invalidateQueries({ queryKey: [key] });
|
||||
setAdding(false);
|
||||
toast.success('Added');
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { ChartColumnBig, Repeat, Target, type LucideIcon } from 'lucide-react';
|
||||
import { EmptyState } from '@/components/page';
|
||||
|
||||
// Screens that arrive in later milestones, each explaining what it will do.
|
||||
|
||||
interface Upcoming {
|
||||
icon: LucideIcon;
|
||||
title: string;
|
||||
description: string;
|
||||
milestone: string;
|
||||
}
|
||||
|
||||
const SCREENS: Record<string, Upcoming> = {
|
||||
budget: {
|
||||
icon: Target,
|
||||
title: 'Monthly budgets',
|
||||
description:
|
||||
'Set a target for each category, copy last month in one tap, and see over or under for the month and the year at a glance.',
|
||||
milestone: 'Milestone 2 · The full picture',
|
||||
},
|
||||
subscriptions: {
|
||||
icon: Repeat,
|
||||
title: 'Every subscription in one place',
|
||||
description:
|
||||
'Spendlight spots recurring charges across all your cards, totals them by month and year, and speaks up when a price changes or a cancelled service charges again.',
|
||||
milestone: 'Milestone 2 · The full picture',
|
||||
},
|
||||
insights: {
|
||||
icon: ChartColumnBig,
|
||||
title: 'Where the money goes',
|
||||
description:
|
||||
'Month-by-month spending by category, budget against actual, year pacing, and money in against money out, each in plain words first.',
|
||||
milestone: 'Milestone 2 · The full picture',
|
||||
},
|
||||
};
|
||||
|
||||
export default function UpcomingPage({ screen }: { screen: keyof typeof SCREENS }) {
|
||||
const s = SCREENS[screen]!;
|
||||
return <EmptyState icon={s.icon} title={s.title} description={s.description} milestone={s.milestone} />;
|
||||
}
|
||||
58
web/src/pages/settings/AiPage.tsx
Normal file
58
web/src/pages/settings/AiPage.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
import type { AiStatus } from '@spendlight/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ShieldCheck, Sparkles } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { PageIntro } from '@/components/page';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge, Skeleton, Switch } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
|
||||
export default function AiPage() {
|
||||
const client = useQueryClient();
|
||||
const status = useQuery({ queryKey: ['ai'], queryFn: () => api<AiStatus>('/ai') });
|
||||
const toggle = useMutation({
|
||||
mutationFn: (body: { feature: string; enabled: boolean }) => api<AiStatus>('/ai', { method: 'PATCH', body }),
|
||||
onSuccess: (next) => client.setQueryData(['ai'], next),
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const s = status.data;
|
||||
return (
|
||||
<div>
|
||||
<PageIntro title="AI features" description="Built into Spendlight’s design and switched off. Nothing here runs, and nothing is sent anywhere, until a provider is set up on the server and an admin turns a feature on." />
|
||||
{!s ? (
|
||||
<Skeleton className="h-64 rounded-2xl" />
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-5 flex items-start gap-3 rounded-2xl border bg-card px-5 py-4 shadow-soft">
|
||||
<ShieldCheck className="mt-0.5 size-5 shrink-0 text-good" />
|
||||
<p className="text-[14px] leading-relaxed">
|
||||
{s.providerConfigured ? (
|
||||
<>
|
||||
Provider set up on this server: <span className="font-medium">{s.provider}</span>. Each feature below sends only what it says, and only when it’s on.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="font-medium">No AI provider is set up on this server,</span> so Spendlight makes no outside calls at all. Your financial data stays on this machine.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{s.features.map((f) => (
|
||||
<label key={f.key} className="flex items-start gap-4 px-5 py-4">
|
||||
<Sparkles className="mt-0.5 size-5 shrink-0 text-muted-foreground" />
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="flex items-center gap-2 text-[15px] font-medium">
|
||||
{f.label} {!s.providerConfigured ? <Badge>Off</Badge> : null}
|
||||
</span>
|
||||
<span className="mt-0.5 block text-[13px] leading-relaxed text-muted-foreground">{f.description}</span>
|
||||
</span>
|
||||
<Switch checked={f.enabled} disabled={!s.providerConfigured || toggle.isPending} onCheckedChange={(enabled) => toggle.mutate({ feature: f.key, enabled })} aria-label={f.label} />
|
||||
</label>
|
||||
))}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
85
web/src/pages/settings/AlertsPage.tsx
Normal file
85
web/src/pages/settings/AlertsPage.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { AlertSettings } from '@spendlight/shared';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState, type ChangeEvent, type FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardFooter } from '@/components/ui/card';
|
||||
import { Field, Input } from '@/components/ui/form';
|
||||
import { Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { centsToInput, parseDollars } from '@/lib/money';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
|
||||
export default function AlertsPage() {
|
||||
const me = useCurrentUser();
|
||||
const client = useQueryClient();
|
||||
const current = useQuery({ queryKey: ['settings', 'alerts'], queryFn: () => api<AlertSettings>('/settings/alerts') });
|
||||
const [form, setForm] = useState({ pct: '', min: '', warn: '', unusual: '' });
|
||||
useEffect(() => {
|
||||
if (current.data) {
|
||||
setForm({ pct: String(current.data.priceChangePct), min: centsToInput(current.data.priceChangeMinCents), warn: String(current.data.budgetWarnPct), unusual: String(current.data.unusualMultiple) });
|
||||
}
|
||||
}, [current.data]);
|
||||
const save = useMutation({
|
||||
mutationFn: () => {
|
||||
const min = parseDollars(form.min);
|
||||
if (min === null) throw new Error('Enter the price change in dollars, like 2');
|
||||
return api<AlertSettings>('/settings/alerts', {
|
||||
method: 'PUT',
|
||||
body: { priceChangePct: Number(form.pct), priceChangeMinCents: min, budgetWarnPct: Number(form.warn), unusualMultiple: Number(form.unusual) },
|
||||
});
|
||||
},
|
||||
onSuccess: () => {
|
||||
void client.invalidateQueries({ queryKey: ['settings', 'alerts'] });
|
||||
void client.invalidateQueries({ queryKey: ['alerts'] });
|
||||
toast.success('Saved');
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const canEdit = me.access === 'full';
|
||||
const set = (key: keyof typeof form) => (e: ChangeEvent<HTMLInputElement>) => setForm({ ...form, [key]: e.target.value });
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageIntro title="Alerts" description="When Spendlight speaks up. Alerts appear on Home, in Insights, and as a badge on Subscriptions; nothing leaves the app." />
|
||||
{!current.data ? (
|
||||
<Skeleton className="h-72 rounded-2xl" />
|
||||
) : (
|
||||
<Card>
|
||||
<form
|
||||
onSubmit={(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
<CardContent className="grid gap-5 pt-6 sm:pt-6">
|
||||
<div>
|
||||
<p className="mb-2 text-[14px] font-semibold">Subscription price changes</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="More than this percent">{(id) => <Input id={id} value={form.pct} onChange={set('pct')} inputMode="numeric" disabled={!canEdit} />}</Field>
|
||||
<Field label="And more than this many dollars">{(id) => <Input id={id} value={form.min} onChange={set('min')} inputMode="decimal" disabled={!canEdit} />}</Field>
|
||||
</div>
|
||||
<p className="mt-1.5 text-[12.5px] text-muted-foreground">With 10% and $2, Netflix going from $15.49 to $17.99 raises an alert; a few cents of tax doesn’t.</p>
|
||||
</div>
|
||||
<Field label="Heads-up when a category reaches this share of its budget (%)" hint="While the month is still going.">
|
||||
{(id) => <Input id={id} value={form.warn} onChange={set('warn')} inputMode="numeric" disabled={!canEdit} className="w-28" />}
|
||||
</Field>
|
||||
<Field label="A purchase is unusual at this many times the category’s typical purchase" hint="Only purchases over $100 are checked.">
|
||||
{(id) => <Input id={id} value={form.unusual} onChange={set('unusual')} inputMode="numeric" disabled={!canEdit} className="w-28" />}
|
||||
</Field>
|
||||
<p className="text-[12.5px] text-muted-foreground">Any single subscription’s alerts can be switched off from its page in Subscriptions.</p>
|
||||
</CardContent>
|
||||
{canEdit ? (
|
||||
<CardFooter className="justify-end">
|
||||
<Button type="submit" loading={save.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</CardFooter>
|
||||
) : null}
|
||||
</form>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
333
web/src/pages/settings/CategoriesPage.tsx
Normal file
333
web/src/pages/settings/CategoriesPage.tsx
Normal file
@@ -0,0 +1,333 @@
|
||||
import { PALETTE, type Category, type CategoryGroup, type CategoryKind, type PaletteColor } from '@spendlight/shared';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Archive, ArrowDown, ArrowUp, FolderPlus, Merge, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { CategoryIcon, ICON_NAMES, PALETTE_CLASS } from '@/components/money';
|
||||
import { PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ConfirmDialog, Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Field, Input, Select } from '@/components/ui/form';
|
||||
import { Badge, Skeleton, Switch } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { MONEY_KEYS, useCategories } from '@/lib/data';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const KIND_LABEL: Record<CategoryKind, string> = { expense: 'Money out', income: 'Money in', transfer: 'Transfer' };
|
||||
|
||||
function useRefresh() {
|
||||
const client = useQueryClient();
|
||||
return () => {
|
||||
void client.invalidateQueries({ queryKey: ['categories'] });
|
||||
MONEY_KEYS.forEach((k) => void client.invalidateQueries({ queryKey: [...k] }));
|
||||
};
|
||||
}
|
||||
|
||||
function CategoryForm({ category, group, groups, onDone }: { category?: Category; group: CategoryGroup; groups: CategoryGroup[]; onDone: () => void }) {
|
||||
const refresh = useRefresh();
|
||||
const [name, setName] = useState(category?.name ?? '');
|
||||
const [icon, setIcon] = useState(category?.icon ?? 'circle');
|
||||
const [color, setColor] = useState<PaletteColor>(category?.color ?? 'indigo');
|
||||
const [groupId, setGroupId] = useState(category?.groupId ?? group.id);
|
||||
const [kind, setKind] = useState<CategoryKind>(category?.kind ?? group.categories[0]?.kind ?? 'expense');
|
||||
const [archived, setArchived] = useState(category?.archived ?? false);
|
||||
const [merging, setMerging] = useState(false);
|
||||
const [into, setInto] = useState<number | ''>('');
|
||||
const save = useMutation({
|
||||
mutationFn: () =>
|
||||
category
|
||||
? api(`/categories/${category.id}`, { method: 'PATCH', body: { name, icon, color, groupId, kind, archived } })
|
||||
: api('/categories', { method: 'POST', body: { name, icon, color, groupId, kind } }),
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
toast.success(category ? 'Saved' : 'Category added');
|
||||
onDone();
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const merge = useMutation({
|
||||
mutationFn: () => api<{ moved: number }>(`/categories/${category!.id}/merge`, { method: 'POST', body: { intoId: into } }),
|
||||
onSuccess: ({ moved }) => {
|
||||
refresh();
|
||||
toast.success(`Merged ${category!.name}`, { description: `${moved} transaction${moved === 1 ? '' : 's'}, its budgets and rules moved across.` });
|
||||
onDone();
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const sameKind = groups.flatMap((g) => g.categories).filter((c) => c.kind === (category?.kind ?? kind) && c.id !== category?.id && !c.archived);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
save.mutate();
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<CategoryIcon icon={icon} color={color} size="lg" />
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} placeholder="Category name" required maxLength={60} aria-label="Name" autoFocus={!category} />
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-[13px] font-medium">Color</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PALETTE.map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setColor(p)}
|
||||
className={cn('size-8 rounded-full ring-offset-2 ring-offset-popover transition', PALETTE_CLASS[p].dot, color === p && 'ring-2 ring-foreground')}
|
||||
aria-label={p}
|
||||
aria-pressed={color === p}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-[13px] font-medium">Icon</span>
|
||||
<div className="grid max-h-40 grid-cols-8 gap-1.5 overflow-y-auto rounded-xl border p-2 sm:grid-cols-10">
|
||||
{ICON_NAMES.map((n) => (
|
||||
<button key={n} type="button" onClick={() => setIcon(n)} className={cn('flex items-center justify-center rounded-lg p-0.5', icon === n && 'ring-2 ring-primary')} aria-label={n} aria-pressed={icon === n}>
|
||||
<CategoryIcon icon={n} color={color} size="sm" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<Field label="Group">
|
||||
{(id) => (
|
||||
<Select id={id} value={groupId} onChange={(e) => setGroupId(Number(e.target.value))}>
|
||||
{groups.map((g) => (
|
||||
<option key={g.id} value={g.id}>
|
||||
{g.name}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Field label="Counts as">
|
||||
{(id) => (
|
||||
<Select id={id} value={kind} onChange={(e) => setKind(e.target.value as CategoryKind)}>
|
||||
<option value="expense">Money out</option>
|
||||
<option value="income">Money in</option>
|
||||
<option value="transfer">Transfer (not counted)</option>
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
{category ? (
|
||||
<label className="flex items-center justify-between gap-3 rounded-xl border px-4 py-3">
|
||||
<span className="grid">
|
||||
<span className="flex items-center gap-2 text-[14px] font-medium">
|
||||
<Archive className="size-4 text-muted-foreground" /> Archived
|
||||
</span>
|
||||
<span className="text-[12.5px] text-muted-foreground">Hidden from pickers; its history stays in every report.</span>
|
||||
</span>
|
||||
<Switch checked={archived} onCheckedChange={setArchived} />
|
||||
</label>
|
||||
) : null}
|
||||
{category && merging ? (
|
||||
<div className="grid gap-2 rounded-xl border border-bad/30 bg-bad/5 p-3.5">
|
||||
<Field label={`Merge ${category.name} into`} hint="Its transactions, split parts, budgets, rules and subscriptions move there, then it’s deleted.">
|
||||
{(id) => (
|
||||
<Select id={id} value={into} onChange={(e) => setInto(e.target.value ? Number(e.target.value) : '')}>
|
||||
<option value="">Choose a category…</option>
|
||||
{sameKind.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.name} · {c.groupName}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
</Field>
|
||||
<Button type="button" variant="destructive" disabled={!into} loading={merge.isPending} onClick={() => merge.mutate()}>
|
||||
<Merge /> Merge and delete
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-between">
|
||||
{category ? (
|
||||
<Button type="button" variant="ghost" className="text-bad" onClick={() => setMerging((m) => !m)}>
|
||||
<Trash2 /> Delete…
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button type="submit" loading={save.isPending}>
|
||||
{category ? 'Save' : 'Add category'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CategoriesPage() {
|
||||
const me = useCurrentUser();
|
||||
const canEdit = me.access === 'full';
|
||||
const refresh = useRefresh();
|
||||
const { data: groups } = useCategories();
|
||||
const [editing, setEditing] = useState<{ category?: Category; group: CategoryGroup } | null>(null);
|
||||
const [groupEdit, setGroupEdit] = useState<{ group?: CategoryGroup; name: string } | null>(null);
|
||||
const [deleteGroup, setDeleteGroup] = useState<CategoryGroup | null>(null);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const reorder = useMutation({
|
||||
mutationFn: (next: CategoryGroup[]) => api('/categories/reorder', { method: 'POST', body: { groups: next.map((g) => ({ id: g.id, categoryIds: g.categories.map((c) => c.id) })) } }),
|
||||
onSuccess: refresh,
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const saveGroup = useMutation({
|
||||
mutationFn: () => (groupEdit?.group ? api(`/category-groups/${groupEdit.group.id}`, { method: 'PATCH', body: { name: groupEdit.name } }) : api('/category-groups', { method: 'POST', body: { name: groupEdit!.name } })),
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
setGroupEdit(null);
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const removeGroup = useMutation({
|
||||
mutationFn: (id: number) => api(`/category-groups/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
refresh();
|
||||
setDeleteGroup(null);
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
|
||||
const move = (gi: number, ci: number | null, delta: number) => {
|
||||
if (!groups) return;
|
||||
const next = groups.map((g) => ({ ...g, categories: [...g.categories] }));
|
||||
if (ci === null) {
|
||||
const j = gi + delta;
|
||||
if (j < 0 || j >= next.length) return;
|
||||
[next[gi], next[j]] = [next[j]!, next[gi]!];
|
||||
} else {
|
||||
const list = next[gi]!.categories;
|
||||
const j = ci + delta;
|
||||
if (j < 0 || j >= list.length) return;
|
||||
[list[ci], list[j]] = [list[j]!, list[ci]!];
|
||||
}
|
||||
reorder.mutate(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageIntro
|
||||
title="Categories"
|
||||
description="One list used everywhere. Rename a category here and every transaction, budget, rule and chart follows."
|
||||
actions={
|
||||
canEdit ? (
|
||||
<Button variant="outline" onClick={() => setGroupEdit({ name: '' })}>
|
||||
<FolderPlus /> Add group
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<label className="mb-4 flex items-center gap-2.5 text-[13.5px] text-muted-foreground">
|
||||
<Switch checked={showArchived} onCheckedChange={setShowArchived} /> Show archived categories
|
||||
</label>
|
||||
{!groups ? <Skeleton className="h-96 rounded-2xl" /> : null}
|
||||
<div className="grid gap-5">
|
||||
{groups?.map((g, gi) => {
|
||||
const visible = g.categories.filter((c) => showArchived || !c.archived);
|
||||
const kinds = new Set(g.categories.map((c) => c.kind));
|
||||
return (
|
||||
<section key={g.id}>
|
||||
<div className="mb-1.5 flex items-center gap-1 px-1">
|
||||
<h3 className="flex-1 text-[13px] font-semibold text-muted-foreground">
|
||||
{g.name}
|
||||
{kinds.size === 1 && [...kinds][0] !== 'expense' ? <span className="ml-2 font-normal">· {KIND_LABEL[[...kinds][0]!]}</span> : null}
|
||||
</h3>
|
||||
{canEdit ? (
|
||||
<>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={() => move(gi, null, -1)} disabled={gi === 0} aria-label={`Move ${g.name} up`}>
|
||||
<ArrowUp />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={() => move(gi, null, 1)} disabled={gi === groups.length - 1} aria-label={`Move ${g.name} down`}>
|
||||
<ArrowDown />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-8" onClick={() => setGroupEdit({ group: g, name: g.name })} aria-label={`Rename ${g.name}`}>
|
||||
<Pencil />
|
||||
</Button>
|
||||
{!g.categories.length ? (
|
||||
<Button variant="ghost" size="icon" className="size-8 text-bad" onClick={() => setDeleteGroup(g)} aria-label={`Remove ${g.name}`}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{visible.map((c) => {
|
||||
const ci = g.categories.indexOf(c);
|
||||
return (
|
||||
<div key={c.id} className={cn('flex items-center gap-3 px-4 py-2.5 sm:px-5', c.archived && 'opacity-60')}>
|
||||
<button disabled={!canEdit} onClick={() => setEditing({ category: c, group: g })} className="flex min-w-0 flex-1 items-center gap-3 text-left">
|
||||
<CategoryIcon icon={c.icon} color={c.color} size="sm" />
|
||||
<span className="truncate text-[14.5px] font-medium">{c.name}</span>
|
||||
{kinds.size > 1 ? <Badge>{KIND_LABEL[c.kind]}</Badge> : null}
|
||||
{c.archived ? <Badge>Archived</Badge> : null}
|
||||
</button>
|
||||
{canEdit ? (
|
||||
<span className="flex shrink-0">
|
||||
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => move(gi, ci, -1)} disabled={ci === 0} aria-label={`Move ${c.name} up`}>
|
||||
<ArrowUp />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="size-8 text-muted-foreground" onClick={() => move(gi, ci, 1)} disabled={ci === g.categories.length - 1} aria-label={`Move ${c.name} down`}>
|
||||
<ArrowDown />
|
||||
</Button>
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{canEdit ? (
|
||||
<button onClick={() => setEditing({ group: g })} className="flex w-full items-center gap-3 px-4 py-2.5 text-[14px] font-medium text-primary hover:bg-muted/60 sm:px-5">
|
||||
<Plus className="size-4" /> Add to {g.name}
|
||||
</button>
|
||||
) : null}
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Dialog open={!!editing} onOpenChange={(o) => !o && setEditing(null)}>
|
||||
{editing && groups ? (
|
||||
<DialogContent title={editing.category ? `Edit ${editing.category.name}` : `New category in ${editing.group.name}`} className="sm:max-w-lg">
|
||||
<CategoryForm key={editing.category?.id ?? `new-${editing.group.id}`} category={editing.category} group={editing.group} groups={groups} onDone={() => setEditing(null)} />
|
||||
</DialogContent>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog open={!!groupEdit} onOpenChange={(o) => !o && setGroupEdit(null)}>
|
||||
{groupEdit ? (
|
||||
<DialogContent title={groupEdit.group ? 'Rename group' : 'New group'} description="Groups add up the budgets of the categories inside them.">
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
saveGroup.mutate();
|
||||
}}
|
||||
>
|
||||
<Input value={groupEdit.name} onChange={(e) => setGroupEdit({ ...groupEdit, name: e.target.value })} required maxLength={60} autoFocus aria-label="Group name" placeholder="Side business" />
|
||||
<Button type="submit" loading={saveGroup.isPending}>
|
||||
Save
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={!!deleteGroup}
|
||||
onOpenChange={(o) => !o && setDeleteGroup(null)}
|
||||
title={`Remove ${deleteGroup?.name}?`}
|
||||
description="The group is empty, so nothing else changes."
|
||||
confirmLabel="Remove"
|
||||
destructive
|
||||
loading={removeGroup.isPending}
|
||||
onConfirm={() => deleteGroup && removeGroup.mutate(deleteGroup.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
web/src/pages/settings/ExportPage.tsx
Normal file
31
web/src/pages/settings/ExportPage.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import { FileSpreadsheet, FileText, Sheet } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { PageIntro } from '@/components/page';
|
||||
import { Card } from '@/components/ui/card';
|
||||
|
||||
function Download({ href, icon, title, detail }: { href: string; icon: ReactNode; title: string; detail: string }) {
|
||||
return (
|
||||
<a href={href} download className="flex items-center gap-4 px-5 py-4 transition-colors hover:bg-muted/60">
|
||||
<span className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-accent text-accent-foreground [&_svg]:size-5">{icon}</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-[15px] font-medium">{title}</span>
|
||||
<span className="block text-[13px] text-muted-foreground">{detail}</span>
|
||||
</span>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ExportPage() {
|
||||
const year = new Date().getFullYear();
|
||||
return (
|
||||
<div>
|
||||
<PageIntro title="Export" description="Take everything with you, any time. Files download straight from this server; nothing is sent anywhere else." />
|
||||
<Card className="divide-y overflow-hidden">
|
||||
<Download href="/api/v1/export/everything.xlsx" icon={<FileSpreadsheet />} title="Everything, as Excel" detail="Transactions, budgets, categories, accounts, subscriptions and rules, one sheet each." />
|
||||
<Download href={`/api/v1/export/transactions.xlsx?from=${year}-01-01&to=${year}-12-31`} icon={<Sheet />} title={`${year}’s transactions, as Excel`} detail="Handy for taxes. Split purchases come out one row per part." />
|
||||
<Download href="/api/v1/export/transactions.csv" icon={<FileText />} title="All transactions, as CSV" detail="Opens in Excel, Numbers, Google Sheets or anything else." />
|
||||
</Card>
|
||||
<p className="mt-4 px-1 text-[13px] text-muted-foreground">To export just part of your history, search or filter in Activity and use its Export button.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
144
web/src/pages/settings/RulesPage.tsx
Normal file
144
web/src/pages/settings/RulesPage.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import type { Rule } from '@spendlight/shared';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, Search, Trash2, Wand2 } from 'lucide-react';
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { CategoryButton } from '@/components/CategoryPicker';
|
||||
import { EmptyState, PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Dialog, DialogContent } from '@/components/ui/dialog';
|
||||
import { Field, Input } from '@/components/ui/form';
|
||||
import { Badge, Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useRules } from '@/lib/data';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
import { timeAgo } from '@/lib/utils';
|
||||
|
||||
function RuleRow({ rule, canEdit }: { rule: Rule; canEdit: boolean }) {
|
||||
const client = useQueryClient();
|
||||
const [rename, setRename] = useState(rule.renameTo ?? '');
|
||||
const refresh = () => client.invalidateQueries({ queryKey: ['rules'] });
|
||||
const patch = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api(`/rules/${rule.id}`, { method: 'PATCH', body }),
|
||||
onSuccess: refresh,
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const remove = useMutation({
|
||||
mutationFn: () => api(`/rules/${rule.id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
void refresh();
|
||||
toast(`Forgot the rule for ${rule.merchant}`);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className="grid gap-2.5 px-4 py-3.5 sm:grid-cols-[1fr_auto] sm:items-center sm:px-5">
|
||||
<div className="min-w-0">
|
||||
<p className="flex flex-wrap items-center gap-2 text-[14.5px] font-medium">
|
||||
<span className="truncate">{rule.merchant}</span>
|
||||
<Badge tone={rule.source === 'manual' ? 'primary' : 'neutral'}>{rule.source === 'manual' ? 'Set by hand' : 'Learned'}</Badge>
|
||||
</p>
|
||||
<p className="text-[12.5px] text-muted-foreground">
|
||||
Used {rule.hits} time{rule.hits === 1 ? '' : 's'} on import{rule.lastUsedAt ? `, last ${timeAgo(rule.lastUsedAt)}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CategoryButton categoryId={rule.categoryId} onPick={(c) => canEdit && patch.mutate({ categoryId: c.id })} placeholder="No category" className="w-48" />
|
||||
<Input
|
||||
value={rename}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setRename(e.target.value)}
|
||||
onBlur={() => rename !== (rule.renameTo ?? '') && patch.mutate({ renameTo: rename.trim() || null })}
|
||||
placeholder="Keep its name"
|
||||
aria-label={`New name for ${rule.merchant}`}
|
||||
className="h-10 w-44 sm:h-10"
|
||||
/>
|
||||
{canEdit ? (
|
||||
<Button variant="ghost" size="icon" onClick={() => remove.mutate()} aria-label={`Forget the rule for ${rule.merchant}`}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RulesPage() {
|
||||
const me = useCurrentUser();
|
||||
const canEdit = me.access === 'full';
|
||||
const client = useQueryClient();
|
||||
const rules = useRules();
|
||||
const [q, setQ] = useState('');
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [merchant, setMerchant] = useState('');
|
||||
const [categoryId, setCategoryId] = useState<number | null>(null);
|
||||
const add = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!categoryId) throw new Error('Choose a category for it');
|
||||
return api('/rules', { method: 'POST', body: { merchant, categoryId } });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void client.invalidateQueries({ queryKey: ['rules'] });
|
||||
setAdding(false);
|
||||
setMerchant('');
|
||||
setCategoryId(null);
|
||||
},
|
||||
onError: (e) => toast.error(errorMessage(e)),
|
||||
});
|
||||
const shown = rules.data?.filter((r) => r.merchant.toLowerCase().includes(q.toLowerCase()) || r.renameTo?.toLowerCase().includes(q.toLowerCase())) ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageIntro
|
||||
title="Rules"
|
||||
description="Merchants Spendlight always files, or renames, the same way. Most are learned when you say “also file the others”; you never have to manage them."
|
||||
actions={
|
||||
canEdit ? (
|
||||
<Button variant="outline" onClick={() => setAdding(true)}>
|
||||
<Plus /> Add rule
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
{rules.data?.length ? (
|
||||
<div className="relative mb-4">
|
||||
<Search className="pointer-events-none absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Find a merchant" className="pl-10" aria-label="Find a merchant" type="search" />
|
||||
</div>
|
||||
) : null}
|
||||
{rules.isPending ? <Skeleton className="h-64 rounded-2xl" /> : null}
|
||||
{rules.data && !rules.data.length ? (
|
||||
<EmptyState icon={Wand2} title="No rules yet" description="When you file a merchant and choose “also file the others”, Spendlight remembers it here." />
|
||||
) : null}
|
||||
{shown.length ? (
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{shown.map((r) => (
|
||||
<RuleRow key={r.id} rule={r} canEdit={canEdit} />
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
<Dialog open={adding} onOpenChange={setAdding}>
|
||||
<DialogContent title="Add a rule" description="Every future transaction from this merchant arrives filed in this category.">
|
||||
<form
|
||||
className="grid gap-4"
|
||||
onSubmit={(e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
add.mutate();
|
||||
}}
|
||||
>
|
||||
<Field label="Merchant" hint="As it appears in Activity, such as “Kroger”.">
|
||||
{(id) => <Input id={id} value={merchant} onChange={(e) => setMerchant(e.target.value)} required maxLength={80} autoFocus />}
|
||||
</Field>
|
||||
<div className="grid gap-1.5">
|
||||
<span className="text-[13px] font-medium">Category</span>
|
||||
<CategoryButton categoryId={categoryId} onPick={(c) => setCategoryId(c.id)} />
|
||||
</div>
|
||||
<Button type="submit" loading={add.isPending}>
|
||||
Add rule
|
||||
</Button>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Archive, Info, Lightbulb, ShieldCheck, UserRound, Users } from 'lucide-react';
|
||||
import { Archive, Bell, Download, Info, Lightbulb, Shapes, ShieldCheck, Sparkles, Tag, UserRound, Users, Wand2 } from 'lucide-react';
|
||||
import { NavLink, Outlet } from 'react-router';
|
||||
import { useConfig, useCurrentUser } from '@/lib/session';
|
||||
import { cn } from '@/lib/utils';
|
||||
@@ -9,8 +9,14 @@ export default function SettingsLayout() {
|
||||
const sections = [
|
||||
{ to: 'profile', label: 'Profile', icon: UserRound, show: true },
|
||||
{ to: 'security', label: 'Sign-in & devices', icon: ShieldCheck, show: true },
|
||||
{ to: 'categories', label: 'Categories', icon: Shapes, show: true },
|
||||
{ to: 'rules', label: 'Rules', icon: Wand2, show: true },
|
||||
{ to: 'tags', label: 'Tags', icon: Tag, show: true },
|
||||
{ to: 'alerts', label: 'Alerts', icon: Bell, show: true },
|
||||
{ to: 'export', label: 'Export', icon: Download, show: true },
|
||||
{ to: 'people', label: 'People', icon: Users, show: me.isAdmin },
|
||||
{ to: 'backups', label: 'Backups', icon: Archive, show: me.isAdmin },
|
||||
{ to: 'ai', label: 'AI features', icon: Sparkles, show: me.isAdmin },
|
||||
{ to: 'ideas', label: 'Ideas', icon: Lightbulb, show: me.isAdmin && !!config?.ideasEnabled },
|
||||
{ to: 'about', label: 'About', icon: Info, show: true },
|
||||
].filter((s) => s.show);
|
||||
|
||||
93
web/src/pages/settings/TagsPage.tsx
Normal file
93
web/src/pages/settings/TagsPage.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { TagInfo } from '@spendlight/shared';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowRight, Tag, Trash2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { toast } from 'sonner';
|
||||
import { EmptyState, PageIntro } from '@/components/page';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { ConfirmDialog } from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/form';
|
||||
import { Skeleton } from '@/components/ui/misc';
|
||||
import { api, errorMessage } from '@/lib/api';
|
||||
import { useTags } from '@/lib/data';
|
||||
import { useCurrentUser } from '@/lib/session';
|
||||
|
||||
function TagRow({ tag, canEdit, onDelete }: { tag: TagInfo; canEdit: boolean; onDelete: () => void }) {
|
||||
const client = useQueryClient();
|
||||
const [name, setName] = useState(tag.name);
|
||||
const rename = useMutation({
|
||||
mutationFn: () => api(`/tags/${tag.id}`, { method: 'PATCH', body: { name: name.trim() } }),
|
||||
onSuccess: () => {
|
||||
void client.invalidateQueries({ queryKey: ['tags'] });
|
||||
void client.invalidateQueries({ queryKey: ['transactions'] });
|
||||
void client.invalidateQueries({ queryKey: ['reports'] });
|
||||
},
|
||||
onError: (e) => {
|
||||
toast.error(errorMessage(e));
|
||||
setName(tag.name);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 sm:px-5">
|
||||
<Tag className="size-4 shrink-0 text-muted-foreground" />
|
||||
<Input
|
||||
value={name}
|
||||
disabled={!canEdit}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onBlur={() => name.trim() && name.trim() !== tag.name && rename.mutate()}
|
||||
onKeyDown={(e) => e.key === 'Enter' && (e.target as HTMLInputElement).blur()}
|
||||
aria-label={`Rename ${tag.name}`}
|
||||
className="h-9 flex-1 border-transparent bg-transparent px-2 shadow-none hover:border-input sm:h-9"
|
||||
/>
|
||||
<Link to={`/activity?tag=${encodeURIComponent(tag.name)}`} className="flex shrink-0 items-center gap-1 text-[13px] text-muted-foreground hover:text-foreground">
|
||||
{tag.count} <ArrowRight className="size-3.5" />
|
||||
</Link>
|
||||
{canEdit ? (
|
||||
<Button variant="ghost" size="icon" className="size-9" onClick={onDelete} aria-label={`Remove ${tag.name}`}>
|
||||
<Trash2 />
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TagsPage() {
|
||||
const me = useCurrentUser();
|
||||
const client = useQueryClient();
|
||||
const tags = useTags();
|
||||
const [removing, setRemoving] = useState<TagInfo | null>(null);
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => api(`/tags/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
void client.invalidateQueries({ queryKey: ['tags'] });
|
||||
void client.invalidateQueries({ queryKey: ['transactions'] });
|
||||
setRemoving(null);
|
||||
},
|
||||
});
|
||||
return (
|
||||
<div>
|
||||
<PageIntro title="Tags" description="Labels that cut across categories, like “Vacation 2026” or “Reimbursable”. Rename one to an existing tag to merge the two." />
|
||||
{tags.isPending ? <Skeleton className="h-48 rounded-2xl" /> : null}
|
||||
{tags.data && !tags.data.length ? <EmptyState icon={Tag} title="No tags yet" description="Add tags to any transaction in Activity; they appear here with their totals in Insights." /> : null}
|
||||
{tags.data?.length ? (
|
||||
<Card className="divide-y overflow-hidden">
|
||||
{tags.data.map((t) => (
|
||||
<TagRow key={`${t.id}-${t.name}`} tag={t} canEdit={me.access === 'full'} onDelete={() => setRemoving(t)} />
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
<ConfirmDialog
|
||||
open={!!removing}
|
||||
onOpenChange={(o) => !o && setRemoving(null)}
|
||||
title={`Remove the tag “${removing?.name}”?`}
|
||||
description={`It comes off ${removing?.count ?? 0} transaction${removing?.count === 1 ? '' : 's'}. The transactions themselves stay.`}
|
||||
confirmLabel="Remove tag"
|
||||
destructive
|
||||
loading={remove.isPending}
|
||||
onConfirm={() => removing && remove.mutate(removing.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,11 +9,13 @@ import SignInPage from './pages/SignInPage';
|
||||
|
||||
// Each screen loads its own code, so a phone's first visit stays small.
|
||||
const HomePage = lazy(() => import('./pages/HomePage'));
|
||||
const UpcomingPage = lazy(() => import('./pages/Upcoming'));
|
||||
const ReviewPage = lazy(() => import('./pages/ReviewPage'));
|
||||
const ActivityPage = lazy(() => import('./pages/ActivityPage'));
|
||||
const AccountsPage = lazy(() => import('./pages/AccountsPage'));
|
||||
const ImportPage = lazy(() => import('./pages/ImportPage'));
|
||||
const BudgetPage = lazy(() => import('./pages/BudgetPage'));
|
||||
const SubscriptionsPage = lazy(() => import('./pages/SubscriptionsPage'));
|
||||
const InsightsPage = lazy(() => import('./pages/InsightsPage'));
|
||||
const MorePage = lazy(() => import('./pages/MorePage'));
|
||||
const NotFoundPage = lazy(() => import('./pages/NotFoundPage'));
|
||||
const SettingsLayout = lazy(() => import('./pages/settings/SettingsLayout'));
|
||||
@@ -22,6 +24,12 @@ const SecurityPage = lazy(() => import('./pages/settings/SecurityPage'));
|
||||
const PeoplePage = lazy(() => import('./pages/settings/PeoplePage'));
|
||||
const BackupsPage = lazy(() => import('./pages/settings/BackupsPage'));
|
||||
const IdeasPage = lazy(() => import('./pages/settings/IdeasPage'));
|
||||
const CategoriesPage = lazy(() => import('./pages/settings/CategoriesPage'));
|
||||
const RulesPage = lazy(() => import('./pages/settings/RulesPage'));
|
||||
const TagsPage = lazy(() => import('./pages/settings/TagsPage'));
|
||||
const AlertsPage = lazy(() => import('./pages/settings/AlertsPage'));
|
||||
const ExportPage = lazy(() => import('./pages/settings/ExportPage'));
|
||||
const AiPage = lazy(() => import('./pages/settings/AiPage'));
|
||||
const AboutPage = lazy(() => import('./pages/settings/AboutPage'));
|
||||
|
||||
function AdminOnly({ children }: { children: ReactNode }) {
|
||||
@@ -48,9 +56,9 @@ export const router = createBrowserRouter([
|
||||
{ path: 'review', element: <ReviewPage /> },
|
||||
{ path: 'activity', element: <ActivityPage /> },
|
||||
{ path: 'import', element: <ImportPage /> },
|
||||
{ path: 'budget', element: <UpcomingPage screen="budget" /> },
|
||||
{ path: 'subscriptions', element: <UpcomingPage screen="subscriptions" /> },
|
||||
{ path: 'insights', element: <UpcomingPage screen="insights" /> },
|
||||
{ path: 'budget', element: <BudgetPage /> },
|
||||
{ path: 'subscriptions', element: <SubscriptionsPage /> },
|
||||
{ path: 'insights', element: <InsightsPage /> },
|
||||
{ path: 'accounts', element: <AccountsPage /> },
|
||||
{ path: 'more', element: <MorePage /> },
|
||||
{
|
||||
@@ -60,6 +68,12 @@ export const router = createBrowserRouter([
|
||||
{ index: true, element: <Navigate to="profile" replace /> },
|
||||
{ path: 'profile', element: <ProfilePage /> },
|
||||
{ path: 'security', element: <SecurityPage /> },
|
||||
{ path: 'categories', element: <CategoriesPage /> },
|
||||
{ path: 'rules', element: <RulesPage /> },
|
||||
{ path: 'tags', element: <TagsPage /> },
|
||||
{ path: 'alerts', element: <AlertsPage /> },
|
||||
{ path: 'export', element: <ExportPage /> },
|
||||
{ path: 'ai', element: <AdminOnly><AiPage /></AdminOnly> },
|
||||
{ path: 'people', element: <AdminOnly><PeoplePage /></AdminOnly> },
|
||||
{ path: 'backups', element: <AdminOnly><BackupsPage /></AdminOnly> },
|
||||
{ path: 'ideas', element: <AdminOnly><IdeasPage /></AdminOnly> },
|
||||
|
||||
Reference in New Issue
Block a user