diff --git a/frontend-web/bun.lock b/frontend-web/bun.lock index e512821..dc52119 100644 --- a/frontend-web/bun.lock +++ b/frontend-web/bun.lock @@ -5,7 +5,9 @@ "": { "name": "frontend-web", "dependencies": { + "@fontsource-variable/funnel-sans": "^5.2.8", "@fontsource-variable/geist": "^5.2.8", + "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/oxanium": "^5.2.8", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", @@ -163,8 +165,12 @@ "@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="], + "@fontsource-variable/funnel-sans": ["@fontsource-variable/funnel-sans@5.2.8", "", {}, "sha512-HHicOhwA5IcF29JYO8koOUdVAePvp0Efi7dQ/TxY2WNW7cdE7ffRzw52GxKT3ksV/HShssYBFT5/etjFujEgtQ=="], + "@fontsource-variable/geist": ["@fontsource-variable/geist@5.2.8", "", {}, "sha512-cJ6m9e+8MQ5dCYJsLylfZrgBh6KkG4bOLckB35Tr9J/EqdkEM6QllH5PxqP1dhTvFup+HtMRPuz9xOjxXJggxw=="], + "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], + "@fontsource-variable/oxanium": ["@fontsource-variable/oxanium@5.2.8", "", {}, "sha512-W3HWxRLXVB6yox3dgm1DIGOp98pz8KglwiM6/2BsMopvZrg98mXI9citFWz2pUX0FOOLwf8fmHxX+KrIn+6BoA=="], "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], diff --git a/frontend-web/package.json b/frontend-web/package.json index 944356d..4a21179 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -10,7 +10,9 @@ "preview": "vite preview" }, "dependencies": { + "@fontsource-variable/funnel-sans": "^5.2.8", "@fontsource-variable/geist": "^5.2.8", + "@fontsource-variable/inter": "^5.2.8", "@fontsource-variable/oxanium": "^5.2.8", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-label": "^2.1.8", diff --git a/frontend-web/src/components/dialogs/AddAssetDialog.tsx b/frontend-web/src/components/dialogs/AddAssetDialog.tsx new file mode 100644 index 0000000..16008d4 --- /dev/null +++ b/frontend-web/src/components/dialogs/AddAssetDialog.tsx @@ -0,0 +1,69 @@ +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, addAsset} from '@/store'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const assetTypes = ['cash', 'investment', 'property', 'vehicle', 'other'] as const; + +export default function AddAssetDialog({open, onOpenChange}: Props) { + const dispatch = useAppDispatch(); + const [form, setForm] = useState({name: '', type: '', value: ''}); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch(addAsset({ + id: crypto.randomUUID(), + name: form.name, + type: form.type as typeof assetTypes[number], + value: parseFloat(form.value) || 0, + updatedAt: new Date().toISOString(), + })); + onOpenChange(false); + setForm({name: '', type: '', value: ''}); + }; + + return ( + + + + Add Asset + Add a new asset to track your net worth + +
+
+
+ + setForm({...form, name: e.target.value})} className="input-depth" required /> +
+
+ + +
+
+ + setForm({...form, value: e.target.value})} className="input-depth" required /> +
+
+ + + + +
+
+
+ ); +} + diff --git a/frontend-web/src/components/dialogs/AddClientDialog.tsx b/frontend-web/src/components/dialogs/AddClientDialog.tsx new file mode 100644 index 0000000..ffae686 --- /dev/null +++ b/frontend-web/src/components/dialogs/AddClientDialog.tsx @@ -0,0 +1,71 @@ +import {useState} from 'react'; +import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'; +import {Button} from '@/components/ui/button'; +import {Input} from '@/components/ui/input'; +import {Label} from '@/components/ui/label'; +import {useAppDispatch, addClient} from '@/store'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function AddClientDialog({open, onOpenChange}: Props) { + const dispatch = useAppDispatch(); + const [form, setForm] = useState({name: '', email: '', phone: '', company: '', address: ''}); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch(addClient({ + id: crypto.randomUUID(), + name: form.name, + email: form.email, + phone: form.phone || undefined, + company: form.company || undefined, + address: form.address || undefined, + createdAt: new Date().toISOString(), + })); + onOpenChange(false); + setForm({name: '', email: '', phone: '', company: '', address: ''}); + }; + + return ( + + + + Add Client + Add a new client for invoicing + +
+
+
+ + setForm({...form, name: e.target.value})} className="input-depth" required /> +
+
+ + setForm({...form, email: e.target.value})} className="input-depth" required /> +
+
+ + setForm({...form, company: e.target.value})} className="input-depth" /> +
+
+ + setForm({...form, phone: e.target.value})} className="input-depth" /> +
+
+ + setForm({...form, address: e.target.value})} className="input-depth" /> +
+
+ + + + +
+
+
+ ); +} + diff --git a/frontend-web/src/components/dialogs/AddExpenseDialog.tsx b/frontend-web/src/components/dialogs/AddExpenseDialog.tsx new file mode 100644 index 0000000..3dd1154 --- /dev/null +++ b/frontend-web/src/components/dialogs/AddExpenseDialog.tsx @@ -0,0 +1,89 @@ +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, addExpense} from '@/store'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const frequencies = ['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'once'] as const; + +export default function AddExpenseDialog({open, onOpenChange}: Props) { + const dispatch = useAppDispatch(); + const {categories} = useAppSelector(state => state.cashflow); + const [form, setForm] = useState({name: '', amount: '', frequency: '', category: '', isEssential: false}); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch(addExpense({ + id: crypto.randomUUID(), + name: form.name, + amount: parseFloat(form.amount) || 0, + frequency: form.frequency as typeof frequencies[number], + category: form.category, + nextDate: new Date().toISOString().split('T')[0], + isActive: true, + isEssential: form.isEssential, + createdAt: new Date().toISOString(), + })); + onOpenChange(false); + setForm({name: '', amount: '', frequency: '', category: '', isEssential: false}); + }; + + return ( + + + + Add Expense + Add a recurring expense + +
+
+
+ + setForm({...form, name: e.target.value})} className="input-depth" required /> +
+
+
+ + setForm({...form, amount: e.target.value})} className="input-depth" required /> +
+
+ + +
+
+
+ + +
+ +
+ + + + +
+
+
+ ); +} + diff --git a/frontend-web/src/components/dialogs/AddIncomeDialog.tsx b/frontend-web/src/components/dialogs/AddIncomeDialog.tsx new file mode 100644 index 0000000..1895147 --- /dev/null +++ b/frontend-web/src/components/dialogs/AddIncomeDialog.tsx @@ -0,0 +1,84 @@ +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, addIncomeSource} from '@/store'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const frequencies = ['weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'once'] as const; + +export default function AddIncomeDialog({open, onOpenChange}: Props) { + const dispatch = useAppDispatch(); + const {categories} = useAppSelector(state => state.cashflow); + const [form, setForm] = useState({name: '', amount: '', frequency: '', category: ''}); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch(addIncomeSource({ + id: crypto.randomUUID(), + name: form.name, + amount: parseFloat(form.amount) || 0, + frequency: form.frequency as typeof frequencies[number], + category: form.category, + nextDate: new Date().toISOString().split('T')[0], + isActive: true, + createdAt: new Date().toISOString(), + })); + onOpenChange(false); + setForm({name: '', amount: '', frequency: '', category: ''}); + }; + + return ( + + + + Add Income Source + Add a recurring income source + +
+
+
+ + setForm({...form, name: e.target.value})} className="input-depth" required /> +
+
+
+ + setForm({...form, amount: e.target.value})} className="input-depth" required /> +
+
+ + +
+
+
+ + +
+
+ + + + +
+
+
+ ); +} + diff --git a/frontend-web/src/components/dialogs/AddLiabilityDialog.tsx b/frontend-web/src/components/dialogs/AddLiabilityDialog.tsx new file mode 100644 index 0000000..e33238d --- /dev/null +++ b/frontend-web/src/components/dialogs/AddLiabilityDialog.tsx @@ -0,0 +1,69 @@ +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, addLiability} from '@/store'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const liabilityTypes = ['credit_card', 'loan', 'mortgage', 'other'] as const; + +export default function AddLiabilityDialog({open, onOpenChange}: Props) { + const dispatch = useAppDispatch(); + const [form, setForm] = useState({name: '', type: '', balance: ''}); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch(addLiability({ + id: crypto.randomUUID(), + name: form.name, + type: form.type as typeof liabilityTypes[number], + balance: parseFloat(form.balance) || 0, + updatedAt: new Date().toISOString(), + })); + onOpenChange(false); + setForm({name: '', type: '', balance: ''}); + }; + + return ( + + + + Add Liability + Add a new liability to track + +
+
+
+ + setForm({...form, name: e.target.value})} className="input-depth" required /> +
+
+ + +
+
+ + setForm({...form, balance: e.target.value})} className="input-depth" required /> +
+
+ + + + +
+
+
+ ); +} + diff --git a/frontend-web/src/components/dialogs/AddTransactionDialog.tsx b/frontend-web/src/components/dialogs/AddTransactionDialog.tsx new file mode 100644 index 0000000..b2b94db --- /dev/null +++ b/frontend-web/src/components/dialogs/AddTransactionDialog.tsx @@ -0,0 +1,87 @@ +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, addTransaction} from '@/store'; + +interface Props { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export default function AddTransactionDialog({open, onOpenChange}: Props) { + const dispatch = useAppDispatch(); + const {categories} = useAppSelector(state => state.cashflow); + const [form, setForm] = useState({type: 'expense' as 'income' | 'expense', name: '', amount: '', category: '', date: new Date().toISOString().split('T')[0]}); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + dispatch(addTransaction({ + id: crypto.randomUUID(), + type: form.type, + name: form.name, + amount: parseFloat(form.amount) || 0, + category: form.category, + date: form.date, + })); + onOpenChange(false); + setForm({type: 'expense', name: '', amount: '', category: '', date: new Date().toISOString().split('T')[0]}); + }; + + const categoryList = form.type === 'income' ? categories.income : categories.expense; + + return ( + + + + Add Transaction + Record a one-time transaction + +
+
+
+ + +
+
+ + setForm({...form, name: e.target.value})} className="input-depth" required /> +
+
+
+ + setForm({...form, amount: e.target.value})} className="input-depth" required /> +
+
+ + setForm({...form, date: e.target.value})} className="input-depth" required /> +
+
+
+ + +
+
+ + + + +
+
+
+ ); +} + diff --git a/frontend-web/src/fonts.d.ts b/frontend-web/src/fonts.d.ts index e2f522b..e48ea3d 100644 --- a/frontend-web/src/fonts.d.ts +++ b/frontend-web/src/fonts.d.ts @@ -1,3 +1,5 @@ declare module '@fontsource-variable/geist'; declare module '@fontsource-variable/oxanium'; +declare module '@fontsource-variable/funnel-sans'; +declare module '@fontsource-variable/inter'; diff --git a/frontend-web/src/index.css b/frontend-web/src/index.css index 28ccabd..8479ad3 100644 --- a/frontend-web/src/index.css +++ b/frontend-web/src/index.css @@ -42,72 +42,72 @@ } :root { - --radius: 0.625rem; - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); + --radius: 0.5rem; + --background: oklch(0.98 0 0); + --foreground: oklch(0.15 0 0); --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); + --card-foreground: oklch(0.15 0 0); --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.205 0 0); - --primary-foreground: oklch(0.985 0 0); - --secondary: oklch(0.97 0 0); - --secondary-foreground: oklch(0.205 0 0); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); - --destructive: oklch(0.577 0.245 27.325); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.646 0.222 41.116); - --chart-2: oklch(0.6 0.118 184.704); - --chart-3: oklch(0.398 0.07 227.392); - --chart-4: oklch(0.828 0.189 84.429); - --chart-5: oklch(0.769 0.188 70.08); - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.205 0 0); - --sidebar-primary-foreground: oklch(0.985 0 0); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --popover-foreground: oklch(0.15 0 0); + --primary: oklch(0.2 0 0); + --primary-foreground: oklch(0.98 0 0); + --secondary: oklch(0.96 0 0); + --secondary-foreground: oklch(0.2 0 0); + --muted: oklch(0.96 0 0); + --muted-foreground: oklch(0.5 0 0); + --accent: oklch(0.96 0 0); + --accent-foreground: oklch(0.2 0 0); + --destructive: oklch(0.55 0.2 25); + --border: oklch(0.9 0 0); + --input: oklch(0.9 0 0); + --ring: oklch(0.7 0 0); + --chart-1: oklch(0.65 0.2 40); + --chart-2: oklch(0.6 0.12 185); + --chart-3: oklch(0.4 0.07 225); + --chart-4: oklch(0.8 0.18 85); + --chart-5: oklch(0.75 0.18 70); + --sidebar: oklch(0.98 0 0); + --sidebar-foreground: oklch(0.15 0 0); + --sidebar-primary: oklch(0.2 0 0); + --sidebar-primary-foreground: oklch(0.98 0 0); + --sidebar-accent: oklch(0.96 0 0); + --sidebar-accent-foreground: oklch(0.2 0 0); + --sidebar-border: oklch(0.9 0 0); + --sidebar-ring: oklch(0.7 0 0); } .dark { - --background: oklch(0.14 0 0); - --foreground: oklch(0.92 0 0); - --card: oklch(0.18 0 0); - --card-foreground: oklch(0.92 0 0); - --popover: oklch(0.18 0 0); - --popover-foreground: oklch(0.92 0 0); - --primary: oklch(0.92 0 0); - --primary-foreground: oklch(0.14 0 0); - --secondary: oklch(0.22 0 0); + --background: oklch(0.22 0.01 260); + --foreground: oklch(0.9 0 0); + --card: oklch(0.26 0.01 260 / 0.6); + --card-foreground: oklch(0.9 0 0); + --popover: oklch(0.28 0.01 260); + --popover-foreground: oklch(0.9 0 0); + --primary: oklch(0.9 0 0); + --primary-foreground: oklch(0.2 0 0); + --secondary: oklch(0.3 0.01 260 / 0.5); --secondary-foreground: oklch(0.85 0 0); - --muted: oklch(0.22 0 0); - --muted-foreground: oklch(0.55 0 0); - --accent: oklch(0.22 0 0); - --accent-foreground: oklch(0.92 0 0); + --muted: oklch(0.3 0.01 260); + --muted-foreground: oklch(0.6 0 0); + --accent: oklch(0.32 0.01 260 / 0.5); + --accent-foreground: oklch(0.9 0 0); --destructive: oklch(0.6 0.2 25); - --border: oklch(0.26 0 0); - --input: oklch(0.2 0 0); - --ring: oklch(0.45 0 0); - --chart-1: oklch(0.7 0 0); - --chart-2: oklch(0.55 0 0); - --chart-3: oklch(0.8 0 0); - --chart-4: oklch(0.45 0 0); - --chart-5: oklch(0.65 0 0); - --sidebar: oklch(0.12 0 0); - --sidebar-foreground: oklch(0.92 0 0); - --sidebar-primary: oklch(0.92 0 0); - --sidebar-primary-foreground: oklch(0.14 0 0); - --sidebar-accent: oklch(0.22 0 0); - --sidebar-accent-foreground: oklch(0.92 0 0); - --sidebar-border: oklch(0.26 0 0); - --sidebar-ring: oklch(0.45 0 0); + --border: oklch(1 0 0 / 0.08); + --input: oklch(0.26 0.01 260); + --ring: oklch(0.5 0 0); + --chart-1: oklch(0.7 0.08 260); + --chart-2: oklch(0.6 0.06 200); + --chart-3: oklch(0.75 0.05 60); + --chart-4: oklch(0.55 0.08 300); + --chart-5: oklch(0.65 0.06 30); + --sidebar: oklch(0.2 0.01 260 / 0.8); + --sidebar-foreground: oklch(0.9 0 0); + --sidebar-primary: oklch(0.9 0 0); + --sidebar-primary-foreground: oklch(0.2 0 0); + --sidebar-accent: oklch(0.32 0.01 260 / 0.5); + --sidebar-accent-foreground: oklch(0.9 0 0); + --sidebar-border: oklch(1 0 0 / 0.06); + --sidebar-ring: oklch(0.5 0 0); } @layer base { @@ -115,53 +115,46 @@ @apply border-border outline-ring/50; } html { - font-size: 17px; + font-size: 16px; } body { @apply bg-background text-foreground; - font-family: 'Oxanium Variable', system-ui, sans-serif; + font-family: 'Inter Variable', system-ui, sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; + letter-spacing: -0.01em; + } + h1, h2, h3, h4, h5, h6 { + font-family: 'Funnel Sans Variable', system-ui, sans-serif; + letter-spacing: -0.02em; } } -/* Depth utilities */ +/* Dark glass background */ .dark body { - background: - radial-gradient(ellipse 80% 60% at 50% -20%, oklch(0.22 0 0), transparent), - oklch(0.14 0 0); + background: oklch(0.22 0.01 260); } +/* Glass card effect */ .card-elevated { - background: linear-gradient( - 170deg, - oklch(0.2 0 0) 0%, - oklch(0.17 0 0) 100% - ); + background: oklch(0.28 0.01 260 / 0.5); + backdrop-filter: blur(12px); border: 1px solid oklch(1 0 0 / 0.06); - box-shadow: - inset 0 1px 0 oklch(1 0 0 / 0.03), - 0 2px 8px oklch(0 0 0 / 0.3), - 0 8px 32px oklch(0 0 0 / 0.25); + box-shadow: 0 1px 2px oklch(0 0 0 / 0.1); } .glow-subtle { - box-shadow: - inset 0 1px 0 oklch(1 0 0 / 0.03), - 0 2px 8px oklch(0 0 0 / 0.3), - 0 8px 32px oklch(0 0 0 / 0.25); + box-shadow: 0 1px 2px oklch(0 0 0 / 0.1); } .input-depth { - background: oklch(0.12 0 0); + background: oklch(0.2 0.01 260 / 0.6); + backdrop-filter: blur(8px); border: 1px solid oklch(1 0 0 / 0.08); - box-shadow: inset 0 1px 2px oklch(0 0 0 / 0.3); - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: border-color 0.15s ease; } .input-depth:focus { - border-color: oklch(1 0 0 / 0.2); - box-shadow: - inset 0 1px 2px oklch(0 0 0 / 0.3), - 0 0 0 3px oklch(1 0 0 / 0.05); + border-color: oklch(1 0 0 / 0.15); + outline: none; } diff --git a/frontend-web/src/main.tsx b/frontend-web/src/main.tsx index fdaf4f1..7c411a8 100644 --- a/frontend-web/src/main.tsx +++ b/frontend-web/src/main.tsx @@ -2,7 +2,8 @@ import {StrictMode} from 'react'; import {createRoot} from 'react-dom/client'; import {Provider} from 'react-redux'; import {store} from './store'; -import '@fontsource-variable/oxanium'; +import '@fontsource-variable/funnel-sans'; +import '@fontsource-variable/inter'; import './index.css'; import App from './App.tsx';