Enhance frontend-web with new features and dependencies

- Added new font support for 'Oxanium' and integrated Radix UI components including Dialog and Select.
- Updated CSS to set the default font to 'Oxanium Variable' and adjusted HTML font size.
- Introduced AddAccountDialog component for managing debt accounts, enhancing user experience.
- Refactored DebtsPage to utilize the new AddAccountDialog and improved account management features.
- Updated Redux store to support debt categories and accounts, including actions for adding, updating, and removing accounts.
- Mock data added for clients and invoices to facilitate development and testing.
This commit is contained in:
2025-12-07 11:10:33 -05:00
parent bf00261e1d
commit 043f0bd316
17 changed files with 1374 additions and 422 deletions

View File

@@ -0,0 +1,239 @@
import {useState} from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {useAppDispatch, useAppSelector, addAccount} from '@/store';
interface AddAccountDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export default function AddAccountDialog({open, onOpenChange}: AddAccountDialogProps) {
const dispatch = useAppDispatch();
const {categories} = useAppSelector(state => state.debts);
const [formData, setFormData] = useState({
name: '',
categoryId: '',
institution: '',
accountNumber: '',
originalBalance: '',
currentBalance: '',
interestRate: '',
minimumPayment: '',
dueDay: '',
notes: '',
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const now = new Date().toISOString();
const account = {
id: crypto.randomUUID(),
name: formData.name,
categoryId: formData.categoryId,
institution: formData.institution,
accountNumber: formData.accountNumber || undefined,
originalBalance: parseFloat(formData.originalBalance) || 0,
currentBalance: parseFloat(formData.currentBalance) || parseFloat(formData.originalBalance) || 0,
interestRate: parseFloat(formData.interestRate) || 0,
minimumPayment: parseFloat(formData.minimumPayment) || 0,
dueDay: parseInt(formData.dueDay) || 1,
notes: formData.notes || undefined,
createdAt: now,
updatedAt: now,
};
dispatch(addAccount(account));
onOpenChange(false);
setFormData({
name: '',
categoryId: '',
institution: '',
accountNumber: '',
originalBalance: '',
currentBalance: '',
interestRate: '',
minimumPayment: '',
dueDay: '',
notes: '',
});
};
const updateField = (field: string, value: string) => {
setFormData(prev => ({...prev, [field]: value}));
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="card-elevated max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>Add Debt Account</DialogTitle>
<DialogDescription>
Add a new debt account to track your payoff progress
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4">
<div className="grid gap-2">
<Label htmlFor="name">Account Name</Label>
<Input
id="name"
placeholder="e.g., Chase Sapphire"
value={formData.name}
onChange={e => updateField('name', e.target.value)}
className="input-depth"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="category">Category</Label>
<Select
value={formData.categoryId}
onValueChange={value => updateField('categoryId', value)}
required
>
<SelectTrigger className="input-depth">
<SelectValue placeholder="Select a category" />
</SelectTrigger>
<SelectContent>
{categories.map(category => (
<SelectItem key={category.id} value={category.id}>
{category.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="institution">Institution / Lender</Label>
<Input
id="institution"
placeholder="e.g., Chase Bank"
value={formData.institution}
onChange={e => updateField('institution', e.target.value)}
className="input-depth"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="accountNumber">Last 4 Digits (optional)</Label>
<Input
id="accountNumber"
placeholder="1234"
maxLength={4}
value={formData.accountNumber}
onChange={e => updateField('accountNumber', e.target.value.replace(/\D/g, ''))}
className="input-depth"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="originalBalance">Original Balance</Label>
<Input
id="originalBalance"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={formData.originalBalance}
onChange={e => updateField('originalBalance', e.target.value)}
className="input-depth"
required
/>
</div>
<div className="grid gap-2">
<Label htmlFor="currentBalance">Current Balance</Label>
<Input
id="currentBalance"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={formData.currentBalance}
onChange={e => updateField('currentBalance', e.target.value)}
className="input-depth"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="interestRate">Interest Rate (%)</Label>
<Input
id="interestRate"
type="number"
step="0.01"
min="0"
max="100"
placeholder="0.00"
value={formData.interestRate}
onChange={e => updateField('interestRate', e.target.value)}
className="input-depth"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="minimumPayment">Min Payment</Label>
<Input
id="minimumPayment"
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={formData.minimumPayment}
onChange={e => updateField('minimumPayment', e.target.value)}
className="input-depth"
/>
</div>
</div>
<div className="grid gap-2">
<Label htmlFor="dueDay">Due Day of Month</Label>
<Input
id="dueDay"
type="number"
min="1"
max="31"
placeholder="1"
value={formData.dueDay}
onChange={e => updateField('dueDay', e.target.value)}
className="input-depth"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" disabled={!formData.name || !formData.categoryId || !formData.institution}>
Add Account
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -1,53 +1,50 @@
import {NavLink, Outlet} from 'react-router-dom';
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip';
import {TrendingUp, CreditCard, FileText, Users} from 'lucide-react';
const navItems = [
{to: '/', label: 'Net Worth', icon: '◈'},
{to: '/debts', label: 'Debts', icon: '◇'},
{to: '/invoices', label: 'Invoices', icon: '▤'},
{to: '/clients', label: 'Clients', icon: '◉'},
{to: '/', label: 'Net Worth', icon: TrendingUp},
{to: '/debts', label: 'Debts', icon: CreditCard},
{to: '/invoices', label: 'Invoices', icon: FileText},
{to: '/clients', label: 'Clients', icon: Users},
];
export default function Layout() {
return (
<TooltipProvider delayDuration={0}>
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="fixed left-0 top-0 z-40 flex h-screen w-16 flex-col border-r border-border bg-sidebar">
<div className="flex h-16 items-center justify-center border-b border-border">
<span className="text-xl font-semibold text-foreground">W</span>
</div>
<nav className="flex flex-1 flex-col items-center gap-2 py-4">
{navItems.map(item => (
<Tooltip key={item.to}>
<TooltipTrigger asChild>
<NavLink
to={item.to}
className={({isActive}) =>
`flex h-10 w-10 items-center justify-center rounded-lg text-lg transition-colors ${
isActive
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`
}
>
{item.icon}
</NavLink>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8}>
{item.label}
</TooltipContent>
</Tooltip>
))}
</nav>
</aside>
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="group fixed left-0 top-0 z-40 flex h-screen w-14 hover:w-44 flex-col border-r border-border bg-sidebar transition-all duration-200 ease-out">
<div className="flex h-12 items-center gap-2 border-b border-border px-4">
<span className="text-base font-semibold text-foreground">W</span>
<span className="text-sm font-medium text-foreground opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
Wealth
</span>
</div>
<nav className="flex flex-1 flex-col gap-1 py-3 px-2">
{navItems.map(item => (
<NavLink
key={item.to}
to={item.to}
className={({isActive}) =>
`flex h-9 items-center gap-3 rounded-lg px-2.5 transition-colors ${
isActive
? 'bg-accent text-foreground'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'
}`
}
>
<item.icon className="h-[18px] w-[18px] shrink-0" />
<span className="text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-200 whitespace-nowrap">
{item.label}
</span>
</NavLink>
))}
</nav>
</aside>
{/* Main content */}
<main className="ml-16 flex-1">
<Outlet />
</main>
</div>
</TooltipProvider>
{/* Main content */}
<main className="ml-14 flex-1">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}

View File

@@ -0,0 +1,187 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}