From 9808a1f39b7e496ac6e67aa4bfee8e331436f334 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Mon, 17 Aug 2026 15:14:57 +0530 Subject: [PATCH 01/10] design overhaul + expenses page - Stripe-style theme: single indigo (#635BFF) brand, 6px radius, Inter scale; rewritten themePrimitives + MUI customizations (solid primary, near-black secondary, severity-aware Alert) - Sticky Header (brand-left nav, theme toggle, mobile drawer), inline Footer, route fade-in, per-route document.title - Home hero + feature cards (dead /dashboard,/reports links replaced) - Split-panel AuthPage with validation and password visibility - Data pages: PageHeader/EmptyState, breadcrumbed Fetch Request pages, admin table polish (numeric alignment, row-hover actions, skeletons, empty states), single-accent admin SideMenu - Global ToastProvider wired into app shell - New /expenses page: month-grouped feed, single-open accordion with inline detail (account, tags, amount, timestamps) --- index.html | 8 +- react-auth/AuthPage.tsx | 280 +++++++++----- .../src/components/ResourceDetail.tsx | 13 +- react-openapi/src/components/ResourceList.tsx | 123 ++++-- react-openapi/src/components/SideMenu.tsx | 15 +- src/Expense/Expense.tsx | 140 +++++++ src/Expense/ExpenseDetail.tsx | 85 +++++ src/Expense/ExpenseList.tsx | 173 +++++++++ src/Expense/types.ts | 61 +++ src/FetchRequest/FetchRequestCreate.tsx | 60 ++- src/FetchRequest/FetchRequestDetail.tsx | 114 +++--- src/Footer.tsx | 64 ++-- src/Header.tsx | 357 ++++++++++++------ src/Home.tsx | 236 +++++------- src/main.jsx | 64 +++- src/shared-theme/customizations/feedback.tsx | 15 +- src/shared-theme/customizations/inputs.tsx | 49 +-- src/shared-theme/customizations/surfaces.ts | 2 +- src/shared-theme/themePrimitives.ts | 322 +++++----------- src/ui/EmptyState.tsx | 57 +++ src/ui/PageHeader.tsx | 67 ++++ src/ui/Toast.tsx | 52 +++ 22 files changed, 1567 insertions(+), 790 deletions(-) create mode 100644 src/Expense/Expense.tsx create mode 100644 src/Expense/ExpenseDetail.tsx create mode 100644 src/Expense/ExpenseList.tsx create mode 100644 src/Expense/types.ts create mode 100644 src/ui/EmptyState.tsx create mode 100644 src/ui/PageHeader.tsx create mode 100644 src/ui/Toast.tsx diff --git a/index.html b/index.html index 256428e..bb742b8 100644 --- a/index.html +++ b/index.html @@ -3,17 +3,19 @@ + + - khata - Aetoskia + Khata — Financial Ledger
- + \ No newline at end of file diff --git a/react-auth/AuthPage.tsx b/react-auth/AuthPage.tsx index c426240..8ad297d 100644 --- a/react-auth/AuthPage.tsx +++ b/react-auth/AuthPage.tsx @@ -1,6 +1,20 @@ import * as React from 'react'; -import { Box, TextField, Button, Typography, IconButton, CircularProgress, Link } from '@mui/material'; +import { + Box, + TextField, + Button, + Typography, + IconButton, + CircularProgress, + Link, + InputAdornment, + alpha, +} from '@mui/material'; +import VisibilityIcon from '@mui/icons-material/Visibility'; +import VisibilityOffIcon from '@mui/icons-material/VisibilityOff'; +import CheckRoundedIcon from '@mui/icons-material/CheckRounded'; import ArrowBackRoundedIcon from '@mui/icons-material/ArrowBackRounded'; +import { useTheme } from '@mui/material/styles'; export type AuthMode = "login" | "register"; @@ -15,6 +29,12 @@ export interface AuthPageProps { currentUser: any; } +const FEATURES = [ + "OpenAPI-driven admin for accounts, expenses, tags & payors", + "Import bank statements with live pipeline progress", + "Entity & tag enrichment for clean, comparable data", +]; + export function AuthPage({ mode, onBack, @@ -25,114 +45,200 @@ export function AuthPage({ error, currentUser, }: AuthPageProps) { - + const theme = useTheme(); const [username, setUsername] = React.useState(''); const [password, setPassword] = React.useState(''); + const [showPassword, setShowPassword] = React.useState(false); + const [fieldErrors, setFieldErrors] = React.useState<{ username?: string; password?: string }>({}); const isLogin = mode === "login"; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - if (isLogin) { - await login(username, password); - } else { - await register(username, password); - } - }; - - // ✅ Auto-return if already logged in React.useEffect(() => { if (currentUser) onBack(); }, [currentUser, onBack]); - return ( - - - + const validate = (): boolean => { + const next: { username?: string; password?: string } = {}; + if (!username.trim()) next.username = "Username is required"; + else if (username.trim().length < 3) next.username = "Username must be at least 3 characters"; + if (!password) next.password = "Password is required"; + else if (password.length < 6) next.password = "Password must be at least 6 characters"; + setFieldErrors(next); + return Object.keys(next).length === 0; + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!validate()) return; + if (isLogin) { + await login(username.trim(), password); + } else { + await register(username.trim(), password); + } + }; + + const handleSwitch = () => { + setFieldErrors({}); + onSwitchMode(); + }; + + const form = ( + + + - - {isLogin ? "Sign In" : "Create Account"} + + {isLogin ? "Sign in to Khata" : "Create your account"} - + {isLogin - ? "Please log in to continue" - : "Create an account to get started"} + ? "Welcome back. Enter your credentials to continue." + : "Start managing your ledger in a few seconds."} -
- setUsername(e.target.value)} - required - autoFocus - /> - setPassword(e.target.value)} - required - /> + + + { + setUsername(e.target.value); + if (fieldErrors.username) setFieldErrors((p) => ({ ...p, username: undefined })); + }} + error={!!fieldErrors.username} + helperText={fieldErrors.username} + autoComplete="username" + autoFocus + /> + { + setPassword(e.target.value); + if (fieldErrors.password) setFieldErrors((p) => ({ ...p, password: undefined })); + }} + error={!!fieldErrors.password} + helperText={fieldErrors.password} + autoComplete={isLogin ? 'current-password' : 'new-password'} + InputProps={{ + endAdornment: ( + + setShowPassword((s) => !s)} + edge="end" + size="small" + sx={{ border: 'none' }} + > + {showPassword ? : } + + + ), + }} + /> - {error && ( - - {error} - - )} - - + + + - - {isLogin ? "Don’t have an account?" : "Already have an account?"}{' '} - - {isLogin ? "Register" : "Login"} + + {isLogin ? "Don't have an account?" : "Already have an account?"}{' '} + + {isLogin ? "Register" : "Sign In"}
); + + return ( + + + `0 12px 40px ${alpha(t.palette.common.black, t.palette.mode === 'dark' ? 0.4 : 0.08)}`, + }} + > + {/* LEFT BRAND PANEL */} + + + + Khata + + + The intelligent, extensible financial ledger for your cashflow. + + + + + {FEATURES.map((f) => ( + + + + {f} + + + ))} + + + + {/* RIGHT FORM PANEL */} + + {form} + + + + ); } diff --git a/react-openapi/src/components/ResourceDetail.tsx b/react-openapi/src/components/ResourceDetail.tsx index 7e6567f..d5347bb 100644 --- a/react-openapi/src/components/ResourceDetail.tsx +++ b/react-openapi/src/components/ResourceDetail.tsx @@ -58,9 +58,14 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) { if (!data) { return ( - - Record not found - + + + Record not found + + + ); } @@ -82,7 +87,7 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) { - + {applyDisplayFormat( Object.fromEntries( resource.orderedFields.map((field) => { diff --git a/react-openapi/src/components/ResourceList.tsx b/react-openapi/src/components/ResourceList.tsx index 4417546..763540a 100644 --- a/react-openapi/src/components/ResourceList.tsx +++ b/react-openapi/src/components/ResourceList.tsx @@ -20,11 +20,13 @@ import { DialogContent, DialogActions, Grid, + Skeleton, } from "@mui/material"; import AddIcon from "@mui/icons-material/Add"; import EditIcon from "@mui/icons-material/Edit"; import DeleteIcon from "@mui/icons-material/Delete"; import VisibilityIcon from "@mui/icons-material/Visibility"; +import StorageIcon from "@mui/icons-material/Storage"; import type { ResourceConfig, FieldConfig } from "../types"; import { useResource } from "../context/useResource"; import { useAppContext } from "../context/AppContext"; @@ -38,6 +40,10 @@ interface ResourceListProps { basePath: string; } +function isNumericColumn(col: FieldConfig): boolean { + return col.type === "integer" || col.type === "number"; +} + function matchRow(row: any, filters: Record, fields: FieldConfig[], allResources: ResourceConfig[]): boolean { for (const field of fields) { if (!field.filterable) continue; @@ -265,12 +271,16 @@ export function ResourceList({ resource, basePath }: ResourceListProps) { /> )} - + {visibleColumns.map((col) => ( - + {col.sortable ? ( ))} - {hasActions && Actions} + {hasActions && Actions} - {displayData.length === 0 ? ( + {crud.loading && displayData.length === 0 ? ( + Array.from({ length: 6 }).map((_, i) => ( + + {visibleColumns.map((col) => ( + + + + ))} + {hasActions && ( + + + + )} + + )) + ) : displayData.length === 0 ? ( - - {isStreaming ? "Waiting for events\u2026" : "No records found"} - + + + + {isStreaming ? "Waiting for events…" : "No records found"} + + {!isStreaming && resource.operations.create && ( + + )} + ) : ( @@ -303,7 +342,7 @@ export function ResourceList({ resource, basePath }: ResourceListProps) { { if (isStreaming) { setDetailRow(row); @@ -322,34 +361,56 @@ export function ResourceList({ resource, basePath }: ResourceListProps) { fmt = col.inlineDisplayFormat; } return ( - + ); })} {hasActions && ( - e.stopPropagation()}> - {resource.operations.get && !isStreaming && ( - - navigate(`${basePath}/${resource.name}/${rowId}`)}> - - - - )} - {resource.operations.update && ( - - navigate(`${basePath}/${resource.name}/${rowId}/edit`)}> - - - - )} - {resource.operations.delete && ( - - handleDelete(rowId)} color="error"> - - - - )} + e.stopPropagation()} + > + + {resource.operations.get && !isStreaming && ( + + navigate(`${basePath}/${resource.name}/${rowId}`)}> + + + + )} + {resource.operations.update && ( + + navigate(`${basePath}/${resource.name}/${rowId}/edit`)}> + + + + )} + {resource.operations.delete && ( + + handleDelete(rowId)} color="error"> + + + + )} + )} diff --git a/react-openapi/src/components/SideMenu.tsx b/react-openapi/src/components/SideMenu.tsx index cb9918c..2a7ed45 100644 --- a/react-openapi/src/components/SideMenu.tsx +++ b/react-openapi/src/components/SideMenu.tsx @@ -30,11 +30,6 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("md")); - const colors = [ - "#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6", - "#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16", - ]; - const content = ( @@ -43,7 +38,7 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP - {resources.map((r, i) => { + {resources.map((r) => { const listPath = `${basePath}/${r.name}`; const active = location.pathname.startsWith(listPath); return ( @@ -58,13 +53,15 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP borderRadius: 2, mb: 0.5, "&.Mui-selected": { - bgcolor: `${colors[i % colors.length]}15`, - "&:hover": { bgcolor: `${colors[i % colors.length]}20` }, + bgcolor: "primary.main", + color: "primary.contrastText", + "&:hover": { bgcolor: "primary.main" }, + "& .MuiListItemIcon-root": { color: "primary.contrastText" }, }, }} > - + + + {label} + + + {value} + + {hint && ( + + {hint} + + )} + + ); +} + +export default function Expense() { + const navigate = useNavigate(); + const { list, loading, error } = useResource("expenses"); + const [items, setItems] = useState(null); + + useEffect(() => { + let mounted = true; + list({ limit: 0 }).then((res) => { + if (mounted) setItems(res.items as ExpenseItem[]); + }); + return () => { + mounted = false; + }; + }, []); + + const sorted = useMemo( + () => + (items ?? []).sort( + (a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(), + ), + [items], + ); + + const summary = useMemo(() => { + const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; + const totalSpent = sorted.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0); + const totalIncome = sorted.filter((it) => !isExpense(it)).reduce((s, it) => s + (it.amount ?? 0), 0); + const now = new Date(); + const thisMonth = monthKey(now.toISOString()); + const monthItems = sorted.filter((it) => monthKey(it.occurred_at) === thisMonth); + const monthTotal = monthItems.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0); + return { currency, totalSpent, totalIncome, thisMonth: monthLabel(thisMonth), monthItems, monthTotal }; + }, [sorted]); + + const loadingUI = ( + + {[0, 1, 2].map((i) => ( + + + + {[0, 1, 2].map((j) => ( + + ))} + + + ))} + + ); + + return ( + + navigate("/admin/expenses/new")} + > + New Expense + + } + /> + + {error && ( + + Failed to load expenses: {error} + + )} + + {items === null && !error ? ( + loadingUI + ) : items && items.length === 0 ? ( + + } + title="No expenses yet" + description="Once you run a fetch request, imported transactions will show up here grouped by month." + actionLabel="Go to Fetch Requests" + onAction={() => navigate("/fetch-requests")} + /> + + ) : ( + <> + + + + + + + + + )} + + ); +} diff --git a/src/Expense/ExpenseDetail.tsx b/src/Expense/ExpenseDetail.tsx new file mode 100644 index 0000000..f851f9c --- /dev/null +++ b/src/Expense/ExpenseDetail.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { Box, Typography, Chip, Divider } from "@mui/material"; +import type { ExpenseItem } from "./types"; +import { formatCurrency, formatDate, formatDateTime } from "./types"; + +function Row({ label, value }: { label: string; value: React.ReactNode }) { + return ( + + + {label} + + {value} + + ); +} + +export function ExpenseDetail({ item }: { item: ExpenseItem }) { + const account = item.account; + const tags = item.tags ?? []; + const last4 = account?.number ? `…${account.number.slice(-4)}` : ""; + + return ( + + + + {formatCurrency(item.amount, account?.currency)} + + } + /> + + {account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"} + {account?.type && ( + + )} + + } + /> + 0 ? ( + + {tags.map((tag, i) => ( + + ))} + + ) : ( + + No tags + + ) + } + /> + {formatDate(item.occurred_at)}} /> + + {item.id} + + } + /> + {formatDateTime(item.created_at)}} + /> + + ); +} diff --git a/src/Expense/ExpenseList.tsx b/src/Expense/ExpenseList.tsx new file mode 100644 index 0000000..419f8f4 --- /dev/null +++ b/src/Expense/ExpenseList.tsx @@ -0,0 +1,173 @@ +import React, { useState } from "react"; +import { + Box, + Paper, + Typography, + Accordion, + AccordionSummary, + AccordionDetails, + Avatar, + Chip, + useTheme, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import type { ExpenseItem } from "./types"; +import { formatCurrency, formatDate, isExpense, monthKey, monthLabel } from "./types"; +import { ExpenseDetail } from "./ExpenseDetail"; + +interface GroupedMonth { + key: string; + items: ExpenseItem[]; + total: number; + currency: string; +} + +function groupByMonth(items: ExpenseItem[]): GroupedMonth[] { + const map = new Map(); + for (const item of items) { + const key = monthKey(item.occurred_at); + const list = map.get(key) ?? []; + list.push(item); + map.set(key, list); + } + return [...map.entries()] + .map(([key, list]) => { + const sorted = [...list].sort( + (a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(), + ); + const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; + const total = sorted.reduce((sum, it) => sum + (it.amount ?? 0), 0); + return { key, items: sorted, total, currency }; + }) + .sort((a, b) => b.key.localeCompare(a.key)); +} + +function EntityAvatar({ entity }: { entity: ExpenseItem["entity"] }) { + const theme = useTheme(); + const name = entity?.name ?? "?"; + const letter = name.trim().charAt(0).toUpperCase() || "?"; + const logo = entity?.logo; + const isImage = typeof logo === "string" && (logo.startsWith("http") || logo.startsWith("data:")); + + return ( + + {letter} + + ); +} + +export function ExpenseList({ items }: { items: ExpenseItem[] }) { + const [expandedId, setExpandedId] = useState(null); + const groups = groupByMonth(items); + + return ( + + {groups.map((group) => ( + + + + {monthLabel(group.key)} + + + {group.items.length} transaction{group.items.length === 1 ? "" : "s"} + + + + {formatCurrency(group.total, group.currency)} + + + + + {group.items.map((item) => { + const negative = isExpense(item); + const currency = item.account?.currency ?? group.currency; + return ( + setExpandedId(expanded ? item.id : null)} + sx={{ + border: "1px solid", + borderColor: expandedId === item.id ? "primary.main" : "divider", + borderRadius: 2, + overflow: "hidden", + boxShadow: "none", + "&:before": { display: "none" }, + transition: "border-color 160ms ease, background-color 160ms ease", + "&:hover": { borderColor: "primary.light" }, + }} + > + } + sx={{ + "& .MuiAccordionSummary-content": { + alignItems: "center", + gap: 2, + minWidth: 0, + py: 0.5, + }, + }} + > + + + + {item.entity?.name ?? "Unknown"} + + + + {formatDate(item.occurred_at)} + + {item.account?.name && ( + + )} + + + + {formatCurrency(item.amount, currency)} + + + + + + + ); + })} + + + ))} + + ); +} + +export { groupByMonth }; diff --git a/src/Expense/types.ts b/src/Expense/types.ts new file mode 100644 index 0000000..c6eeb93 --- /dev/null +++ b/src/Expense/types.ts @@ -0,0 +1,61 @@ +export interface ExpenseItem { + id: string; + entity?: { name?: string; type?: string; logo?: string } | null; + amount: number; + account?: { name?: string; number?: string; type?: string; currency?: string } | null; + tags?: { icon?: string; name?: string }[]; + occurred_at?: string; + created_at?: string; + updated_at?: string; +} + +export function isExpense(item: ExpenseItem): boolean { + return (item.amount ?? 0) < 0; +} + +export function formatCurrency(amount: number, currency?: string): string { + const code = currency && ["INR", "USD", "EUR", "GBP", "AED", "SGD"].includes(currency) ? currency : "INR"; + try { + return new Intl.NumberFormat("en-IN", { + style: "currency", + currency: code, + maximumFractionDigits: 2, + }).format(amount); + } catch { + return `₹${amount.toFixed(2)}`; + } +} + +export function formatDate(iso?: string): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }); +} + +export function formatDateTime(iso?: string): string { + if (!iso) return "—"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "—"; + return d.toLocaleString("en-IN", { + day: "numeric", + month: "short", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function monthKey(iso?: string): string { + if (!iso) return "unknown"; + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return "unknown"; + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +} + +export function monthLabel(key: string): string { + const [y, m] = key.split("-").map(Number); + if (!y || !m) return key; + const d = new Date(y, m - 1, 1); + return d.toLocaleDateString("en-IN", { month: "long", year: "numeric" }); +} diff --git a/src/FetchRequest/FetchRequestCreate.tsx b/src/FetchRequest/FetchRequestCreate.tsx index f721126..ababafa 100644 --- a/src/FetchRequest/FetchRequestCreate.tsx +++ b/src/FetchRequest/FetchRequestCreate.tsx @@ -4,8 +4,11 @@ import { CircularProgress, } from "@mui/material"; import { useNavigate } from "react-router-dom"; +import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; import { useAppContext, useResource, ListCellRenderer, FormFieldRenderer, getApi, applyDisplayFormat } from "../../react-openapi"; import type { FieldConfig } from "../../react-openapi"; +import { PageHeader } from "../ui/PageHeader"; +import { EmptyState } from "../ui/EmptyState"; const CREATE_FIELDS = ["account", "bank", "pipeline", "start_date", "end_date", "source"]; @@ -29,25 +32,39 @@ function FetchRequestList() { if (!rows) { return ( - - + + ); } if (rows.length === 0) { - return No fetch requests found.; + return ( + } + title="No fetch requests yet" + description="Upload a bank statement or configure email ingestion to import your first transactions." + actionLabel="New Fetch Request" + onAction={() => document.getElementById("new-fetch-request")?.scrollIntoView({ behavior: "smooth", block: "center" })} + /> + ); } return ( - + {rows.map((row, i) => { const displayFormat = resource?.displayFormat ?? ""; return ( navigate(`/fetch-requests/${row.id}`)} > @@ -145,18 +162,25 @@ export default function FetchRequestCreate() { return ( - - Fetch Requests - + - - - - New Fetch Request - - + + + New Fetch Request + + + Choose an account, pipeline, and a file or email source to kick off an import. + - + {formFields.map((field) => ( - + - {result && ( - setResult(null)}> + setResult(null)}> {result.message} )} diff --git a/src/FetchRequest/FetchRequestDetail.tsx b/src/FetchRequest/FetchRequestDetail.tsx index e246357..b05d22d 100644 --- a/src/FetchRequest/FetchRequestDetail.tsx +++ b/src/FetchRequest/FetchRequestDetail.tsx @@ -9,9 +9,8 @@ import { Chip, CircularProgress, Alert, - Snackbar, + Divider, } from "@mui/material"; -import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import ReplayIcon from "@mui/icons-material/Replay"; import CheckCircleIcon from "@mui/icons-material/CheckCircle"; import ErrorIcon from "@mui/icons-material/Error"; @@ -25,6 +24,8 @@ import { RETRY_MAX, formatApiError } from "../features/fetch-requests"; import type { FetchRequestStatus, SSEEvent, ProgressMessage } from "../features/fetch-requests"; import { PipelineStepper } from "./components/PipelineStepper"; import { AmbiguityResolver } from "./components/AmbiguityResolver"; +import { PageHeader } from "../ui/PageHeader"; +import { useToast } from "../ui/Toast"; const statusColors: Record = { pending: "default", @@ -67,6 +68,20 @@ function sseIcon(status: SSEEvent["status"]) { } } +function Section({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) { + return ( + + + + {title} + + {action} + + {children} + + ); +} + export default function FetchRequestDetail() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -75,7 +90,7 @@ export default function FetchRequestDetail() { const [stepStats, setStepStats] = useState>({}); const [liveParsedCount, setLiveParsedCount] = useState(undefined); const [retrying, setRetrying] = useState(false); - const [failNotif, setFailNotif] = useState(null); + const { showToast } = useToast(); const feedRef = useRef(null); const { data: fetchRequest, isLoading, error: fetchError, refetch: refetchRequest } = useQuery({ @@ -109,7 +124,7 @@ export default function FetchRequestDetail() { refetchRequest(); } if (parsed.status === "failed") { - setFailNotif(parsed.message.error || "Fetch request failed"); + showToast(parsed.message.error || "Fetch request failed", "error"); refetchRequest(); } if (parsed.status === "completed" || parsed.step === "resume_extract") { @@ -153,7 +168,7 @@ export default function FetchRequestDetail() { await patch(id, { status: "pending" }); refetchRequest(); } catch (err: any) { - setFailNotif(formatApiError(err)); + showToast(formatApiError(err), "error"); } finally { setRetrying(false); } @@ -162,7 +177,7 @@ export default function FetchRequestDetail() { const req = fetchRequest as any; const retryCount = req?.retry_count ?? 0; const isRetryExhausted = retryCount >= RETRY_MAX; - const status = req?.status as FetchRequestStatus | undefined; + const status = (req?.status ?? "pending") as FetchRequestStatus; const detailFields = (resource?.orderedFields ?? []).filter( (f) => f.name !== "source", ); @@ -193,34 +208,44 @@ export default function FetchRequestDetail() { if (fetchError || !fetchRequest) { return ( - + Failed to load fetch request ); } return ( - - + + - - +
- {displayTitle} + } + > + + + ID: {req.id} + @@ -242,6 +267,8 @@ export default function FetchRequestDetail() { })} + + @@ -260,7 +287,7 @@ export default function FetchRequestDetail() { )} - +
{status === "failed" && req.error_message && ( @@ -280,33 +307,33 @@ export default function FetchRequestDetail() { liveParsedCount={liveParsedCount ?? 0} /> - - - - Progress Events - - - - {sseConnected ? "Connected" : "Disconnected"} - - +
+ + + {sseConnected ? "Connected" : "Disconnected"} + + + } + > {displayEvents.length === 0 ? ( @@ -321,7 +348,7 @@ export default function FetchRequestDetail() { display: "flex", alignItems: "center", gap: 1.5, - p: 1, + p: 1.25, borderRadius: 2, bgcolor: "action.hover", }} @@ -341,20 +368,9 @@ export default function FetchRequestDetail() { )) )} - +
- - setFailNotif(null)} - anchorOrigin={{ vertical: "bottom", horizontal: "center" }} - > - setFailNotif(null)} sx={{ borderRadius: 2 }}> - {failNotif} - -
); } diff --git a/src/Footer.tsx b/src/Footer.tsx index e8ad23f..0f27dda 100644 --- a/src/Footer.tsx +++ b/src/Footer.tsx @@ -1,52 +1,36 @@ import * as React from 'react'; +import Box from '@mui/material/Box'; import Container from '@mui/material/Container'; -import Link from '@mui/material/Link'; import Typography from '@mui/material/Typography'; -import {AppBar, Box, Button, IconButton, Toolbar, Tooltip} from "@mui/material"; -import MenuIcon from "@mui/icons-material/Menu"; -import LogoutIcon from "@mui/icons-material/Logout"; - -function Copyright() { - return ( - - - {'Copyright © Aetoskia Internal Infrastructure — All rights reserved.'} - -   - {new Date().getFullYear()} - - ); -} export default function Footer() { return ( - - - - - - - - - + + + Khata + + + © {new Date().getFullYear()} Aetoskia Internal Infrastructure — All rights reserved. + + +
); -} +} \ No newline at end of file diff --git a/src/Header.tsx b/src/Header.tsx index 78e266b..ff15ae9 100644 --- a/src/Header.tsx +++ b/src/Header.tsx @@ -1,8 +1,5 @@ import * as React from "react"; -import { - useLocation, - matchPath -} from "react-router-dom"; +import { useLocation, matchPath, useNavigate } from "react-router-dom"; import { AppBar, Toolbar, @@ -11,6 +8,11 @@ import { Tooltip, Button, Box, + Drawer, + List, + ListItemButton, + ListItemText, + Divider, useMediaQuery, useTheme, } from "@mui/material"; @@ -18,7 +20,6 @@ import MenuIcon from "@mui/icons-material/Menu"; import LogoutIcon from "@mui/icons-material/Logout"; import DarkModeIcon from "@mui/icons-material/DarkMode"; import LightModeIcon from "@mui/icons-material/LightMode"; -import { useNavigate } from "react-router-dom"; import { useAuth } from "../react-auth"; import { ColorModeContext } from "./shared-theme/AppTheme"; @@ -30,134 +31,260 @@ interface HeaderProps { onDrawerToggle?: () => void; } -export default function Header({ - routerMapping, - onDrawerToggle, -}: HeaderProps) { - const location = useLocation(); - const matchedRoute = routerMapping.find((route) => - matchPath({ path: route.path, end: false }, location.pathname) - ); - const headerTitle = matchedRoute?.headerTitle ?? "Khata"; +const NAV_LINKS = [ + { label: "Home", path: "/" }, + { label: "Expenses", path: "/expenses" }, + { label: "Fetch Requests", path: "/fetch-requests" }, +]; +function isActive(path: string, locationPath: string): boolean { + if (path === "/") return locationPath === "/" || locationPath === "/home"; + return matchPath({ path, end: false }, locationPath) !== null; +} + +export default function Header({ routerMapping, onDrawerToggle }: HeaderProps) { + const location = useLocation(); const navigate = useNavigate(); const { currentUser, logout } = useAuth(); const { mode, toggleColorMode } = React.useContext(ColorModeContext); const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const [mobileOpen, setMobileOpen] = React.useState(false); const isAuthenticated = !!currentUser; - return ( - theme.zIndex.drawer + 1, - backdropFilter: "blur(8px)", - boxShadow: "none", - borderBottom: "1px solid", - borderColor: "divider", + display: { xs: "none", md: "flex" }, + alignItems: "center", + gap: 0.5, + ml: 3, }} > - - {/* MOBILE MENU BUTTON */} - {isMobile && onDrawerToggle && ( - { + const active = isActive(link.path, location.pathname); + return ( + + ); + })} +
+ ); - {/* THEME TOGGLE */} - - {mode === 'dark' ? : } - - - {/* TITLE */} - navigate("/")} - > - {headerTitle} - - - - - {/* NAV LINKS */} - - {[ - { label: "Fetch", path: "/fetch-requests" }, - ].map(({ label, path }) => ( - - ))} - - - {/* AUTH SECTION */} - {isAuthenticated ? ( - <> - - - - - - - - - - - - ) : ( + const authSection = ( + + {isAuthenticated ? ( + <> - )} - - + + + + + + + + ) : ( + + )} + ); -} \ No newline at end of file + + const drawerContent = ( + + + + + + + {routerMapping + .filter((r) => !r.path.startsWith("/login") && !r.path.startsWith("/register") && !r.path.startsWith("/profile")) + .map((route) => { + const active = isActive(route.path, location.pathname); + return ( + { + navigate(route.path); + setMobileOpen(false); + }} + sx={{ + borderRadius: "6px", + mx: 1, + mb: 0.5, + "&.Mui-selected": { + backgroundColor: "action.selected", + }, + }} + > + + + ); + })} + + {isAuthenticated ? ( + <> + navigate("/profile/me")} sx={{ borderRadius: "6px", mx: 1 }}> + + + + + + + ) : ( + navigate("/login")} sx={{ borderRadius: "6px", mx: 1 }}> + + + )} + + + ); + + return ( + <> + theme.zIndex.drawer + 1, + backdropFilter: "blur(8px)", + boxShadow: "none", + borderBottom: "1px solid", + borderColor: "divider", + backgroundColor: "background.default", + }} + > + + {/* MOBILE MENU */} + {isMobile && ( + setMobileOpen(true)} + aria-label="Open navigation" + sx={{ mr: 1 }} + > + + + )} + + {/* BRAND — always leftmost */} + navigate("/")} /> + + {navContent} + + + + {/* THEME TOGGLE — right side */} + + {mode === "dark" ? : } + + + {authSection} + + + + setMobileOpen(false)} + ModalProps={{ keepMounted: true }} + > + {drawerContent} + + + ); +} + +function Brand({ onClick }: { onClick?: () => void }) { + return ( + + + K + + + Khata + + + ); +} diff --git a/src/Home.tsx b/src/Home.tsx index e5272f5..bb86668 100644 --- a/src/Home.tsx +++ b/src/Home.tsx @@ -1,11 +1,11 @@ import * as React from "react"; -import { Box, Typography, Button, Container, Grid, Paper, Chip } from "@mui/material"; -import { useTheme, alpha } from "@mui/material/styles"; +import { Box, Typography, Button, Container, Grid, Paper, alpha } from "@mui/material"; +import { useTheme } from "@mui/material/styles"; import { useNavigate } from "react-router-dom"; -import DashboardIcon from "@mui/icons-material/Dashboard"; +import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; import SyncIcon from "@mui/icons-material/Sync"; -import BarChartIcon from "@mui/icons-material/BarChart"; -import SettingsIcon from "@mui/icons-material/Settings"; +import AccountBalanceWalletIcon from "@mui/icons-material/AccountBalanceWallet"; +import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import { useAuth } from "../react-auth"; @@ -14,11 +14,9 @@ interface FeatureCardProps { title: string; description: string; path: string; - label?: string; - accent: string; } -function FeatureCard({ icon, title, description, path, label, accent }: FeatureCardProps) { +function FeatureCard({ icon, title, description, path }: FeatureCardProps) { const navigate = useNavigate(); const theme = useTheme(); @@ -35,206 +33,154 @@ function FeatureCard({ icon, title, description, path, label, accent }: FeatureC height: "100%", display: "flex", flexDirection: "column", - position: "relative", - overflow: "hidden", - transition: "all 0.25s ease", - "&::before": { - content: '""', - position: "absolute", - top: 0, - left: 0, - right: 0, - height: 3, - background: accent, - opacity: 0, - transition: "opacity 0.25s ease", - }, + transition: "border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease", "&:hover": { - transform: "translateY(-4px)", - boxShadow: `0 12px 32px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.3 : 0.08)}`, borderColor: "transparent", - "&::before": { opacity: 1 }, + transform: "translateY(-2px)", + boxShadow: `0 8px 24px ${alpha(theme.palette.common.black, theme.palette.mode === "dark" ? 0.35 : 0.08)}`, + "& .feature-arrow": { + opacity: 1, + transform: "translateX(0)", + }, }, }} > - - - {icon} - - - {title} - + + {icon} + + {title} + + {description} - {label && ( - - )} + + + Open + + +
); } export default function Home() { const navigate = useNavigate(); - const theme = useTheme(); const { currentUser } = useAuth(); const features = [ { - icon: , - title: "Dashboard", - description: "Visualise inflows and outflows with interactive charts, drill into categories, and track trends over daily, weekly, and monthly periods.", - path: "/dashboard", - accent: theme.palette.mode === "dark" ? "#818cf8" : "#6366f1", + icon: , + title: "Expenses", + description: + "Browse every transaction imported from your accounts — grouped by month, with entity, account, tags, and amount at a glance.", + path: "/expenses", }, { icon: , title: "Fetch Requests", - description: "Upload bank statements or configure email ingestion to auto-import transactions. Track pipeline status from pending through to completion.", + description: + "Upload bank statements or configure email ingestion to auto-import transactions. Track pipeline status from pending through to completion.", path: "/fetch-requests", - accent: theme.palette.mode === "dark" ? "#34d399" : "#10b981", }, { - icon: , - title: "Report Snapshots", - description: "Generate cached report snapshots with custom filters — accounts, date ranges, amount bounds — then pin a snapshot on the dashboard for consistent comparisons.", - path: "/reports", - accent: theme.palette.mode === "dark" ? "#fbbf24" : "#f59e0b", - }, - { - icon: , - title: "Admin", - description: "Full CRUD over accounts, expenses, tags, and payors. Manage your data programmatically through the OpenAPI-driven admin panel.", + icon: , + title: "Accounts & Entities", + description: + "Manage your accounts, expenses, tags, and payors through the OpenAPI-driven admin panel with full CRUD and server-side filters.", path: "/admin", - accent: theme.palette.mode === "dark" ? "#e879f9" : "#d946ef", + }, + { + icon: , + title: "Profile", + description: + "View and edit your user profile — username, email, and account preferences — from the profile page.", + path: "/profile/me", }, ]; return ( - - - + + + + + Khata · Financial Ledger + + - Welcome to Khata + Your intelligent, extensible financial ledger. - Your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow. + Import transactions from bank statements, enrich them with entity and tag intelligence, + and stay on top of your cashflow — all in one place. - + - + {features.map((f) => ( diff --git a/src/main.jsx b/src/main.jsx index d367e92..ad1d0f1 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -5,22 +5,25 @@ import { BrowserRouter, Routes, Route, - useNavigate + useNavigate, + useLocation, + matchPath } from "react-router-dom"; import { Box, CssBaseline, - CircularProgress, - Toolbar + CircularProgress } from "@mui/material"; import Home from './Home'; import FetchRequests from './FetchRequest/FetchRequestCreate'; import FetchRequestDetail from './FetchRequest/FetchRequestDetail'; +import Expense from './Expense/Expense'; import { AppProvider, useAppContext, Admin, ProfileRoutes } from '../react-openapi'; import { AuthProvider, AuthPage, useAuth, ProfileCreate, ProfileEdit, ProfileView } from "../react-auth"; import Header from './Header'; import Footer from './Footer'; import AppTheme from './shared-theme/AppTheme'; +import { ToastProvider } from './ui/Toast'; import { specConfiguration } from './openapi-config'; const queryClient = new QueryClient(); @@ -76,6 +79,7 @@ const routerMapping = [ { path: "/register", component: RegisterPage, headerTitle: "Register" }, { path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" }, { path: "/fetch-requests/:id", component: FetchRequestDetail, headerTitle: "Fetch Request" }, + { path: "/expenses", component: Expense, headerTitle: "Expenses" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" }, { path: "/profile/*", component: ProfileRoutes, headerTitle: "Profile" }, ]; @@ -84,6 +88,16 @@ const routerMapping = [ function AppContent() { const { authConfig, loading } = useAppContext(); const navigate = useNavigate(); + const location = useLocation(); + + React.useEffect(() => { + const matched = routerMapping.find((route) => + matchPath({ path: route.path, end: false }, location.pathname), + ); + document.title = matched + ? `${matched.headerTitle} · Khata` + : "Khata — Financial Ledger"; + }, [location.pathname]); if (loading) { return ( @@ -97,23 +111,37 @@ function AppContent() { navigate("/login")}> -
+ + +
- - + + + {routerMapping.map(({ path, component: Component }) => ( + } + /> + ))} + + - - {routerMapping.map(({ path, component: Component }) => ( - } - /> - ))} - - - -
+
+ + ); diff --git a/src/shared-theme/customizations/feedback.tsx b/src/shared-theme/customizations/feedback.tsx index 5eb04db..22efda9 100644 --- a/src/shared-theme/customizations/feedback.tsx +++ b/src/shared-theme/customizations/feedback.tsx @@ -1,21 +1,14 @@ import { Theme, alpha, Components } from '@mui/material/styles'; -import { gray, orange } from '../themePrimitives'; +import { gray } from '../themePrimitives'; /* eslint-disable import/prefer-default-export */ export const feedbackCustomizations: Components = { MuiAlert: { styleOverrides: { root: ({ theme }) => ({ - borderRadius: 10, - backgroundColor: orange[100], - color: (theme.vars || theme).palette.text.primary, - border: `1px solid ${alpha(orange[300], 0.5)}`, - '& .MuiAlert-icon': { - color: orange[500], - }, + borderRadius: (theme.vars || theme).shape.borderRadius, ...theme.applyStyles('dark', { - backgroundColor: alpha(orange[900], 0.35), - border: `1px solid ${alpha(orange[800], 0.3)}`, + backgroundColor: alpha((theme.vars || theme).palette.background.paper, 0.6), }), }), }, @@ -24,7 +17,7 @@ export const feedbackCustomizations: Components = { styleOverrides: { root: ({ theme }) => ({ '& .MuiDialog-paper': { - borderRadius: '10px', + borderRadius: (theme.vars || theme).shape.borderRadius, border: '1px solid', borderColor: (theme.vars || theme).palette.divider, }, diff --git a/src/shared-theme/customizations/inputs.tsx b/src/shared-theme/customizations/inputs.tsx index e9d2ba5..eecc006 100644 --- a/src/shared-theme/customizations/inputs.tsx +++ b/src/shared-theme/customizations/inputs.tsx @@ -58,31 +58,26 @@ export const inputsCustomizations: Components = { }, style: { color: 'white', - backgroundColor: gray[900], - backgroundImage: `linear-gradient(to bottom, ${gray[700]}, ${gray[800]})`, - boxShadow: `inset 0 1px 0 ${gray[600]}, inset 0 -1px 0 1px hsl(220, 0%, 0%)`, - border: `1px solid ${gray[700]}`, + backgroundColor: brand[500], + border: `1px solid ${brand[600]}`, + boxShadow: 'none', '&:hover': { - backgroundImage: 'none', - backgroundColor: gray[700], + backgroundColor: brand[600], boxShadow: 'none', }, '&:active': { - backgroundColor: gray[800], + backgroundColor: brand[700], }, ...theme.applyStyles('dark', { - color: 'black', - backgroundColor: gray[50], - backgroundImage: `linear-gradient(to bottom, ${gray[100]}, ${gray[50]})`, - boxShadow: 'inset 0 -1px 0 hsl(220, 30%, 80%)', - border: `1px solid ${gray[50]}`, + color: brand[50], + backgroundColor: brand[500], + border: `1px solid ${brand[600]}`, '&:hover': { - backgroundImage: 'none', - backgroundColor: gray[300], + backgroundColor: brand[400], boxShadow: 'none', }, '&:active': { - backgroundColor: gray[400], + backgroundColor: brand[300], }, }), }, @@ -94,18 +89,28 @@ export const inputsCustomizations: Components = { }, style: { color: 'white', - backgroundColor: brand[300], - backgroundImage: `linear-gradient(to bottom, ${alpha(brand[400], 0.8)}, ${brand[500]})`, - boxShadow: `inset 0 2px 0 ${alpha(brand[200], 0.2)}, inset 0 -2px 0 ${alpha(brand[700], 0.4)}`, - border: `1px solid ${brand[500]}`, + backgroundColor: gray[900], + border: `1px solid ${gray[700]}`, + boxShadow: 'none', '&:hover': { - backgroundColor: brand[700], + backgroundColor: gray[800], boxShadow: 'none', }, '&:active': { - backgroundColor: brand[700], - backgroundImage: 'none', + backgroundColor: gray[700], }, + ...theme.applyStyles('dark', { + color: 'black', + backgroundColor: gray[50], + border: `1px solid ${gray[300]}`, + '&:hover': { + backgroundColor: gray[200], + boxShadow: 'none', + }, + '&:active': { + backgroundColor: gray[300], + }, + }), }, }, { diff --git a/src/shared-theme/customizations/surfaces.ts b/src/shared-theme/customizations/surfaces.ts index 7a2f318..8201418 100644 --- a/src/shared-theme/customizations/surfaces.ts +++ b/src/shared-theme/customizations/surfaces.ts @@ -36,7 +36,7 @@ export const surfacesCustomizations: Components = { styleOverrides: { root: ({ theme }) => ({ border: 'none', - borderRadius: 8, + borderRadius: (theme.vars || theme).shape.borderRadius, '&:hover': { backgroundColor: gray[50] }, '&:focus-visible': { backgroundColor: 'transparent' }, ...theme.applyStyles('dark', { diff --git a/src/shared-theme/themePrimitives.ts b/src/shared-theme/themePrimitives.ts index db2a942..17b1392 100644 --- a/src/shared-theme/themePrimitives.ts +++ b/src/shared-theme/themePrimitives.ts @@ -35,32 +35,37 @@ const defaultTheme = createTheme(); const customShadows: Shadows = [...defaultTheme.shadows]; +/** + * Stripe-like indigo brand. #635BFF ≈ hsl(243, 76%, 59%). + * Single brand hue — no multi-colour accents. + */ export const brand = { - 50: 'hsl(210, 100%, 95%)', - 100: 'hsl(210, 100%, 92%)', - 200: 'hsl(210, 100%, 80%)', - 300: 'hsl(210, 100%, 65%)', - 400: 'hsl(210, 98%, 48%)', - 500: 'hsl(210, 98%, 42%)', - 600: 'hsl(210, 98%, 55%)', - 700: 'hsl(210, 100%, 35%)', - 800: 'hsl(210, 100%, 16%)', - 900: 'hsl(210, 100%, 21%)', + 50: 'hsl(250, 100%, 98%)', + 100: 'hsl(250, 95%, 95%)', + 200: 'hsl(249, 92%, 90%)', + 300: 'hsl(248, 92%, 80%)', + 400: 'hsl(245, 90%, 72%)', + 500: 'hsl(243, 76%, 59%)', + 600: 'hsl(243, 75%, 51%)', + 700: 'hsl(243, 78%, 42%)', + 800: 'hsl(242, 76%, 30%)', + 900: 'hsl(241, 75%, 20%)', }; +/** Cool, neutral grays. */ export const gray = { 50: 'hsl(220, 35%, 97%)', 100: 'hsl(220, 30%, 94%)', - 200: 'hsl(220, 20%, 88%)', - 300: 'hsl(220, 20%, 80%)', - 400: 'hsl(220, 20%, 65%)', - 500: 'hsl(220, 20%, 42%)', - 600: 'hsl(220, 20%, 35%)', - 700: 'hsl(220, 20%, 25%)', - 750: 'hsl(220, 20%, 18%)', - 800: 'hsl(220, 30%, 6%)', - 850: 'hsl(220, 22%, 11%)', - 900: 'hsl(220, 35%, 3%)', + 200: 'hsl(220, 20%, 89%)', + 300: 'hsl(220, 17%, 80%)', + 400: 'hsl(220, 15%, 64%)', + 500: 'hsl(220, 14%, 45%)', + 600: 'hsl(220, 13%, 36%)', + 700: 'hsl(220, 13%, 27%)', + 750: 'hsl(220, 13%, 20%)', + 800: 'hsl(220, 12%, 12%)', + 850: 'hsl(220, 10%, 8%)', + 900: 'hsl(220, 9%, 6%)', }; export const green = { @@ -116,27 +121,39 @@ export const getDesignTokens = (mode: PaletteMode) => { palette: { mode, primary: { - light: brand[200], - main: brand[400], + light: brand[400], + main: brand[500], dark: brand[700], contrastText: brand[50], ...(mode === 'dark' && { contrastText: brand[50], - light: 'hsl(210, 50%, 65%)', - main: 'hsl(210, 55%, 55%)', - dark: 'hsl(210, 50%, 35%)', + light: 'hsl(245, 85%, 70%)', + main: 'hsl(243, 76%, 64%)', + dark: 'hsl(243, 78%, 48%)', + }), + }, + secondary: { + light: gray[300], + main: gray[600], + dark: gray[800], + contrastText: gray[50], + ...(mode === 'dark' && { + light: gray[200], + main: gray[400], + dark: gray[600], + contrastText: gray[900], }), }, info: { light: brand[100], - main: brand[300], + main: brand[400], dark: brand[600], contrastText: gray[50], ...(mode === 'dark' && { contrastText: 'hsl(210, 30%, 80%)', - light: 'hsl(210, 40%, 50%)', - main: 'hsl(210, 35%, 40%)', - dark: 'hsl(210, 30%, 25%)', + light: 'hsl(245, 60%, 60%)', + main: 'hsl(243, 55%, 52%)', + dark: 'hsl(243, 50%, 38%)', }), }, warning: { @@ -174,7 +191,7 @@ export const getDesignTokens = (mode: PaletteMode) => { }, divider: mode === 'dark' ? 'hsla(0, 0%, 100%, 0.08)' : alpha(gray[300], 0.4), background: { - default: 'hsl(0, 0%, 99%)', + default: 'hsl(0, 0%, 100%)', paper: 'hsl(220, 35%, 97%)', ...(mode === 'dark' && { default: darkBg, paper: darkPaper }), }, @@ -208,242 +225,73 @@ export const getDesignTokens = (mode: PaletteMode) => { typography: { fontFamily: 'Inter, sans-serif', h1: { - fontSize: defaultTheme.typography.pxToRem(48), + fontSize: defaultTheme.typography.pxToRem(40), fontWeight: 600, - lineHeight: 1.2, - letterSpacing: -0.5, + lineHeight: 1.1, + letterSpacing: '-0.025em', }, h2: { - fontSize: defaultTheme.typography.pxToRem(36), + fontSize: defaultTheme.typography.pxToRem(32), fontWeight: 600, lineHeight: 1.2, + letterSpacing: '-0.02em', }, h3: { - fontSize: defaultTheme.typography.pxToRem(30), - lineHeight: 1.2, + fontSize: defaultTheme.typography.pxToRem(26), + fontWeight: 600, + lineHeight: 1.25, + letterSpacing: '-0.015em', }, h4: { - fontSize: defaultTheme.typography.pxToRem(24), + fontSize: defaultTheme.typography.pxToRem(20), + fontWeight: 600, + lineHeight: 1.4, + letterSpacing: '-0.01em', + }, + h5: { + fontSize: defaultTheme.typography.pxToRem(18), + fontWeight: 600, + lineHeight: 1.4, + letterSpacing: '-0.005em', + }, + h6: { + fontSize: defaultTheme.typography.pxToRem(16), fontWeight: 600, lineHeight: 1.5, }, - h5: { - fontSize: defaultTheme.typography.pxToRem(20), - fontWeight: 600, - }, - h6: { - fontSize: defaultTheme.typography.pxToRem(18), - fontWeight: 600, - }, subtitle1: { - fontSize: defaultTheme.typography.pxToRem(18), + fontSize: defaultTheme.typography.pxToRem(16), + fontWeight: 500, }, subtitle2: { - fontSize: defaultTheme.typography.pxToRem(14), + fontSize: defaultTheme.typography.pxToRem(13), fontWeight: 500, }, body1: { - fontSize: defaultTheme.typography.pxToRem(14), + fontSize: defaultTheme.typography.pxToRem(15), + lineHeight: 1.5, }, body2: { fontSize: defaultTheme.typography.pxToRem(14), fontWeight: 400, + lineHeight: 1.45, }, caption: { fontSize: defaultTheme.typography.pxToRem(12), fontWeight: 400, + lineHeight: 1.4, + }, + overline: { + fontSize: defaultTheme.typography.pxToRem(11), + fontWeight: 600, + lineHeight: 1.5, + textTransform: 'uppercase', + letterSpacing: '0.06em', }, }, shape: { - borderRadius: 8, + borderRadius: 6, }, shadows: customShadows, }; -}; - -export const colorSchemes = { - light: { - palette: { - primary: { - light: brand[200], - main: brand[400], - dark: brand[700], - contrastText: brand[50], - }, - info: { - light: brand[100], - main: brand[300], - dark: brand[600], - contrastText: gray[50], - }, - warning: { - light: orange[300], - main: orange[400], - dark: orange[800], - }, - error: { - light: red[300], - main: red[400], - dark: red[800], - }, - success: { - light: green[300], - main: green[400], - dark: green[800], - }, - grey: { - ...gray, - }, - divider: alpha(gray[300], 0.4), - background: { - default: 'hsl(0, 0%, 99%)', - paper: 'hsl(220, 35%, 97%)', - }, - text: { - primary: gray[800], - secondary: gray[600], - warning: orange[400], - }, - action: { - hover: alpha(gray[200], 0.2), - selected: `${alpha(gray[200], 0.3)}`, - }, - flows: { - outflows: { - primary: '#d32f2f', - surface: '#fdecea', - text: '#b71c1c', - }, - inflows: { - primary: '#2e7d32', - surface: '#e8f5e9', - text: '#1b5e20', - }, - }, - baseShadow: - 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px', - }, - }, - dark: { - palette: { - primary: { - contrastText: brand[50], - light: 'hsl(210, 50%, 65%)', - main: 'hsl(210, 55%, 55%)', - dark: 'hsl(210, 50%, 35%)', - }, - info: { - contrastText: 'hsl(210, 30%, 80%)', - light: 'hsl(210, 40%, 50%)', - main: 'hsl(210, 35%, 40%)', - dark: 'hsl(210, 30%, 25%)', - }, - warning: { - light: 'hsl(45, 60%, 55%)', - main: 'hsl(45, 55%, 45%)', - dark: 'hsl(45, 50%, 30%)', - }, - error: { - light: 'hsl(0, 55%, 60%)', - main: 'hsl(0, 55%, 50%)', - dark: 'hsl(0, 50%, 35%)', - }, - success: { - light: 'hsl(120, 40%, 55%)', - main: 'hsl(120, 40%, 45%)', - dark: 'hsl(120, 35%, 30%)', - }, - grey: { - ...gray, - }, - divider: 'hsla(0, 0%, 100%, 0.08)', - background: { - default: darkBg, - paper: darkPaper, - }, - text: { - primary: 'hsl(0, 0%, 92%)', - secondary: 'hsl(0, 0%, 60%)', - }, - action: { - hover: 'hsla(0, 0%, 100%, 0.06)', - selected: 'hsla(0, 0%, 100%, 0.1)', - }, - flows: { - outflows: { - primary: 'hsl(0, 55%, 60%)', - surface: 'hsla(0, 35%, 25%, 0.6)', - text: 'hsl(0, 60%, 80%)', - }, - inflows: { - primary: 'hsl(120, 40%, 55%)', - surface: 'hsla(120, 25%, 22%, 0.6)', - text: 'hsl(120, 40%, 78%)', - }, - }, - baseShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)', - }, - }, -}; - -export const typography = { - fontFamily: 'Inter, sans-serif', - h1: { - fontSize: defaultTheme.typography.pxToRem(48), - fontWeight: 600, - lineHeight: 1.2, - letterSpacing: -0.5, - }, - h2: { - fontSize: defaultTheme.typography.pxToRem(36), - fontWeight: 600, - lineHeight: 1.2, - }, - h3: { - fontSize: defaultTheme.typography.pxToRem(30), - lineHeight: 1.2, - }, - h4: { - fontSize: defaultTheme.typography.pxToRem(24), - fontWeight: 600, - lineHeight: 1.5, - }, - h5: { - fontSize: defaultTheme.typography.pxToRem(20), - fontWeight: 600, - }, - h6: { - fontSize: defaultTheme.typography.pxToRem(18), - fontWeight: 600, - }, - subtitle1: { - fontSize: defaultTheme.typography.pxToRem(18), - }, - subtitle2: { - fontSize: defaultTheme.typography.pxToRem(14), - fontWeight: 500, - }, - body1: { - fontSize: defaultTheme.typography.pxToRem(14), - }, - body2: { - fontSize: defaultTheme.typography.pxToRem(14), - fontWeight: 400, - }, - caption: { - fontSize: defaultTheme.typography.pxToRem(12), - fontWeight: 400, - }, -}; - -export const shape = { - borderRadius: 8, -}; - -// @ts-ignore -const defaultShadows: Shadows = [ - 'none', - 'var(--template-palette-baseShadow)', - ...defaultTheme.shadows.slice(2), -]; -export const shadows = defaultShadows; +}; \ No newline at end of file diff --git a/src/ui/EmptyState.tsx b/src/ui/EmptyState.tsx new file mode 100644 index 0000000..af53231 --- /dev/null +++ b/src/ui/EmptyState.tsx @@ -0,0 +1,57 @@ +import * as React from "react"; +import { Box, Typography, Button } from "@mui/material"; + +interface EmptyStateProps { + icon?: React.ReactNode; + title: string; + description?: string; + actionLabel?: string; + onAction?: () => void; +} + +export function EmptyState({ icon, title, description, actionLabel, onAction }: EmptyStateProps) { + return ( + + {icon && ( + + {icon} + + )} + + {title} + + {description && ( + + {description} + + )} + {actionLabel && onAction && ( + + )} + + ); +} diff --git a/src/ui/PageHeader.tsx b/src/ui/PageHeader.tsx new file mode 100644 index 0000000..5de464e --- /dev/null +++ b/src/ui/PageHeader.tsx @@ -0,0 +1,67 @@ +import * as React from "react"; +import { Box, Breadcrumbs, Typography, Link } from "@mui/material"; +import { useNavigate } from "react-router-dom"; +import NavigateNextIcon from "@mui/icons-material/NavigateNext"; + +export interface Crumb { + label: string; + path?: string; +} + +interface PageHeaderProps { + crumbs: Crumb[]; + title: string; + subtitle?: string; + actions?: React.ReactNode; +} + +export function PageHeader({ crumbs, title, subtitle, actions }: PageHeaderProps) { + const navigate = useNavigate(); + + return ( + + } + aria-label="breadcrumb" + sx={{ mb: 1 }} + > + {crumbs.map((crumb, i) => { + const isLast = i === crumbs.length - 1; + if (isLast || !crumb.path) { + return ( + + {crumb.label} + + ); + } + return ( + navigate(crumb.path!)} + sx={{ fontWeight: 500, fontSize: "0.875rem" }} + > + {crumb.label} + + ); + })} + + + + + + {title} + + {subtitle && ( + + {subtitle} + + )} + + {actions && {actions}} + + + ); +} diff --git a/src/ui/Toast.tsx b/src/ui/Toast.tsx new file mode 100644 index 0000000..c0c7e48 --- /dev/null +++ b/src/ui/Toast.tsx @@ -0,0 +1,52 @@ +import * as React from "react"; +import { Snackbar, Alert, AlertColor } from "@mui/material"; + +interface ToastState { + open: boolean; + message: string; + severity: AlertColor; +} + +interface ToastContextValue { + showToast: (message: string, severity?: AlertColor) => void; +} + +const ToastContext = React.createContext(null); + +export function ToastProvider({ children }: { children: React.ReactNode }) { + const [toast, setToast] = React.useState({ open: false, message: "", severity: "success" }); + const timerRef = React.useRef | null>(null); + + const showToast = React.useCallback((message: string, severity: AlertColor = "success") => { + if (timerRef.current) clearTimeout(timerRef.current); + setToast({ open: true, message, severity }); + timerRef.current = setTimeout(() => setToast((t) => ({ ...t, open: false })), 4000); + }, []); + + const handleClose = (_?: React.SyntheticEvent | Event, reason?: string) => { + if (reason === "clickaway") return; + setToast((t) => ({ ...t, open: false })); + }; + + return ( + + {children} + + + {toast.message} + + + + ); +} + +export function useToast(): ToastContextValue { + const ctx = React.useContext(ToastContext); + if (!ctx) throw new Error("useToast must be used within a ToastProvider"); + return ctx; +} -- 2.49.1 From d6856a538f4353a8d0f49b4d5299cf0251f0840d Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Mon, 17 Aug 2026 15:22:37 +0530 Subject: [PATCH 02/10] fix expenses list performance + dom nesting - memoize accordion cards and groupByMonth so toggling one expense re-renders O(1) cards instead of the whole list - cache Intl.NumberFormat and date formatters per key (was ~2-3 instantiations per item per render) - unmount collapsed accordion details via unmountOnExit to cut initial mount cost and input delay - restructure Account row in ExpenseDetail so Chip is not nested inside

(fixes validateDOMNesting warning) - avoid mutating expenses state array when sorting --- src/Expense/Expense.tsx | 2 +- src/Expense/ExpenseDetail.tsx | 10 +- src/Expense/ExpenseList.tsx | 174 +++++++++++++++++++--------------- src/Expense/types.ts | 32 +++++-- 4 files changed, 128 insertions(+), 90 deletions(-) diff --git a/src/Expense/Expense.tsx b/src/Expense/Expense.tsx index 8e18055..2afe060 100644 --- a/src/Expense/Expense.tsx +++ b/src/Expense/Expense.tsx @@ -54,7 +54,7 @@ export default function Expense() { const sorted = useMemo( () => - (items ?? []).sort( + [...(items ?? [])].sort( (a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(), ), [items], diff --git a/src/Expense/ExpenseDetail.tsx b/src/Expense/ExpenseDetail.tsx index f851f9c..015047d 100644 --- a/src/Expense/ExpenseDetail.tsx +++ b/src/Expense/ExpenseDetail.tsx @@ -33,17 +33,19 @@ export function ExpenseDetail({ item }: { item: ExpenseItem }) { - {account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"} + + + {account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"} + {account?.type && ( )} - + } /> void; +} + +const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, onToggle }: ExpenseCardProps) { + const negative = isExpense(item); + const itemCurrency = item.account?.currency ?? currency; + + return ( + onToggle(isExpanded ? item.id : "")} + TransitionProps={{ unmountOnExit: true }} + sx={{ + border: "1px solid", + borderColor: expanded ? "primary.main" : "divider", + borderRadius: 2, + overflow: "hidden", + boxShadow: "none", + "&:before": { display: "none" }, + transition: "border-color 160ms ease, background-color 160ms ease", + "&:hover": { borderColor: "primary.light" }, + }} + > + } + sx={{ + "& .MuiAccordionSummary-content": { + alignItems: "center", + gap: 2, + minWidth: 0, + py: 0.5, + }, + }} + > + + + + {item.entity?.name ?? "Unknown"} + + + + {formatDate(item.occurred_at)} + + {item.account?.name && ( + + )} + + + + {formatCurrency(item.amount, itemCurrency)} + + + + + + + ); +}); + export function ExpenseList({ items }: { items: ExpenseItem[] }) { const [expandedId, setExpandedId] = useState(null); - const groups = groupByMonth(items); + const groups = useMemo(() => groupByMonth(items), [items]); + const handleToggle = useCallback((id: string) => { + setExpandedId((prev) => (prev === id ? null : id)); + }, []); return ( @@ -89,80 +174,15 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) { - {group.items.map((item) => { - const negative = isExpense(item); - const currency = item.account?.currency ?? group.currency; - return ( - setExpandedId(expanded ? item.id : null)} - sx={{ - border: "1px solid", - borderColor: expandedId === item.id ? "primary.main" : "divider", - borderRadius: 2, - overflow: "hidden", - boxShadow: "none", - "&:before": { display: "none" }, - transition: "border-color 160ms ease, background-color 160ms ease", - "&:hover": { borderColor: "primary.light" }, - }} - > - } - sx={{ - "& .MuiAccordionSummary-content": { - alignItems: "center", - gap: 2, - minWidth: 0, - py: 0.5, - }, - }} - > - - - - {item.entity?.name ?? "Unknown"} - - - - {formatDate(item.occurred_at)} - - {item.account?.name && ( - - )} - - - - {formatCurrency(item.amount, currency)} - - - - - - - ); - })} + {group.items.map((item) => ( + + ))} ))} diff --git a/src/Expense/types.ts b/src/Expense/types.ts index c6eeb93..0effadf 100644 --- a/src/Expense/types.ts +++ b/src/Expense/types.ts @@ -13,37 +13,53 @@ export function isExpense(item: ExpenseItem): boolean { return (item.amount ?? 0) < 0; } +const CURRENCIES = ["INR", "USD", "EUR", "GBP", "AED", "SGD"]; +const _currencyFormatters = new Map(); + export function formatCurrency(amount: number, currency?: string): string { - const code = currency && ["INR", "USD", "EUR", "GBP", "AED", "SGD"].includes(currency) ? currency : "INR"; - try { - return new Intl.NumberFormat("en-IN", { + const code = currency && CURRENCIES.includes(currency) ? currency : "INR"; + let formatter = _currencyFormatters.get(code); + if (!formatter) { + formatter = new Intl.NumberFormat("en-IN", { style: "currency", currency: code, maximumFractionDigits: 2, - }).format(amount); - } catch { - return `₹${amount.toFixed(2)}`; + }); + _currencyFormatters.set(code, formatter); } + return formatter.format(amount); } +const _dateCache = new Map(); + export function formatDate(iso?: string): string { if (!iso) return "—"; + const cached = _dateCache.get(iso); + if (cached) return cached; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; - return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }); + const out = d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }); + _dateCache.set(iso, out); + return out; } +const _dateTimeCache = new Map(); + export function formatDateTime(iso?: string): string { if (!iso) return "—"; + const cached = _dateTimeCache.get(iso); + if (cached) return cached; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; - return d.toLocaleString("en-IN", { + const out = d.toLocaleString("en-IN", { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit", }); + _dateTimeCache.set(iso, out); + return out; } export function monthKey(iso?: string): string { -- 2.49.1 From 3dd833ac2beda459914d1aca35c82ae6b5a8148a Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Mon, 17 Aug 2026 16:27:43 +0530 Subject: [PATCH 03/10] shared currency field + expenses rendered from react-openapi fields - add CurrencyField renderer (inherits NumberField for editing) with cached Intl formatter; route via uiType "currency" in list/detail/form - add resourceConfig.fieldTypes override applied in AppProvider, so amount becomes currency for both Admin and the Expenses page - render Expenses page through react-openapi fields (ListCellRenderer, DetailFieldRenderer, applyDisplayFormat) instead of custom fields - resolve relative /uploads entity logos against the API base URL on load so entity logos render on the expenses feed - drop custom avatar, currency/date formatters from the Expense module --- react-openapi/index.ts | 1 + .../components/fields/DetailFieldRenderer.tsx | 5 +- .../components/fields/FormFieldRenderer.tsx | 11 +++ .../components/fields/ListCellRenderer.tsx | 5 + react-openapi/src/components/fields/index.ts | 1 + .../fields/renderers/CurrencyField.tsx | 42 ++++++++ react-openapi/src/context/AppProvider.tsx | 16 +++- react-openapi/src/types.ts | 2 + src/Expense/Expense.tsx | 51 ++++++++-- src/Expense/ExpenseDetail.tsx | 91 +++--------------- src/Expense/ExpenseList.tsx | 95 ++++++++----------- src/Expense/types.ts | 70 +++++--------- src/openapi-config.ts | 1 + 13 files changed, 200 insertions(+), 191 deletions(-) create mode 100644 react-openapi/src/components/fields/renderers/CurrencyField.tsx diff --git a/react-openapi/index.ts b/react-openapi/index.ts index 21f743b..0f1a3ac 100644 --- a/react-openapi/index.ts +++ b/react-openapi/index.ts @@ -5,6 +5,7 @@ export { useAppContext } from "./src/context/AppContext"; export { useResource } from "./src/context/useResource"; export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields"; export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer"; +export { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField"; export { SseStreamView } from "./src/components/SseStreamView"; export { SseConnectionStatus } from "./src/components/SseConnectionStatus"; export { getApi } from "./src/hooks/useApi"; diff --git a/react-openapi/src/components/fields/DetailFieldRenderer.tsx b/react-openapi/src/components/fields/DetailFieldRenderer.tsx index 150e0aa..d21d583 100644 --- a/react-openapi/src/components/fields/DetailFieldRenderer.tsx +++ b/react-openapi/src/components/fields/DetailFieldRenderer.tsx @@ -2,6 +2,7 @@ import React from "react"; import { Box, Typography, Avatar } from "@mui/material"; import type { FieldConfig } from "../../types"; import { ListCellRenderer } from "./ListCellRenderer"; +import { CurrencyField } from "./renderers/CurrencyField"; interface DetailFieldProps { field: FieldConfig; @@ -18,7 +19,9 @@ export function DetailFieldRenderer({ field, value, displayFormat, basePath }: D {field.label} - {field.uiType === "image" ? ( + {field.uiType === "currency" ? ( + + ) : field.uiType === "image" ? ( ) : ( diff --git a/react-openapi/src/components/fields/FormFieldRenderer.tsx b/react-openapi/src/components/fields/FormFieldRenderer.tsx index 6d9616e..4b21f66 100644 --- a/react-openapi/src/components/fields/FormFieldRenderer.tsx +++ b/react-openapi/src/components/fields/FormFieldRenderer.tsx @@ -96,6 +96,17 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk return ; } + if (field.uiType === "currency") { + return ( + + ); + } + if (field.type === "integer" || field.type === "number") { return ( ; } + if (field.uiType === "currency" && value != null && !Number.isNaN(Number(value))) { + return ; + } + if (field.type === "boolean") { return ; } diff --git a/react-openapi/src/components/fields/index.ts b/react-openapi/src/components/fields/index.ts index dcb48b8..279036b 100644 --- a/react-openapi/src/components/fields/index.ts +++ b/react-openapi/src/components/fields/index.ts @@ -3,3 +3,4 @@ export { ListCellRenderer } from "./ListCellRenderer"; export { DetailFieldRenderer } from "./DetailFieldRenderer"; export { applyDisplayFormat } from "./utils"; export { JsonField } from "./renderers/JsonField"; +export { CurrencyField, formatCurrency } from "./renderers/CurrencyField"; diff --git a/react-openapi/src/components/fields/renderers/CurrencyField.tsx b/react-openapi/src/components/fields/renderers/CurrencyField.tsx new file mode 100644 index 0000000..27b748d --- /dev/null +++ b/react-openapi/src/components/fields/renderers/CurrencyField.tsx @@ -0,0 +1,42 @@ +import React from "react"; +import { Typography } from "@mui/material"; + +const CURRENCIES = ["INR", "USD", "EUR", "GBP", "AED", "SGD"]; +const _currencyFormatters = new Map(); + +export function formatCurrency(amount: number, currency?: string): string { + const code = currency && CURRENCIES.includes(currency) ? currency : "INR"; + let formatter = _currencyFormatters.get(code); + if (!formatter) { + formatter = new Intl.NumberFormat("en-IN", { + style: "currency", + currency: code, + maximumFractionDigits: 2, + }); + _currencyFormatters.set(code, formatter); + } + return formatter.format(amount); +} + +interface CurrencyFieldProps { + value: number; + currency?: string; + large?: boolean; +} + +export function CurrencyField({ value, currency, large }: CurrencyFieldProps) { + const negative = value < 0; + return ( + + {formatCurrency(value, currency)} + + ); +} diff --git a/react-openapi/src/context/AppProvider.tsx b/react-openapi/src/context/AppProvider.tsx index 8e7f894..65f3878 100644 --- a/react-openapi/src/context/AppProvider.tsx +++ b/react-openapi/src/context/AppProvider.tsx @@ -43,6 +43,20 @@ function extractProfileOperations(spec: any): ProfileOperation[] { return ops; } +function applyResourceOverrides(configs: ResourceConfig[], specConfiguration: SpecConfiguration): ResourceConfig[] { + for (const resource of configs) { + const fieldTypes = specConfiguration.resourceConfig?.[resource.name]?.fieldTypes; + if (!fieldTypes) continue; + // fields and orderedFields share the same FieldConfig object references, + // so an in-place mutation updates both. + for (const field of resource.fields) { + const override = fieldTypes[field.name]; + if (override) field.uiType = override; + } + } + return configs; +} + const DEFAULT_AUTH_CONFIG: AuthConfig = { serverUrl: "", loginPath: "/login", @@ -88,7 +102,7 @@ export function AppProvider({ specConfiguration, children }: AppProviderProps) { if (errs.length === 0) { const configs = buildResourceConfigs(spec); if (!cancelled) { - setResources(configs); + setResources(applyResourceOverrides(configs, specConfiguration)); } const baseUrl = specConfiguration.baseApiUrl ?? spec.servers?.[0]?.url ?? ""; diff --git a/react-openapi/src/types.ts b/react-openapi/src/types.ts index 0964d1a..2053029 100644 --- a/react-openapi/src/types.ts +++ b/react-openapi/src/types.ts @@ -4,6 +4,8 @@ export interface ResourceConfiguration { filterOptions?: { mode?: FilterMode; }; + /** Map of field name → uiType override (e.g. { amount: "currency" }). */ + fieldTypes?: Record; } export interface ProfileComponents { diff --git a/src/Expense/Expense.tsx b/src/Expense/Expense.tsx index 2afe060..b0b2df0 100644 --- a/src/Expense/Expense.tsx +++ b/src/Expense/Expense.tsx @@ -10,11 +10,13 @@ import { } from "@mui/material"; import ReceiptLongIcon from "@mui/icons-material/ReceiptLong"; import { useNavigate } from "react-router-dom"; -import { useResource } from "../../react-openapi"; +import { useResource, useAppContext, formatCurrency } from "../../react-openapi"; import { PageHeader } from "../ui/PageHeader"; import { EmptyState } from "../ui/EmptyState"; import { ExpenseList } from "./ExpenseList"; -import { ExpenseItem, formatCurrency, isExpense, monthKey, monthLabel } from "./types"; +import { ExpenseItem, ExpenseFieldConfigs, isExpense, monthKey, monthLabel, resolveLogoUrl } from "./types"; + +const API_BASE = import.meta.env.VITE_API_BASE_URL; function StatCard({ label, value, hint }: { label: string; value: string; hint?: string }) { return ( @@ -39,13 +41,50 @@ function StatCard({ label, value, hint }: { label: string; value: string; hint?: export default function Expense() { const navigate = useNavigate(); - const { list, loading, error } = useResource("expenses"); + const { list, loading, error, resource } = useResource("expenses"); + const { resources } = useAppContext(); const [items, setItems] = useState(null); + const fieldConfigs = useMemo(() => { + if (!resource) return null; + const find = (name: string) => resource.fields.find((f) => f.name === name); + const entity = find("entity"); + const amount = find("amount"); + const account = find("account"); + const tags = find("tags"); + const occurredAt = find("occurred_at"); + const entitiesRes = resources.find((r) => r.name === "entities"); + const accountsRes = resources.find((r) => r.name === "accounts"); + const tagsRes = resources.find((r) => r.name === "tags"); + const logo = entitiesRes?.fields.find((f) => f.name === "logo"); + if (!entity || !amount || !account || !tags || !occurredAt || !logo) return null; + return { + entity, + amount, + account, + tags, + occurredAt, + logo, + formats: { + entity: entitiesRes?.displayFormat ?? "{name}", + account: accountsRes?.displayFormat ?? "{name}", + tags: tagsRes?.displayFormat ?? "{name}", + }, + }; + }, [resource, resources]); + useEffect(() => { let mounted = true; list({ limit: 0 }).then((res) => { - if (mounted) setItems(res.items as ExpenseItem[]); + if (!mounted) return; + const rows = (res.items ?? []) as ExpenseItem[]; + const normalized = rows.map((it) => ({ + ...it, + entity: it.entity + ? { ...it.entity, logo: resolveLogoUrl(it.entity.logo, API_BASE) } + : it.entity, + })); + setItems(normalized); }); return () => { mounted = false; @@ -132,9 +171,9 @@ export default function Expense() { - + {fieldConfigs && } )} ); -} +} \ No newline at end of file diff --git a/src/Expense/ExpenseDetail.tsx b/src/Expense/ExpenseDetail.tsx index 015047d..203994b 100644 --- a/src/Expense/ExpenseDetail.tsx +++ b/src/Expense/ExpenseDetail.tsx @@ -1,87 +1,24 @@ import React from "react"; -import { Box, Typography, Chip, Divider } from "@mui/material"; -import type { ExpenseItem } from "./types"; -import { formatCurrency, formatDate, formatDateTime } from "./types"; +import { Box, Divider } from "@mui/material"; +import { DetailFieldRenderer } from "../../react-openapi"; +import type { ExpenseItem, ExpenseFieldConfigs } from "./types"; -function Row({ label, value }: { label: string; value: React.ReactNode }) { - return ( - - - {label} - - {value} - - ); +interface ExpenseDetailProps { + item: ExpenseItem; + fields: ExpenseFieldConfigs; } -export function ExpenseDetail({ item }: { item: ExpenseItem }) { - const account = item.account; - const tags = item.tags ?? []; - const last4 = account?.number ? `…${account.number.slice(-4)}` : ""; - +export function ExpenseDetail({ item, fields }: ExpenseDetailProps) { return ( - - {formatCurrency(item.amount, account?.currency)} - - } - /> - - - {account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"} - - {account?.type && ( - - )} - - } - /> - 0 ? ( - - {tags.map((tag, i) => ( - - ))} - - ) : ( - - No tags - - ) - } - /> - {formatDate(item.occurred_at)}} /> - - {item.id} - - } - /> - {formatDateTime(item.created_at)}} - /> + + + + + + + ); } diff --git a/src/Expense/ExpenseList.tsx b/src/Expense/ExpenseList.tsx index 39618a6..0e07670 100644 --- a/src/Expense/ExpenseList.tsx +++ b/src/Expense/ExpenseList.tsx @@ -5,13 +5,11 @@ import { Accordion, AccordionSummary, AccordionDetails, - Avatar, - Chip, - useTheme, } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import type { ExpenseItem } from "./types"; -import { formatCurrency, formatDate, isExpense, monthKey, monthLabel } from "./types"; +import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi"; +import type { ExpenseItem, ExpenseFieldConfigs } from "./types"; +import { monthKey, monthLabel } from "./types"; import { ExpenseDetail } from "./ExpenseDetail"; interface GroupedMonth { @@ -41,40 +39,15 @@ function groupByMonth(items: ExpenseItem[]): GroupedMonth[] { .sort((a, b) => b.key.localeCompare(a.key)); } -function EntityAvatar({ entity }: { entity: ExpenseItem["entity"] }) { - const theme = useTheme(); - const name = entity?.name ?? "?"; - const letter = name.trim().charAt(0).toUpperCase() || "?"; - const logo = entity?.logo; - const isImage = typeof logo === "string" && (logo.startsWith("http") || logo.startsWith("data:")); - - return ( - - {letter} - - ); -} - interface ExpenseCardProps { item: ExpenseItem; expanded: boolean; currency: string; + fields: ExpenseFieldConfigs; onToggle: (id: string) => void; } -const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, onToggle }: ExpenseCardProps) { - const negative = isExpense(item); +const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, fields, onToggle }: ExpenseCardProps) { const itemCurrency = item.account?.currency ?? currency; return ( @@ -105,7 +78,21 @@ const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, }, }} > - + + {item.entity?.logo ? ( + + ) : ( + + )} + - {item.entity?.name ?? "Unknown"} + {item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"} - - - {formatDate(item.occurred_at)} - + + {item.account?.name && ( - )} - - {formatCurrency(item.amount, itemCurrency)} - + - + ); }); -export function ExpenseList({ items }: { items: ExpenseItem[] }) { +interface ExpenseListProps { + items: ExpenseItem[]; + fields: ExpenseFieldConfigs; +} + +export function ExpenseList({ items, fields }: ExpenseListProps) { const [expandedId, setExpandedId] = useState(null); const groups = useMemo(() => groupByMonth(items), [items]); const handleToggle = useCallback((id: string) => { @@ -180,6 +158,7 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) { item={item} expanded={expandedId === item.id} currency={group.currency} + fields={fields} onToggle={handleToggle} /> ))} @@ -190,4 +169,4 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) { ); } -export { groupByMonth }; +export { groupByMonth }; \ No newline at end of file diff --git a/src/Expense/types.ts b/src/Expense/types.ts index 0effadf..746eaaf 100644 --- a/src/Expense/types.ts +++ b/src/Expense/types.ts @@ -1,3 +1,5 @@ +import type { FieldConfig } from "../../react-openapi"; + export interface ExpenseItem { id: string; entity?: { name?: string; type?: string; logo?: string } | null; @@ -9,57 +11,29 @@ export interface ExpenseItem { updated_at?: string; } +export interface ExpenseFieldConfigs { + entity: FieldConfig; + amount: FieldConfig; + account: FieldConfig; + tags: FieldConfig; + occurredAt: FieldConfig; + logo: FieldConfig; + formats: { + entity: string; + account: string; + tags: string; + }; +} + export function isExpense(item: ExpenseItem): boolean { return (item.amount ?? 0) < 0; } -const CURRENCIES = ["INR", "USD", "EUR", "GBP", "AED", "SGD"]; -const _currencyFormatters = new Map(); - -export function formatCurrency(amount: number, currency?: string): string { - const code = currency && CURRENCIES.includes(currency) ? currency : "INR"; - let formatter = _currencyFormatters.get(code); - if (!formatter) { - formatter = new Intl.NumberFormat("en-IN", { - style: "currency", - currency: code, - maximumFractionDigits: 2, - }); - _currencyFormatters.set(code, formatter); - } - return formatter.format(amount); -} - -const _dateCache = new Map(); - -export function formatDate(iso?: string): string { - if (!iso) return "—"; - const cached = _dateCache.get(iso); - if (cached) return cached; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return "—"; - const out = d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }); - _dateCache.set(iso, out); - return out; -} - -const _dateTimeCache = new Map(); - -export function formatDateTime(iso?: string): string { - if (!iso) return "—"; - const cached = _dateTimeCache.get(iso); - if (cached) return cached; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return "—"; - const out = d.toLocaleString("en-IN", { - day: "numeric", - month: "short", - year: "numeric", - hour: "numeric", - minute: "2-digit", - }); - _dateTimeCache.set(iso, out); - return out; +export function resolveLogoUrl(logo?: string, base?: string): string | undefined { + if (!logo) return undefined; + if (logo.startsWith("http") || logo.startsWith("data:")) return logo; + if (logo.startsWith("/") && base) return `${base.replace(/\/+$/, "")}${logo}`; + return logo; } export function monthKey(iso?: string): string { @@ -74,4 +48,4 @@ export function monthLabel(key: string): string { if (!y || !m) return key; const d = new Date(y, m - 1, 1); return d.toLocaleDateString("en-IN", { month: "long", year: "numeric" }); -} +} \ No newline at end of file diff --git a/src/openapi-config.ts b/src/openapi-config.ts index 9c08aea..5afc1f5 100644 --- a/src/openapi-config.ts +++ b/src/openapi-config.ts @@ -11,6 +11,7 @@ export const specConfiguration: SpecConfiguration = { resourceConfig: { expenses: { filterOptions: { mode: "client" }, + fieldTypes: { amount: "currency" }, }, }, }; -- 2.49.1 From e8c585bdb8cbd05ba1292f31d532100086e49fb4 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Mon, 17 Aug 2026 16:43:46 +0530 Subject: [PATCH 04/10] fix expenses grouping to parse occurred_at as DD-MM-YYYY strictly occurred_at comes from the backend as a DD-MM-YYYY string, which new Date() cannot parse (NaN), so most transactions fell into an "unknown" bucket and month sorting was broken. - add parseOccurredAt() that only accepts DD-MM-YYYY, day-first, and throws on any other format or invalid date - monthKey() now throws instead of returning "unknown" - add currentMonthKey() for the "this month" stat - sort groupByMonth/summary by parsed timestamp --- src/Expense/Expense.tsx | 7 +++---- src/Expense/ExpenseList.tsx | 4 ++-- src/Expense/types.ts | 29 +++++++++++++++++++++++++---- 3 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/Expense/Expense.tsx b/src/Expense/Expense.tsx index b0b2df0..c290302 100644 --- a/src/Expense/Expense.tsx +++ b/src/Expense/Expense.tsx @@ -14,7 +14,7 @@ import { useResource, useAppContext, formatCurrency } from "../../react-openapi" import { PageHeader } from "../ui/PageHeader"; import { EmptyState } from "../ui/EmptyState"; import { ExpenseList } from "./ExpenseList"; -import { ExpenseItem, ExpenseFieldConfigs, isExpense, monthKey, monthLabel, resolveLogoUrl } from "./types"; +import { ExpenseItem, ExpenseFieldConfigs, isExpense, currentMonthKey, monthKey, monthLabel, parseOccurredAt, resolveLogoUrl } from "./types"; const API_BASE = import.meta.env.VITE_API_BASE_URL; @@ -94,7 +94,7 @@ export default function Expense() { const sorted = useMemo( () => [...(items ?? [])].sort( - (a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(), + (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), ), [items], ); @@ -103,8 +103,7 @@ export default function Expense() { const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; const totalSpent = sorted.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0); const totalIncome = sorted.filter((it) => !isExpense(it)).reduce((s, it) => s + (it.amount ?? 0), 0); - const now = new Date(); - const thisMonth = monthKey(now.toISOString()); + const thisMonth = currentMonthKey(); const monthItems = sorted.filter((it) => monthKey(it.occurred_at) === thisMonth); const monthTotal = monthItems.filter(isExpense).reduce((s, it) => s + (it.amount ?? 0), 0); return { currency, totalSpent, totalIncome, thisMonth: monthLabel(thisMonth), monthItems, monthTotal }; diff --git a/src/Expense/ExpenseList.tsx b/src/Expense/ExpenseList.tsx index 0e07670..ee48be3 100644 --- a/src/Expense/ExpenseList.tsx +++ b/src/Expense/ExpenseList.tsx @@ -9,7 +9,7 @@ import { import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi"; import type { ExpenseItem, ExpenseFieldConfigs } from "./types"; -import { monthKey, monthLabel } from "./types"; +import { monthKey, monthLabel, parseOccurredAt } from "./types"; import { ExpenseDetail } from "./ExpenseDetail"; interface GroupedMonth { @@ -30,7 +30,7 @@ function groupByMonth(items: ExpenseItem[]): GroupedMonth[] { return [...map.entries()] .map(([key, list]) => { const sorted = [...list].sort( - (a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(), + (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), ); const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; const total = sorted.reduce((sum, it) => sum + (it.amount ?? 0), 0); diff --git a/src/Expense/types.ts b/src/Expense/types.ts index 746eaaf..1bd3ff9 100644 --- a/src/Expense/types.ts +++ b/src/Expense/types.ts @@ -36,13 +36,34 @@ export function resolveLogoUrl(logo?: string, base?: string): string | undefined return logo; } -export function monthKey(iso?: string): string { - if (!iso) return "unknown"; - const d = new Date(iso); - if (Number.isNaN(d.getTime())) return "unknown"; +export function parseOccurredAt(value?: string): Date { + const m = value?.match(/^(\d{2})-(\d{2})-(\d{4})$/); + if (!m) { + throw new Error(`Expense occurred_at is not DD-MM-YYYY: ${value}`); + } + const [, dd, mm, yyyy] = m; + const d = new Date(Number(yyyy), Number(mm) - 1, Number(dd)); + if ( + Number.isNaN(d.getTime()) || + d.getDate() !== Number(dd) || + d.getMonth() !== Number(mm) - 1 || + d.getFullYear() !== Number(yyyy) + ) { + throw new Error(`Invalid expense occurred_at date: ${value}`); + } + return d; +} + +export function monthKey(value?: string): string { + const d = parseOccurredAt(value); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; } +export function currentMonthKey(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; +} + export function monthLabel(key: string): string { const [y, m] = key.split("-").map(Number); if (!y || !m) return key; -- 2.49.1 From cc940ee6e3732ace5ca96f4a2ac00043f81dc1c4 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Mon, 17 Aug 2026 20:15:39 +0530 Subject: [PATCH 05/10] expense list: split month totals, sticky headers + month pill - show spent and income totals separately per month header (debits red, credits green) instead of a single net amount - pin the current month header below the navbar while scrolling its section (glass backdrop, divider), Stripe-style grouping - add a floating month pill that updates via scroll-spy so the active month stays visible across long feeds --- src/Expense/ExpenseList.tsx | 120 ++++++++++++++++++++++++++++++++---- 1 file changed, 108 insertions(+), 12 deletions(-) diff --git a/src/Expense/ExpenseList.tsx b/src/Expense/ExpenseList.tsx index ee48be3..93b00c2 100644 --- a/src/Expense/ExpenseList.tsx +++ b/src/Expense/ExpenseList.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, Typography, @@ -6,16 +6,18 @@ import { AccordionSummary, AccordionDetails, } from "@mui/material"; +import { alpha } from "@mui/material/styles"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi"; import type { ExpenseItem, ExpenseFieldConfigs } from "./types"; -import { monthKey, monthLabel, parseOccurredAt } from "./types"; +import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types"; import { ExpenseDetail } from "./ExpenseDetail"; interface GroupedMonth { key: string; items: ExpenseItem[]; - total: number; + spent: number; + income: number; currency: string; } @@ -33,8 +35,9 @@ function groupByMonth(items: ExpenseItem[]): GroupedMonth[] { (a, b) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), ); const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; - const total = sorted.reduce((sum, it) => sum + (it.amount ?? 0), 0); - return { key, items: sorted, total, currency }; + const spent = sorted.filter(isExpense).reduce((sum, it) => sum + Math.abs(it.amount ?? 0), 0); + const income = sorted.filter((it) => !isExpense(it)).reduce((sum, it) => sum + (it.amount ?? 0), 0); + return { key, items: sorted, spent, income, currency }; }) .sort((a, b) => b.key.localeCompare(a.key)); } @@ -129,16 +132,70 @@ interface ExpenseListProps { export function ExpenseList({ items, fields }: ExpenseListProps) { const [expandedId, setExpandedId] = useState(null); + const [activeMonth, setActiveMonth] = useState(null); const groups = useMemo(() => groupByMonth(items), [items]); + const listRef = useRef(null); const handleToggle = useCallback((id: string) => { setExpandedId((prev) => (prev === id ? null : id)); }, []); + useEffect(() => { + const root = listRef.current; + if (!root || groups.length === 0) return; + let ticking = false; + + const update = () => { + ticking = false; + const headers = root.querySelectorAll("[data-month-header]"); + let current: string | null = null; + for (const header of headers) { + if (header.getBoundingClientRect().top <= 72) { + current = header.dataset.monthHeader ?? null; + } else { + break; + } + } + setActiveMonth(current); + }; + + const onScroll = () => { + if (!ticking) { + ticking = true; + requestAnimationFrame(update); + } + }; + + update(); + window.addEventListener("scroll", onScroll, { passive: true }); + window.addEventListener("resize", onScroll); + return () => { + window.removeEventListener("scroll", onScroll); + window.removeEventListener("resize", onScroll); + }; + }, [groups]); + return ( - - {groups.map((group) => ( - - + <> + + {groups.map((group) => ( + + alpha(theme.palette.background.default, 0.85), + backdropFilter: "blur(8px)", + borderBottom: "1px solid", + borderColor: "divider", + }} + > {monthLabel(group.key)} @@ -146,8 +203,14 @@ export function ExpenseList({ items, fields }: ExpenseListProps) { {group.items.length} transaction{group.items.length === 1 ? "" : "s"} - - {formatCurrency(group.total, group.currency)} + + {formatCurrency(group.spent, group.currency)} + + + / + + + {formatCurrency(group.income, group.currency)} @@ -165,7 +228,40 @@ export function ExpenseList({ items, fields }: ExpenseListProps) { ))} - + + + + alpha(theme.palette.background.default, 0.85), + backdropFilter: "blur(8px)", + border: "1px solid", + borderColor: "divider", + boxShadow: 1, + }} + > + + {activeMonth ? monthLabel(activeMonth) : ""} + + + + ); } -- 2.49.1 From 47d799fe5fca488af0233b199635c96af4ac5b2f Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Mon, 17 Aug 2026 20:38:28 +0530 Subject: [PATCH 06/10] expense list: clickable month pill + fix scroll-to-month - make the floating month pill interactive (button role, hover, focus ring) and jump to the active month's header on click/Enter - scroll via a non-sticky month anchor + window.scrollTo instead of scrollIntoView, which no-ops on sticky headers that are always already in view --- src/Expense/ExpenseList.tsx | 42 ++++++++++++++++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/Expense/ExpenseList.tsx b/src/Expense/ExpenseList.tsx index 93b00c2..801ca1f 100644 --- a/src/Expense/ExpenseList.tsx +++ b/src/Expense/ExpenseList.tsx @@ -8,6 +8,7 @@ import { } from "@mui/material"; import { alpha } from "@mui/material/styles"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import KeyboardArrowUpIcon from "@mui/icons-material/KeyboardArrowUp"; import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi"; import type { ExpenseItem, ExpenseFieldConfigs } from "./types"; import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types"; @@ -139,6 +140,27 @@ export function ExpenseList({ items, fields }: ExpenseListProps) { setExpandedId((prev) => (prev === id ? null : id)); }, []); + const jumpToActiveMonth = useCallback(() => { + if (!activeMonth) return; + const anchor = listRef.current?.querySelector( + `[data-month-anchor="${activeMonth}"]`, + ); + if (!anchor) return; + const offset = window.matchMedia("(min-width: 900px)").matches ? 64 : 56; + const top = anchor.getBoundingClientRect().top + window.scrollY - offset; + window.scrollTo({ top, behavior: "smooth" }); + }, [activeMonth]); + + const handlePillKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + jumpToActiveMonth(); + } + }, + [jumpToActiveMonth], + ); + useEffect(() => { const root = listRef.current; if (!root || groups.length === 0) return; @@ -179,6 +201,7 @@ export function ExpenseList({ items, fields }: ExpenseListProps) { {groups.map((group) => ( +