Stripe-style UI overhaul + Expenses feed with month grouping (#15)

## Title
Stripe-style UI overhaul + Expenses feed with month grouping

## Summary
Rework the khata-ui frontend to a Stripe-grade design system and add a
rich, monthly-grouped Expenses experience driven by react-openapi's
shared, spec-configured field renderers.

## Highlights

### Design overhaul
- New design system: single indigo (#635BFF), 6px radius, Inter type
  scale, streamlined surfaces/inputs/feedback tokens
- Sticky app bar with brand-left header + inline footer, route titles
- Split-panel auth pages, Stripe-style Home hero + feature cards
- Toast provider + shared PageHeader/EmptyState UI primitives
- Admin polish: right-aligned numerics, row-hover actions, skeletons,
  richer empty states, breadcrumbed fetch-request flows

### Expenses page
- Monthly grouped feed with stat cards (total spent / this month / income)
- Month cards as single-open accordions with red-spent / green-income totals
- Flat transaction rows: logo, name/date, account, amount (no accordion)
- Floating month–year selector pill (scroll-spy) that expands + scrolls
  to the selected month
- Strict DD-MM-YYYY date parsing (day-first), no format guessing

### Shared react-openapi fields
- New `CurrencyField` + `formatCurrency` (cached Intl, sign-colored,
  sign-less amounts; form editing reuses NumberField)
- `resourceConfig.fieldTypes` override mechanism (amount → currency)
- Expenses page rendered via ListCellRenderer / DetailFieldRenderer /
  applyDisplayFormat — zero custom field renderers
- `resolveMediaUrl` resolves relative `/uploads/...` against the API
  base for every image field (Admin + Expenses logos)

### Fixes
- Perf: memoized rows, formatter caches, single-open accordions —
  cuts slow-click INP and DOM-nesting warnings
- Removed unused @mui/x-data-grid@7, which hoisted @mui/system@7 and
  crashed Box/createTheme at runtime (v5/v7 mix)

## Commits (10)
- 9808a1f design overhaul + expenses page
- d6856a5 fix expenses list performance + dom nesting
- 3dd833a shared currency field + expenses rendered from react-openapi fields
- e8c585b fix expenses grouping to parse occurred_at as DD-MM-YYYY strictly
- cc940ee expense list: split month totals, sticky headers + month pill
- 47d799f expense list: clickable month pill + fix scroll-to-month
- 537aef9 resolve relative media URLs against the API base in react-openapi
- 8e9a13d expense list: replace accordion with flat Stripe-style rows
- 62dd06c expense list: month accordions + month-year selector; drop unused x-data-grid
- 806bd42 dropped amount sign from CurrencyField.tsx

Reviewed-on: #15
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
This commit is contained in:
2026-08-18 13:51:28 +00:00
committed by aetos
parent 51762f8d18
commit 25ec534597
37 changed files with 1805 additions and 1067 deletions

View File

@@ -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<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
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 (
<Paper sx={{ p: 3, borderRadius: 3, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mb: 2 }}>
<Typography variant="subtitle1" fontWeight={700} sx={{ flex: 1 }}>
{title}
</Typography>
{action}
</Box>
{children}
</Paper>
);
}
export default function FetchRequestDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
@@ -75,7 +90,7 @@ export default function FetchRequestDetail() {
const [stepStats, setStepStats] = useState<Record<string, number>>({});
const [liveParsedCount, setLiveParsedCount] = useState<number | undefined>(undefined);
const [retrying, setRetrying] = useState(false);
const [failNotif, setFailNotif] = useState<string | null>(null);
const { showToast } = useToast();
const feedRef = useRef<HTMLDivElement>(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 (
<Container sx={{ mt: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back
</Button>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests", path: "/fetch-requests" }, { label: "Details" }]}
title="Fetch Request"
/>
<Alert severity="error">Failed to load fetch request</Alert>
</Container>
);
}
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate("/fetch-requests")} sx={{ mb: 2 }}>
Back to Fetch Requests
</Button>
<Container maxWidth="lg" sx={{ py: 4 }}>
<PageHeader
crumbs={[{ label: "Home", path: "/" }, { label: "Fetch Requests", path: "/fetch-requests" }, { label: displayTitle }]}
title={displayTitle}
subtitle="Live pipeline status, progress events, and ambiguity resolution for this import."
/>
<Paper sx={{ p: 3, borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Section
title="Overview"
action={
<Chip
icon={statusIcons[status] as any}
label={status.replace(/_/g, " ")}
color={statusColors[status]}
sx={{ textTransform: "capitalize" }}
/>
<Typography variant="h6" fontWeight={600}>{displayTitle}</Typography>
}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 2, mb: 2, flexWrap: "wrap" }}>
<Chip
label={"path" in (req.source ?? {}) ? "File" : "Email"}
size="small"
variant="outlined"
color={"path" in (req.source ?? {}) ? "primary" : "secondary"}
/>
<Typography variant="caption" color="text.secondary">
ID: {req.id}
</Typography>
</Box>
<Box sx={{ display: "flex", gap: 4, flexWrap: "wrap", mb: 2 }}>
@@ -242,6 +267,8 @@ export default function FetchRequestDetail() {
})}
</Box>
<Divider sx={{ my: 2 }} />
<Box sx={{ display: "flex", alignItems: "center", gap: 2 }}>
<Box sx={{ flex: 1, maxWidth: 300 }}>
<Typography variant="caption" color="text.secondary">
@@ -260,7 +287,7 @@ export default function FetchRequestDetail() {
</Button>
)}
</Box>
</Paper>
</Section>
{status === "failed" && req.error_message && (
<Alert severity="error" sx={{ mb: 3, borderRadius: 2 }}>
@@ -280,33 +307,33 @@ export default function FetchRequestDetail() {
liveParsedCount={liveParsedCount ?? 0}
/>
<Paper sx={{ borderRadius: 4, mb: 3 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", gap: 1, p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600} sx={{ flex: 1 }}>
Progress Events
</Typography>
<Box
sx={{
width: 10,
height: 10,
borderRadius: "50%",
bgcolor: sseConnected ? "success.main" : "error.main",
flexShrink: 0,
}}
/>
<Typography variant="caption" color="text.secondary">
{sseConnected ? "Connected" : "Disconnected"}
</Typography>
</Box>
<Section
title="Progress Events"
action={
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<Box
sx={{
width: 8,
height: 8,
borderRadius: "50%",
bgcolor: sseConnected ? "success.main" : "error.main",
}}
/>
<Typography variant="caption" color="text.secondary">
{sseConnected ? "Connected" : "Disconnected"}
</Typography>
</Box>
}
>
<Box
ref={feedRef}
sx={{
maxHeight: 300,
overflowY: "auto",
p: 2,
display: "flex",
flexDirection: "column",
gap: 1,
pr: 0.5,
}}
>
{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() {
))
)}
</Box>
</Paper>
</Section>
<AmbiguityResolver fetchRequestId={id!} />
<Snackbar
open={!!failNotif}
autoHideDuration={6000}
onClose={() => setFailNotif(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
<Alert severity="error" onClose={() => setFailNotif(null)} sx={{ borderRadius: 2 }}>
{failNotif}
</Alert>
</Snackbar>
</Container>
);
}