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/package-lock.json b/package-lock.json index 602fbae..0ed9598 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ "@emotion/styled": "^11.13.0", "@mui/icons-material": "^5.16.7", "@mui/material": "^5.16.7", - "@mui/x-data-grid": "^7.22.0", "@tanstack/react-query": "^5.96.1", "axios": "^1.7.7", "buffer": "^6.0.3", @@ -1097,201 +1096,6 @@ } } }, - "node_modules/@mui/private-theming": { - "version": "7.3.11", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.11.tgz", - "integrity": "sha512-9B+YKms0fRHbNrqp9tOT/DNbNnU5gyvJ1o3qAGXfq8GmZcbJnE3At9x07Zr/o0pkhzg4aDdwXVqe4+AcgtOCPA==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.28.6", - "@mui/utils": "^7.3.11", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/private-theming/node_modules/@mui/types": { - "version": "7.4.12", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", - "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.28.6" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/private-theming/node_modules/@mui/utils": { - "version": "7.3.11", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", - "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.28.6", - "@mui/types": "^7.4.12", - "@types/prop-types": "^15.7.15", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.2.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/styled-engine": { - "version": "7.3.10", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.10.tgz", - "integrity": "sha512-WxE9SiF8xskAQqGjsp0poXCkCqsoXFEsSr0HBXfApmGHR+DBnXRp+z46Vsltg4gpPM4Z96DeAQRpeAOnhNg7Ng==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.28.6", - "@emotion/cache": "^11.14.0", - "@emotion/serialize": "^1.3.3", - "@emotion/sheet": "^1.4.0", - "csstype": "^3.2.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } - } - }, - "node_modules/@mui/system": { - "version": "7.3.11", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.11.tgz", - "integrity": "sha512-7izwGWdNawAKpBKcRlx7f2gFnAAjmASBWvMcyX4YYEeLOFsbfGRbUYGInvnAcUeql3rPxI7F9Ft4oY2OLRz44g==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.28.6", - "@mui/private-theming": "^7.3.11", - "@mui/styled-engine": "^7.3.10", - "@mui/types": "^7.4.12", - "@mui/utils": "^7.3.11", - "clsx": "^2.1.1", - "csstype": "^3.2.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/system/node_modules/@mui/types": { - "version": "7.4.12", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.12.tgz", - "integrity": "sha512-iKNAF2u9PzSIj40CjvKJWxFXJo122jXVdrmdh0hMYd+FR+NuJMkr/L88XwWLCRiJ5P1j+uyac25+Kp6YC4hu6w==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.28.6" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/system/node_modules/@mui/utils": { - "version": "7.3.11", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.11.tgz", - "integrity": "sha512-XTjGnifwteg71/ij+0e7Y7d+hwyntMYP5wPoA/g2drdGH+Flkvjwy0OfrVpKBbaOvofq4zU/LIyUZyKgmWu18g==", - "peer": true, - "dependencies": { - "@babel/runtime": "^7.28.6", - "@mui/types": "^7.4.12", - "@types/prop-types": "^15.7.15", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.2.3" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@mui/types": { "version": "7.2.24", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", @@ -1334,62 +1138,6 @@ } } }, - "node_modules/@mui/x-data-grid": { - "version": "7.29.13", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.29.13.tgz", - "integrity": "sha512-XHrZTvpa61eqSgUIevDzXYfYCbI7cbN/aRxSCaw2cZzQZ/USdpECYbnTEhgw5XgUlvkAK0mItKZmgKM4X7zT+Q==", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0", - "@mui/x-internals": "7.29.0", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "reselect": "^5.1.1", - "use-sync-external-store": "^1.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.9.0", - "@emotion/styled": "^11.8.1", - "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0", - "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } - } - }, - "node_modules/@mui/x-internals": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz", - "integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==", - "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/@popperjs/core": { "version": "2.11.8", "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", @@ -4174,11 +3922,6 @@ "node": ">=0.10.0" } }, - "node_modules/reselect": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.2.0.tgz", - "integrity": "sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==" - }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -4516,14 +4259,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/use-sync-external-store": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", - "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/package.json b/package.json index 8989bbb..bda4408 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "@emotion/styled": "^11.13.0", "@mui/icons-material": "^5.16.7", "@mui/material": "^5.16.7", - "@mui/x-data-grid": "^7.22.0", "@tanstack/react-query": "^5.96.1", "axios": "^1.7.7", "buffer": "^6.0.3", 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/README.md b/react-openapi/README.md index 10e6055..961f333 100644 --- a/react-openapi/README.md +++ b/react-openapi/README.md @@ -14,7 +14,7 @@ Auto-generates an admin panel (CRUD, datatable, relationship management) from an ```bash npm install react-openapi # peer dependencies (install these in your app): -npm install react react-dom react-router-dom @mui/material @mui/icons-material @mui/x-data-grid @emotion/react @emotion/styled axios js-yaml +npm install react react-dom react-router-dom @mui/material @mui/icons-material @emotion/react @emotion/styled axios js-yaml ``` ## Quick Start 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/package.json b/react-openapi/package.json index 7fa45d3..b16a5f1 100644 --- a/react-openapi/package.json +++ b/react-openapi/package.json @@ -11,7 +11,6 @@ "react-router-dom": "^6.28.0", "@mui/material": "^5.16.7", "@mui/icons-material": "^5.16.7", - "@mui/x-data-grid": "^7.22.0", "@emotion/react": "^11.13.3", "@emotion/styled": "^11.13.0", "axios": "^1.7.7", 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" }, }, }} > - + {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 ( (null); if (value === null || value === undefined) { @@ -137,7 +138,11 @@ export function ListCellRenderer({ field, value, displayFormat, basePath }: List } if (field.uiType === "image" && value) { - return ; + return ; + } + + if (field.uiType === "currency" && value != null && !Number.isNaN(Number(value))) { + return ; } if (field.type === "boolean") { 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..6adf0cf --- /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(Math.abs(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/components/fields/renderers/FileUploadField.tsx b/react-openapi/src/components/fields/renderers/FileUploadField.tsx index 1bfc338..803b9ca 100644 --- a/react-openapi/src/components/fields/renderers/FileUploadField.tsx +++ b/react-openapi/src/components/fields/renderers/FileUploadField.tsx @@ -2,6 +2,8 @@ import React from "react"; import { Box, Typography, Avatar, Chip, Button, FormHelperText } from "@mui/material"; import type { FieldConfig } from "../../../types"; import { getApi } from "../../../hooks/useApi"; +import { useAppContext } from "../../../context/AppContext"; +import { resolveMediaUrl } from "../utils"; interface Props { field: FieldConfig; @@ -19,6 +21,7 @@ const acceptMap: Record = { export function FileUploadField({ field, value, onChange }: Props) { const uploadConfig = field.upload!; const inputRef = React.useRef(null); + const { config } = useAppContext(); const handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; @@ -51,12 +54,12 @@ export function FileUploadField({ field, value, onChange }: Props) { {value ? ( uploadConfig.type === "image" ? ( - + ) : ( diff --git a/react-openapi/src/components/fields/renderers/ImageField.tsx b/react-openapi/src/components/fields/renderers/ImageField.tsx index 0292ba0..0f8cb52 100644 --- a/react-openapi/src/components/fields/renderers/ImageField.tsx +++ b/react-openapi/src/components/fields/renderers/ImageField.tsx @@ -3,6 +3,8 @@ import { Box, Typography, Avatar, FormHelperText } from "@mui/material"; import Button from "@mui/material/Button"; import type { FieldConfig } from "../../../types"; import { getApi } from "../../../hooks/useApi"; +import { useAppContext } from "../../../context/AppContext"; +import { resolveMediaUrl } from "../utils"; interface Props { field: FieldConfig; @@ -13,6 +15,7 @@ interface Props { } export function ImageField({ field, value, onChange, id, uploadUrl }: Props) { + const { config } = useAppContext(); const handleUpload = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; @@ -47,7 +50,7 @@ export function ImageField({ field, value, onChange, id, uploadUrl }: Props) { {field.label} {value ? ( - + ) : ( + } + /> + + {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")} + /> + + ) : ( + <> + + + + + + + {fieldConfigs && } + + )} + + ); +} \ No newline at end of file diff --git a/src/Expense/ExpenseList.tsx b/src/Expense/ExpenseList.tsx new file mode 100644 index 0000000..5b10561 --- /dev/null +++ b/src/Expense/ExpenseList.tsx @@ -0,0 +1,352 @@ +import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + Box, + Typography, + Accordion, + AccordionSummary, + AccordionDetails, + Menu, + MenuItem, + ListItemText, +} from "@mui/material"; +import { alpha } from "@mui/material/styles"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import KeyboardArrowDownIcon from "@mui/icons-material/KeyboardArrowDown"; +import { ListCellRenderer, CurrencyField, applyDisplayFormat, formatCurrency } from "../../react-openapi"; +import type { ExpenseItem, ExpenseFieldConfigs } from "./types"; +import { isExpense, monthKey, monthLabel, parseOccurredAt } from "./types"; + +interface GroupedMonth { + key: string; + items: ExpenseItem[]; + spent: number; + income: 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) => parseOccurredAt(b.occurred_at).getTime() - parseOccurredAt(a.occurred_at).getTime(), + ); + const currency = sorted.find((it) => it.account?.currency)?.account?.currency ?? "INR"; + 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)); +} + +interface ExpenseRowProps { + item: ExpenseItem; + currency: string; + fields: ExpenseFieldConfigs; +} + +const ExpenseRow = React.memo(function ExpenseRow({ item, currency, fields }: ExpenseRowProps) { + const itemCurrency = item.account?.currency ?? currency; + + return ( + + + {item.entity?.logo ? ( + + ) : ( + + )} + + + + {item.entity ? applyDisplayFormat(item.entity, fields.formats.entity) : "Unknown"} + + + + + + {item.account?.name && ( + + )} + + + + + + ); +}); + +interface ExpenseListProps { + items: ExpenseItem[]; + fields: ExpenseFieldConfigs; +} + +export function ExpenseList({ items, fields }: ExpenseListProps) { + const [activeMonth, setActiveMonth] = useState(null); + const [openMonth, setOpenMonth] = useState(null); + const [menuAnchor, setMenuAnchor] = useState(null); + const groups = useMemo(() => groupByMonth(items), [items]); + const listRef = useRef(null); + const pillRef = useRef(null); + const didInitOpenMonth = useRef(false); + + useEffect(() => { + if (!didInitOpenMonth.current && groups.length > 0) { + didInitOpenMonth.current = true; + setOpenMonth(groups[0].key); + } + }, [groups]); + + const scrollToMonth = useCallback((key: string) => { + const anchor = listRef.current?.querySelector( + `[data-month-anchor="${key}"]`, + ); + 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" }); + }, []); + + const handleSelectMonth = useCallback( + (key: string) => { + setMenuAnchor(null); + setOpenMonth(key); + scrollToMonth(key); + }, + [scrollToMonth], + ); + + const handlePillKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + if (pillRef.current) setMenuAnchor(pillRef.current); + } + }, + [], + ); + + const selectedMonth = activeMonth ?? openMonth ?? groups[0]?.key ?? null; + + 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) => ( + + + ))} + + + + setMenuAnchor(e.currentTarget)} + onKeyDown={handlePillKeyDown} + aria-haspopup="menu" + aria-expanded={Boolean(menuAnchor)} + sx={{ + display: "flex", + alignItems: "center", + gap: 0.5, + px: 1.5, + py: 0.75, + borderRadius: "999px", + cursor: "pointer", + userSelect: "none", + backgroundColor: (theme) => alpha(theme.palette.background.default, 0.85), + backdropFilter: "blur(8px)", + border: "1px solid", + borderColor: "divider", + boxShadow: 1, + transition: "background-color 160ms ease, transform 160ms ease", + "&:hover": { + backgroundColor: (theme) => alpha(theme.palette.background.default, 0.95), + transform: "translateY(-1px)", + }, + "&:focus-visible": { + outline: "2px solid", + outlineColor: "primary.main", + }, + }} + > + + {activeMonth ? monthLabel(activeMonth) : ""} + + + + + + setMenuAnchor(null)} + anchorOrigin={{ vertical: "top", horizontal: "center" }} + transformOrigin={{ vertical: "bottom", horizontal: "center" }} + > + {groups.map((group) => ( + handleSelectMonth(group.key)} + > + + + ))} + + + ); +} + +export { groupByMonth }; \ No newline at end of file diff --git a/src/Expense/types.ts b/src/Expense/types.ts new file mode 100644 index 0000000..efe769e --- /dev/null +++ b/src/Expense/types.ts @@ -0,0 +1,65 @@ +import type { FieldConfig } from "../../react-openapi"; + +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 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; +} + +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; + 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/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/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" }, }, }, }; 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; +}