diff --git a/frontend/src/PrivacyContext.tsx b/frontend/src/PrivacyContext.tsx deleted file mode 100644 index 9850596..0000000 --- a/frontend/src/PrivacyContext.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { createContext, useContext, useEffect, useState } from 'react'; - -const PrivacyContext = createContext<{ - privacyMode: boolean; - togglePrivacy: () => void; -}>({ privacyMode: false, togglePrivacy: () => {} }); - -export function PrivacyProvider({ children }: { children: React.ReactNode }) { - const [privacyMode, setPrivacyMode] = useState(() => { - return localStorage.getItem('privacyMode') === 'true'; - }); - - useEffect(() => { - document.documentElement.classList.toggle('privacy', privacyMode); - localStorage.setItem('privacyMode', String(privacyMode)); - }, [privacyMode]); - - const togglePrivacy = () => setPrivacyMode((p) => !p); - - return ( - - {children} - - ); -} - -export const usePrivacy = () => useContext(PrivacyContext); diff --git a/frontend/src/ThemeContext.tsx b/frontend/src/ThemeContext.tsx deleted file mode 100644 index 3730008..0000000 --- a/frontend/src/ThemeContext.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { createContext, useContext, useEffect, useState } from 'react'; - -type Theme = 'light' | 'dark'; - -const ThemeContext = createContext<{ - theme: Theme; - toggleTheme: () => void; -}>({ theme: 'dark', toggleTheme: () => {} }); - -export function ThemeProvider({ children }: { children: React.ReactNode }) { - const [theme, setTheme] = useState(() => { - const saved = localStorage.getItem('theme') as Theme; - if (saved) return saved; - return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; - }); - - useEffect(() => { - document.documentElement.classList.toggle('dark', theme === 'dark'); - localStorage.setItem('theme', theme); - }, [theme]); - - const toggleTheme = () => setTheme((t) => (t === 'dark' ? 'light' : 'dark')); - - return ( - - {children} - - ); -} - -export const useTheme = () => useContext(ThemeContext); diff --git a/frontend/src/components/DashboardSection.tsx b/frontend/src/components/DashboardSection.tsx deleted file mode 100644 index 4594440..0000000 --- a/frontend/src/components/DashboardSection.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import { Settings } from 'lucide-react'; -import type { SectionSettings } from '../api'; -import { formatAmount } from '@/lib/format'; -import { Card } from '@/components/ui/card'; -import { - Accordion, - AccordionItem, - AccordionTrigger, - AccordionContent, -} from '@/components/ui/accordion'; -import { getColorClasses } from '@/lib/colors'; -import { cn } from '@/lib/utils'; - -interface Props { - sectionId: string; - settings: SectionSettings; - total?: number; - totalCurrency?: string; - onToggleExpanded: (expanded: boolean) => void; - onOpenConfig: () => void; - children: React.ReactNode; -} - -export default function DashboardSection({ - sectionId, - settings, - total, - totalCurrency, - onToggleExpanded, - onOpenConfig, - children, -}: Props) { - const colors = getColorClasses(settings.color); - - return ( - - {/* Settings icon — outside accordion trigger to avoid button-in-button */} - - - onToggleExpanded(value.includes(sectionId))} - > - - -
- - {settings.label} - - {total != null && totalCurrency && ( - - {formatAmount(total, totalCurrency)} - - )} -
-
- -
- {children} -
-
-
-
-
- ); -} diff --git a/frontend/src/components/SectionConfigDialog.tsx b/frontend/src/components/SectionConfigDialog.tsx deleted file mode 100644 index accff0a..0000000 --- a/frontend/src/components/SectionConfigDialog.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { useState } from 'react'; -import type { SectionSettings } from '../api'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Label } from '@/components/ui/label'; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogFooter, -} from '@/components/ui/dialog'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; -import { COLOR_OPTIONS, getColorClasses } from '@/lib/colors'; -import { cn } from '@/lib/utils'; - -interface Props { - sectionId: string; - settings: SectionSettings; - open: boolean; - onOpenChange: (open: boolean) => void; - onSave: (sectionId: string, updated: Partial) => void; -} - -function ColorSwatch({ color }: { color: string }) { - const classes = getColorClasses(color); - return ( - - - {color} - - ); -} - -export default function SectionConfigDialog({ sectionId, settings, open, onOpenChange, onSave }: Props) { - const [label, setLabel] = useState(settings.label); - const [color, setColor] = useState(settings.color); - const [cardColor, setCardColor] = useState(settings.cardColor); - const [visible, setVisible] = useState(settings.visible); - const [order, setOrder] = useState(String(settings.order)); - - const handleSave = () => { - onSave(sectionId, { - label, - color, - cardColor, - visible, - order: parseInt(order) || 0, - }); - onOpenChange(false); - }; - - return ( - - - - Configure Section - - -
-
- - setLabel(e.target.value)} /> -
- -
- - -
- -
- - -
- -
- - setVisible(e.target.checked)} - className="h-4 w-4 rounded border-input accent-primary cursor-pointer" - /> -
- -
- - setOrder(e.target.value)} - className="w-20" - /> -
-
- - - - - -
-
- ); -} diff --git a/frontend/src/contexts/privacy-context.tsx b/frontend/src/contexts/privacy-context.tsx new file mode 100644 index 0000000..260ae39 --- /dev/null +++ b/frontend/src/contexts/privacy-context.tsx @@ -0,0 +1,31 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; + +const PrivacyContext = createContext<{ + privacyMode: boolean; + togglePrivacy: () => void; +}>({ privacyMode: false, togglePrivacy: () => {} }); + +export function PrivacyProvider({ children }: { children: ReactNode }) { + const [privacyMode, setPrivacyMode] = useState(false); + + useEffect(() => { + setPrivacyMode(localStorage.getItem("privacyMode") === "true"); + }, []); + + useEffect(() => { + document.documentElement.classList.toggle("privacy", privacyMode); + localStorage.setItem("privacyMode", String(privacyMode)); + }, [privacyMode]); + + const togglePrivacy = () => setPrivacyMode((p) => !p); + + return ( + + {children} + + ); +} + +export const usePrivacy = () => useContext(PrivacyContext); diff --git a/frontend/src/contexts/theme-context.tsx b/frontend/src/contexts/theme-context.tsx new file mode 100644 index 0000000..f0ab679 --- /dev/null +++ b/frontend/src/contexts/theme-context.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; + +type Theme = "light" | "dark"; + +const ThemeContext = createContext<{ + theme: Theme; + toggleTheme: () => void; +}>({ theme: "dark", toggleTheme: () => {} }); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState("dark"); + + // Initialize once on mount (localStorage + prefers-color-scheme). + useEffect(() => { + const saved = localStorage.getItem("theme") as Theme | null; + const initial: Theme = saved + ? saved + : window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; + setTheme(initial); + }, []); + + useEffect(() => { + document.documentElement.classList.toggle("dark", theme === "dark"); + localStorage.setItem("theme", theme); + }, [theme]); + + const toggleTheme = () => setTheme((t) => (t === "dark" ? "light" : "dark")); + + return ( + + {children} + + ); +} + +export const useTheme = () => useContext(ThemeContext); diff --git a/frontend/src/hooks/useSettings.ts b/frontend/src/hooks/useSettings.ts deleted file mode 100644 index c6e56f3..0000000 --- a/frontend/src/hooks/useSettings.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { useEffect, useState, useCallback } from 'react'; -import { - getSettings, - updateSettings, - type UserSettingsData, - type SectionSettings, -} from '../api'; - -const DEFAULT_SETTINGS: UserSettingsData = { - dashboard: { - sections: { - crc_accounts: { label: 'CRC Accounts', color: 'primary', cardColor: 'primary', visible: true, order: 0, expanded: false }, - usd_accounts: { label: 'USD Accounts', color: 'chart-1', cardColor: 'chart-1', visible: true, order: 1, expanded: false }, - pension: { label: 'Pension', color: 'chart-2', cardColor: 'chart-2', visible: true, order: 2, expanded: false }, - savings: { label: 'Savings', color: 'chart-3', cardColor: 'chart-3', visible: true, order: 3, expanded: false }, - liabilities: { label: 'Liabilities', color: 'destructive', cardColor: 'destructive', visible: true, order: 4, expanded: false }, - crypto: { label: 'Crypto', color: 'chart-4', cardColor: 'chart-4', visible: true, order: 5, expanded: false }, - }, - }, -}; - -export function useSettings() { - const [settings, setSettings] = useState(DEFAULT_SETTINGS); - const [loading, setLoading] = useState(true); - - useEffect(() => { - getSettings() - .then((r) => setSettings(r.data.data)) - .catch(() => {}) // use defaults on error - .finally(() => setLoading(false)); - }, []); - - const patchSection = useCallback( - async (sectionId: string, partial: Partial) => { - setSettings((prev) => { - const updated = { - ...prev, - dashboard: { - ...prev.dashboard, - sections: { - ...prev.dashboard.sections, - [sectionId]: { ...prev.dashboard.sections[sectionId], ...partial }, - }, - }, - }; - // Fire-and-forget save - updateSettings(updated).catch(console.error); - return updated; - }); - }, - [] - ); - - return { settings, loading, patchSection }; -} diff --git a/frontend/src/api.ts b/frontend/src/lib/api.ts similarity index 74% rename from frontend/src/api.ts rename to frontend/src/lib/api.ts index 7a650fa..f5d5849 100644 --- a/frontend/src/api.ts +++ b/frontend/src/lib/api.ts @@ -1,4 +1,4 @@ -const BASE_URL = import.meta.env.VITE_API_URL || '/api/v1'; +const BASE_URL = "/api/v1"; class ApiError extends Error { response: { status: number; data: unknown }; @@ -12,7 +12,12 @@ interface RequestConfig { params?: Record; } -async function request(method: string, url: string, body?: unknown, config?: RequestConfig): Promise<{ data: T }> { +async function request( + method: string, + url: string, + body?: unknown, + config?: RequestConfig, +): Promise<{ data: T }> { let fullUrl = `${BASE_URL}${url}`; if (config?.params) { @@ -25,28 +30,32 @@ async function request(method: string, url: string, body?: unknown, config?: } const headers: Record = {}; - const token = localStorage.getItem('token'); - if (token) headers['Authorization'] = `Bearer ${token}`; - let fetchBody: BodyInit | undefined; if (body instanceof FormData || body instanceof URLSearchParams) { fetchBody = body; } else if (body !== undefined) { - headers['Content-Type'] = 'application/json'; + headers["Content-Type"] = "application/json"; fetchBody = JSON.stringify(body); } - const res = await fetch(fullUrl, { method, headers, body: fetchBody }); + const res = await fetch(fullUrl, { + method, + headers, + body: fetchBody, + credentials: "same-origin", + }); if (res.status === 401) { - localStorage.removeItem('token'); - window.location.href = '/login'; + await fetch("/api/auth/logout", { method: "POST" }).catch(() => {}); + if (typeof window !== "undefined") window.location.replace("/login"); throw new ApiError(401, null); } if (!res.ok) { let data: unknown = null; - try { data = await res.json(); } catch {} + try { + data = await res.json(); + } catch {} throw new ApiError(res.status, data); } @@ -57,34 +66,48 @@ async function request(method: string, url: string, body?: unknown, config?: } const api = { - get(url: string, config?: RequestConfig) { - return request('GET', url, undefined, config); + get(url: string, config?: RequestConfig) { + return request("GET", url, undefined, config); }, - post(url: string, body?: unknown, config?: RequestConfig) { - return request('POST', url, body, config); + post(url: string, body?: unknown, config?: RequestConfig) { + return request("POST", url, body, config); }, - patch(url: string, body?: unknown, config?: RequestConfig) { - return request('PATCH', url, body, config); + patch(url: string, body?: unknown, config?: RequestConfig) { + return request("PATCH", url, body, config); }, - put(url: string, body?: unknown, config?: RequestConfig) { - return request('PUT', url, body, config); + put(url: string, body?: unknown, config?: RequestConfig) { + return request("PUT", url, body, config); }, - delete(url: string, config?: RequestConfig) { - return request('DELETE', url, undefined, config); + delete(url: string, config?: RequestConfig) { + return request("DELETE", url, undefined, config); }, }; export default api; export async function login(username: string, password: string) { - const form = new URLSearchParams(); - form.append('username', username); - form.append('password', password); - const { data } = await api.post('/auth/login', form); - localStorage.setItem('token', data.access_token); - return data; + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + credentials: "same-origin", + }); + if (!res.ok) { + let data: unknown = null; + try { + data = await res.json(); + } catch {} + throw new ApiError(res.status, data); + } + return res.json(); } +export async function logout() { + await fetch("/api/auth/logout", { method: "POST", credentials: "same-origin" }); +} + +// ─── Types ────────────────────────────────────────────────────────────────── + export interface Account { id: number; bank: string; @@ -109,34 +132,6 @@ export interface ImportResult { errors: string[]; } -// --- User Settings --- - -export interface SectionSettings { - label: string; - color: string; - cardColor: string; - visible: boolean; - order: number; - expanded: boolean; -} - -export interface DashboardSettings { - sections: Record; -} - -export interface UserSettingsData { - dashboard: DashboardSettings; -} - -export interface UserSettingsResponse { - key: string; - data: UserSettingsData; - updated_at: string; -} - -export const getSettings = () => api.get('/settings/'); -export const updateSettings = (data: UserSettingsData) => api.patch('/settings/', { data }); - export interface Transaction { id: number; amount: number; @@ -160,8 +155,13 @@ export interface Transaction { // --- Budget / Recurring Items --- -export type RecurringItemType = 'INCOME' | 'EXPENSE'; -export type RecurringFrequency = 'WEEKLY' | 'MONTHLY' | 'QUARTERLY' | 'BIANNUAL' | 'YEARLY'; +export type RecurringItemType = "INCOME" | "EXPENSE"; +export type RecurringFrequency = + | "WEEKLY" + | "MONTHLY" + | "QUARTERLY" + | "BIANNUAL" + | "YEARLY"; export interface RecurringItem { id: number; @@ -295,19 +295,22 @@ export interface SavingsAccrualUpdate { } export const getSavingsAccruals = () => - api.get('/savings-accrual/'); + api.get("/savings-accrual/"); export const createSavingsAccrual = (data: SavingsAccrualCreate) => - api.post('/savings-accrual/', data); + api.post("/savings-accrual/", data); export const updateSavingsAccrual = (id: number, data: SavingsAccrualUpdate) => api.patch(`/savings-accrual/${id}`, data); export const deleteSavingsAccrual = (id: number) => api.delete(`/savings-accrual/${id}`); -// Budget API functions -export const getRecurringItems = (params?: { item_type?: string; is_active?: boolean }) => - api.get('/budget/recurring', { params }); +// --- Budget --- + +export const getRecurringItems = (params?: { + item_type?: string; + is_active?: boolean; +}) => api.get("/budget/recurring", { params }); export const createRecurringItem = (data: RecurringItemCreate) => - api.post('/budget/recurring', data); + api.post("/budget/recurring", data); export const updateRecurringItem = (id: number, data: RecurringItemUpdate) => api.patch(`/budget/recurring/${id}`, data); export const deleteRecurringItem = (id: number) => @@ -316,7 +319,11 @@ export const getYearlyProjection = (year: number) => api.get(`/budget/projection/${year}`); export const getMonthlyDetail = (year: number, month: number) => api.get(`/budget/month/${year}/${month}`); -export const upsertBalanceOverride = (year: number, month: number, override_balance: number) => +export const upsertBalanceOverride = ( + year: number, + month: number, + override_balance: number, +) => api.put(`/budget/balance-override/${year}/${month}`, { override_balance }); export const deleteBalanceOverride = (year: number, month: number) => api.delete(`/budget/balance-override/${year}/${month}`); @@ -330,9 +337,9 @@ export interface SalariosSummary { } export const getSalarios = (params?: { limit?: number; offset?: number }) => - api.get('/salarios/', { params }); + api.get("/salarios/", { params }); export const getSalariosSummary = () => - api.get('/salarios/summary'); + api.get("/salarios/summary"); // --- Pensions --- @@ -380,18 +387,16 @@ export interface PensionManualEntry { export const uploadPensionPDFs = (files: File[]) => { const form = new FormData(); - files.forEach((f) => form.append('files', f)); - return api.post('/pensions/upload', form); + files.forEach((f) => form.append("files", f)); + return api.post("/pensions/upload", form); }; export const getPensionSnapshots = () => - api.get('/pensions/snapshots'); - + api.get("/pensions/snapshots"); export const getPensionFundSummary = () => - api.get('/pensions/fund-summary'); - + api.get("/pensions/fund-summary"); export const submitPensionManualEntries = (entries: PensionManualEntry[]) => - api.post('/pensions/manual', { entries }); + api.post("/pensions/manual", { entries }); // --- Municipal Receipts --- @@ -448,17 +453,18 @@ export interface MunicipalReceiptUploadResult { export const uploadMunicipalReceipt = (file: File) => { const form = new FormData(); - form.append('file', file); - return api.post('/municipal-receipts/upload', form); + form.append("file", file); + return api.post( + "/municipal-receipts/upload", + form, + ); }; export const getMunicipalReceipts = () => - api.get('/municipal-receipts/'); - + api.get("/municipal-receipts/"); export const getMunicipalReceiptDetail = (id: number) => api.get(`/municipal-receipts/${id}`); - export const getWaterConsumption = (months?: number) => - api.get('/municipal-receipts/water-consumption', { + api.get("/municipal-receipts/water-consumption", { params: months ? { months } : undefined, }); diff --git a/frontend/src/pushNotifications.ts b/frontend/src/lib/push-notifications.ts similarity index 100% rename from frontend/src/pushNotifications.ts rename to frontend/src/lib/push-notifications.ts diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx deleted file mode 100644 index 3a42d27..0000000 --- a/frontend/src/pages/Dashboard.tsx +++ /dev/null @@ -1,376 +0,0 @@ -import { useEffect, useState, useMemo } from 'react'; -import { Link } from 'react-router-dom'; -import { - ArrowRight, - TrendingUp, - TrendingDown, - RefreshCw, - CreditCard, - Pencil, - Check, - X, - BellRing, - Landmark, -} from 'lucide-react'; - -import api, { type Account, type Transaction } from '../api'; -import { useSettings } from '@/hooks/useSettings'; -import { formatAmount, formatDate, formatLocalDatetime } from '@/lib/format'; -import DashboardSection from '@/components/DashboardSection'; -import SectionConfigDialog from '@/components/SectionConfigDialog'; -import { Button } from '@/components/ui/button'; -import { Input } from '@/components/ui/input'; -import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; -import { cn } from '@/lib/utils'; - -// --- Section definitions --- - -interface SectionDef { - filterFn: (a: Account) => boolean; - totalCurrency: string; // empty string = no total -} - -const SECTION_DEFS: Record = { - crc_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'CRC', totalCurrency: 'CRC' }, - usd_accounts: { filterFn: (a) => a.account_type === 'BANK' && a.currency === 'USD', totalCurrency: 'USD' }, - pension: { filterFn: (a) => a.account_type === 'PENSION', totalCurrency: 'CRC' }, - savings: { filterFn: (a) => a.account_type === 'SAVINGS', totalCurrency: 'CRC' }, - liabilities: { filterFn: (a) => a.account_type === 'LIABILITY', totalCurrency: '' }, - crypto: { filterFn: (a) => a.account_type === 'CRYPTO', totalCurrency: '' }, -}; - -const BANK_ORDER = ['BAC', 'BCR', 'DAVIVIENDA']; - -// --- AccountRow --- - -interface AccountRowProps { - account: Account; - editingId: number | null; - editValue: string; - setEditValue: (v: string) => void; - startEditing: (a: Account) => void; - saveBalance: (id: number) => void; - cancelEditing: () => void; -} - -function AccountRow({ - account, - editingId, - editValue, - setEditValue, - startEditing, - saveBalance, - cancelEditing, -}: AccountRowProps) { - const isLiability = account.account_type === 'LIABILITY'; - const isCrypto = account.account_type === 'CRYPTO'; - const label = isCrypto ? account.label : (account.bank === 'DAVIVIENDA' ? 'DAV' : account.bank); - const isEditing = editingId === account.id; - - return ( -
- {label} - - {isEditing ? ( -
- setEditValue(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') saveBalance(account.id); - if (e.key === 'Escape') cancelEditing(); - }} - autoFocus - className="text-sm font-bold font-mono tracking-tight h-auto py-1 w-40" - /> - - -
- ) : ( -
- - {formatAmount(account.balance, account.currency)} - - - {isLiability && account.next_payment != null && ( - - Next: {formatAmount(account.next_payment, account.currency)} - - )} -
- )} -
- ); -} - -// --- Dashboard --- - -export default function Dashboard() { - const [accounts, setAccounts] = useState([]); - const [recent, setRecent] = useState([]); - const [loading, setLoading] = useState(true); - const [editingId, setEditingId] = useState(null); - const [editValue, setEditValue] = useState(''); - const [exchangeRate, setExchangeRate] = useState<{ buy_rate: number; sell_rate: number } | null>(null); - const [configSection, setConfigSection] = useState(null); - const [testingPush, setTestingPush] = useState(false); - - const { settings, patchSection } = useSettings(); - - const fetchData = async () => { - setLoading(true); - try { - const [accRes, txRes] = await Promise.all([ - api.get('/accounts/'), - api.get('/transactions/recent?limit=5'), - ]); - setAccounts(accRes.data); - setRecent(txRes.data); - } catch (e) { - console.error(e); - } finally { - setLoading(false); - } - api.get('/exchange-rate/').then((r) => setExchangeRate(r.data)).catch(() => {}); - }; - - useEffect(() => { fetchData(); }, []); - - const startEditing = (account: Account) => { - setEditingId(account.id); - setEditValue(String(account.balance)); - }; - const cancelEditing = () => { setEditingId(null); setEditValue(''); }; - const saveBalance = async (accountId: number) => { - const parsed = parseFloat(editValue); - if (isNaN(parsed)) return cancelEditing(); - try { - await api.patch(`/accounts/${accountId}`, { balance: parsed }); - setEditingId(null); - setEditValue(''); - fetchData(); - } catch (e) { console.error(e); } - }; - - const rowProps = { editingId, editValue, setEditValue, startEditing, saveBalance, cancelEditing }; - - // Sort sections by order, filter by visible - const sortedSections = useMemo(() => { - const sections = settings.dashboard.sections; - return Object.entries(sections) - .filter(([, s]) => s.visible) - .sort(([, a], [, b]) => a.order - b.order); - }, [settings]); - - // Net worth calculation - const netWorthBreakdown = useMemo(() => { - if (accounts.length === 0) return null; - let assets = 0; - let liabilities = 0; - for (const a of accounts) { - const isLiability = a.account_type === 'LIABILITY'; - let crcValue = 0; - if (a.currency === 'USD') { - crcValue = Math.abs(a.balance) * (exchangeRate?.sell_rate ?? 0); - } else if (a.currency === 'CRC') { - crcValue = Math.abs(a.balance); - } - if (isLiability) { - liabilities += crcValue; - } else { - assets += crcValue; - } - } - return { assets, liabilities, net: assets - liabilities }; - }, [accounts, exchangeRate]); - - return ( -
- {/* Header */} -
-
-

Dashboard

-

Financial overview

-
- -
- - {/* Net Worth */} - {netWorthBreakdown != null && ( - - -
- Net {netWorthBreakdown.net < 0 ? '-' : ''}{formatAmount(netWorthBreakdown.net, 'CRC')} -
- Assets {formatAmount(netWorthBreakdown.assets, 'CRC')} - Liabilities {formatAmount(netWorthBreakdown.liabilities, 'CRC')} -
-
-
-
- )} - - {/* Account sections */} - {sortedSections.map(([sectionId, sectionSettings]) => { - const def = SECTION_DEFS[sectionId]; - if (!def) return null; - let accts = accounts.filter(def.filterFn); - if (accts.length === 0) return null; - - // Sort bank accounts by bank order - if (sectionId === 'crc_accounts' || sectionId === 'usd_accounts') { - accts = accts.sort((a, b) => BANK_ORDER.indexOf(a.bank) - BANK_ORDER.indexOf(b.bank)); - } - - const total = accts.reduce((s, a) => s + a.balance, 0); - - return ( - patchSection(sectionId, { expanded })} - onOpenConfig={() => setConfigSection(sectionId)} - > - {accts.map((a) => ( - - ))} - - ); - })} - - {/* Exchange rate */} - {exchangeRate && ( - - - USD/CRC Exchange Rate -
- Buy: ₡{exchangeRate.buy_rate.toFixed(2)} - Sell: ₡{exchangeRate.sell_rate.toFixed(2)} -
-
-
- )} - - {/* Recent transactions */} - - -
- - Recent Charges -
- - View all - - -
- - {recent.length === 0 && !loading ? ( -
No transactions yet. Add your first one!
- ) : ( -
- {recent.map((tx) => ( -
-
-
- {tx.transaction_type === 'DEPOSITO' ? : tx.transaction_type === 'DEVOLUCION' ? : } -
-
-

{tx.merchant}

-

- {formatDate(tx.date)} - {tx.category && {tx.category.name}} -

-
-
- - {tx.transaction_type === 'COMPRA' ? '-' : '+'}{formatAmount(tx.amount, tx.currency)} - -
- ))} -
- )} -
-
- - {/* Test push notification */} - - -
-

Test Push Notification

-

Creates a mock transaction to trigger a push notification

-
- -
-
- - {/* Section config dialog */} - {configSection && settings.dashboard.sections[configSection] && ( - { if (!open) setConfigSection(null); }} - onSave={(id, partial) => patchSection(id, partial)} - /> - )} -
- ); -} diff --git a/frontend/src/pages/Transactions.tsx b/frontend/src/pages/Transactions.tsx deleted file mode 100644 index 0f40b3f..0000000 --- a/frontend/src/pages/Transactions.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useEffect, useState, useCallback } from 'react'; -import { Plus, ClipboardPaste } from 'lucide-react'; - -import api, { type Transaction, type Category } from '../api'; -import PasteImportModal from '../components/PasteImportModal'; -import BillingCycleSelector from '../components/BillingCycleSelector'; -import TransactionList from '../components/TransactionList'; -import { Button } from '@/components/ui/button'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; - -function formatAmount(amount: number, currency: string) { - const abs = Math.abs(amount); - if (currency === 'USD') { - return `$${abs.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; - } - return `₡${abs.toLocaleString('es-CR', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; -} - -export default function Transactions() { - const [transactions, setTransactions] = useState([]); - const [categories, setCategories] = useState([]); - const [search, setSearch] = useState(''); - const [categoryFilter, setCategoryFilter] = useState(''); - const [loading, setLoading] = useState(true); - const [importOpen, setImportOpen] = useState(false); - const [cycle, setCycle] = useState<{ year: number; month: number } | null>(null); - - const fetchTransactions = useCallback(async () => { - setLoading(true); - try { - const params: Record = { source: 'CREDIT_CARD', limit: '200' }; - if (search) params.search = search; - if (categoryFilter) params.category_id = categoryFilter; - if (cycle) { - params.cycle_year = String(cycle.year); - params.cycle_month = String(cycle.month); - } - const { data } = await api.get('/transactions/', { params }); - setTransactions(data); - } finally { - setLoading(false); - } - }, [search, categoryFilter, cycle]); - - useEffect(() => { - api.get('/categories/').then((r) => setCategories(r.data)); - }, []); - - useEffect(() => { - const timer = setTimeout(fetchTransactions, 300); - return () => clearTimeout(timer); - }, [fetchTransactions]); - - const totalCRC = transactions - .filter((tx) => tx.currency === 'CRC') - .reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0); - const totalUSD = transactions - .filter((tx) => tx.currency === 'USD') - .reduce((sum, tx) => sum + (tx.transaction_type === 'DEVOLUCION' ? -tx.amount : tx.amount), 0); - - return ( -
-
-
-

Credit Card Transactions

-

- {transactions.length} transactions - {totalCRC !== 0 && ( - <> · {formatAmount(totalCRC, 'CRC')} - )} - {totalUSD !== 0 && ( - <> · {formatAmount(totalUSD, 'USD')} - )} -

-
-
- -
-
- - {/* Billing cycle */} - - - {/* Category filter */} -
- -
- - - - {importOpen && ( - setImportOpen(false)} - onImported={fetchTransactions} - /> - )} -
- ); -} diff --git a/frontend/src/pages/Transfers.tsx b/frontend/src/pages/Transfers.tsx deleted file mode 100644 index b2cc57e..0000000 --- a/frontend/src/pages/Transfers.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useEffect, useState, useCallback } from 'react'; -import { ArrowLeftRight } from 'lucide-react'; - -import api, { type Transaction } from '../api'; -import TransactionList from '../components/TransactionList'; -import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; - -type SourceTab = 'CASH' | 'TRANSFER'; - -export default function Transfers() { - const [transactions, setTransactions] = useState([]); - const [search, setSearch] = useState(''); - const [sourceTab, setSourceTab] = useState('CASH'); - const [loading, setLoading] = useState(true); - - const fetchTransactions = useCallback(async () => { - setLoading(true); - try { - const params: Record = { source: sourceTab, limit: '200' }; - if (search) params.search = search; - const { data } = await api.get('/transactions/', { params }); - setTransactions(data); - } finally { - setLoading(false); - } - }, [search, sourceTab]); - - useEffect(() => { - const timer = setTimeout(fetchTransactions, 300); - return () => clearTimeout(timer); - }, [fetchTransactions]); - - return ( -
-
-

Cash & Transfers

-

- Track non-credit-card expenses -

-
- - setSourceTab(v as SourceTab)}> - - Cash - Transfers - - - - } - emptyMessage={`No ${sourceTab.toLowerCase()} transactions yet`} - /> - - -
- ); -}