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:
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,
|
||||
|
||||
Reference in New Issue
Block a user