12 Commits

59 changed files with 1069 additions and 2985 deletions

View File

@@ -7,28 +7,6 @@ import { createApiClient } from "../../react-auth";
let _api: AxiosInstance | null = null; let _api: AxiosInstance | null = null;
let _auth: AxiosInstance | null = null; let _auth: AxiosInstance | null = null;
function withParamsSerializer(instance: AxiosInstance): AxiosInstance {
instance.defaults.paramsSerializer = {
serialize: (params) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => {
searchParams.append(key, String(v)); // NO []
});
} else if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
return searchParams.toString();
},
};
return instance;
}
export const api = { export const api = {
get: (...args: Parameters<AxiosInstance["get"]>) => { get: (...args: Parameters<AxiosInstance["get"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
@@ -60,6 +38,6 @@ export const auth = {
}; };
export function initializeApiClients(baseUrl: string, authBaseUrl: string) { export function initializeApiClients(baseUrl: string, authBaseUrl: string) {
_api = withParamsSerializer(createApiClient(baseUrl)); _api = createApiClient(baseUrl);
_auth = withParamsSerializer(createApiClient(authBaseUrl)); _auth = createApiClient(authBaseUrl);
} }

View File

@@ -1,4 +1,4 @@
import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "../api/client"; import { api } from "../api/client";
import { ResourceConfig } from "../types/config"; import { ResourceConfig } from "../types/config";
import { ConfigContext } from "../providers/ConfigContext"; import { ConfigContext } from "../providers/ConfigContext";
@@ -26,17 +26,16 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
}; };
}, },
enabled: !!endpoint, enabled: !!endpoint,
placeholderData: keepPreviousData,
}); });
// --- READ ONE --- // --- READ ONE ---
const useRead = (id: string, params?: any | null) => const useRead = (id: string | null) =>
useQuery({ useQuery({
queryKey: [name, "detail", id, params], queryKey: [name, "detail", id],
queryFn: async () => { queryFn: async () => {
if (!id || !endpoint) return null; if (!id || !endpoint) return null;
// @ts-ignore // @ts-ignore
const res = await api.get<T>(`${endpoint}/${id}`, params ? { params } : undefined); const res = await api.get<T>(`${endpoint}/${id}`);
return res.data; return res.data;
}, },
enabled: !!id && !!endpoint, enabled: !!id && !!endpoint,

48
src/AppTheme.tsx Normal file
View File

@@ -0,0 +1,48 @@
import * as React from "react";
import { ThemeProvider, createTheme } from "@mui/material/styles";
import { getDesignTokens } from "./shared-theme/themePrimitives";
import { inputsCustomizations } from "./shared-theme/customizations/inputs";
import { dataDisplayCustomizations } from "./shared-theme/customizations/dataDisplay";
import { feedbackCustomizations } from "./shared-theme/customizations/feedback";
import { navigationCustomizations } from "./shared-theme/customizations/navigation";
import { surfacesCustomizations } from "./shared-theme/customizations/surfaces";
export const ColorModeContext = React.createContext({
toggleColorMode: () => {},
mode: "light" as "light" | "dark",
});
export default function AppTheme({ children }: { children: React.ReactNode }) {
const [mode, setMode] = React.useState<"light" | "dark">("light");
const colorMode = React.useMemo(
() => ({
toggleColorMode: () => {
setMode((prevMode) => (prevMode === "light" ? "dark" : "light"));
},
mode,
}),
[mode]
);
const theme = React.useMemo(
() =>
createTheme({
...getDesignTokens(mode),
components: {
...inputsCustomizations,
...dataDisplayCustomizations,
...feedbackCustomizations,
...navigationCustomizations,
...surfacesCustomizations,
},
}),
[mode]
);
return (
<ColorModeContext.Provider value={colorMode}>
<ThemeProvider theme={theme}>{children}</ThemeProvider>
</ColorModeContext.Provider>
);
}

View File

@@ -2,237 +2,45 @@ import * as React from "react";
import { import {
Box, Box,
Container, Container,
Grid,
CircularProgress, CircularProgress,
Alert, Alert,
TextField, ToggleButton,
Paper, ToggleButtonGroup,
Autocomplete, Typography
Button
} from "@mui/material"; } from "@mui/material";
import DashboardView from "./components/Dashboard"; import LatestItems from "./components/LatestItems";
import HistoryChart from "./components/HistoryChart";
import ProgressCard from "./components/ProgressCard";
import { import { useDashboardData } from "./features/dashboard";
DashboardState,
DashboardStateSetters,
DashboardFlow,
} from "./components/Dashboard";
import { configuration } from "./dashboard-config";
import {
useReport,
prepareReport,
} from "./features/report";
import { useResourceByName } from "../react-openapi";
function formatSnapshotDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function Dashboard() { export default function Dashboard() {
const [state, setState] = React.useState<DashboardState>({ const [mode, setMode] = React.useState<"expense" | "income">("expense");
flow: "outflows", const [period, setPeriod] = React.useState<"rolling" | "calendar">("rolling");
periodType: "rolling", const [comparison, setComparison] = React.useState(false);
selectedPeriodId: null,
selectedGroupKey: null,
comparison: false,
});
const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]); const palette = {
const [appliedTags, setAppliedTags] = React.useState<string[]>([]); expense: {
primary: "#d32f2f",
const [payeeInput, setPayeeInput] = React.useState<string[]>([]); light: "#fdecea",
const [tagsInput, setTagsInput] = React.useState<string[]>([]); dark: "#9a0007",
text: "#b71c1c"
const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]);
const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
const [selectedSnapshotId, setSelectedSnapshotId] = React.useState<string | null>(null);
const { data: snapshotsData } = useResourceByName("reports").useList();
const snapshotOptions = React.useMemo(() => {
const options: { label: string; value: string | null }[] = [
{ label: "Latest (auto)", value: null },
];
if (snapshotsData?.data) {
for (const snap of snapshotsData.data) {
options.push({
label: `Snapshot from ${formatSnapshotDate(snap.created_at)}`,
value: snap.snapshot_id,
});
}
}
return options;
}, [snapshotsData]);
const selectedSnapshotOption = snapshotOptions.find((o) => o.value === selectedSnapshotId) ?? snapshotOptions[0];
const report = useReport({
snapshot_id: selectedSnapshotId ?? undefined,
periods: ["daily", "weekly", "monthly", "all"],
flow: state.flow,
payee: appliedPayees.length > 0 ? appliedPayees : undefined,
tags: appliedTags.length > 0 ? appliedTags : undefined,
});
React.useEffect(() => {
if (report.data) {
setLoadedPayees(prev => {
const pSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => {
if (t.payee?.name) pSet.add(t.payee.name);
});
});
});
});
return Array.from(pSet).sort();
});
setLoadedTags(prev => {
const tSet = new Set<string>(prev);
report.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => {
t.tags?.forEach((tag: any) => tSet.add(tag.name || tag));
});
});
});
});
return Array.from(tSet).sort();
});
}
}, [report.data]);
const toggleFlow =
React.useCallback(() => {
setState((prev) => ({
...prev,
flow:
prev.flow ===
"outflows"
? "inflows"
: "outflows",
selectedGroupKey:
null,
selectedPeriodId:
null,
}));
}, []);
const setFlow =
React.useCallback(
(
flow: DashboardFlow
) => {
setState((prev) => ({
...prev,
flow,
selectedGroupKey:
null,
selectedPeriodId:
null,
}));
}, },
[] income: {
); primary: "#2e7d32",
light: "#e8f5e9",
dark: "#1b5e20",
text: "#1b5e20"
}
};
const togglePeriodType = const { data, latest, isLoading, error } = useDashboardData(mode);
React.useCallback(() => {
setState((prev) => ({
...prev,
periodType: const colors = palette[mode];
prev.periodType ===
"rolling"
? "calendar"
: "rolling",
}));
}, []);
const toggleComparison = if (isLoading) {
React.useCallback(() => {
setState((prev) => ({
...prev,
comparison:
!prev.comparison,
}));
}, []);
const setSelectedPeriodId =
React.useCallback(
(
selectedPeriodId: DashboardState["selectedPeriodId"]
) => {
setState((prev) => ({
...prev,
selectedPeriodId,
}));
},
[]
);
const setSelectedGroupKey =
React.useCallback(
(
selectedGroupKey: DashboardState["selectedGroupKey"]
) => {
setState((prev) => ({
...prev,
selectedGroupKey,
}));
},
[]
);
const stateSetters: DashboardStateSetters =
React.useMemo(
() => ({
toggleFlow,
setFlow,
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
}),
[
toggleFlow,
setFlow,
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
]
);
const isLoading = report.isLoading;
const error = report.error;
if (isLoading && !report.data) {
return ( return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}> <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
<CircularProgress /> <CircularProgress />
@@ -248,93 +56,92 @@ export default function Dashboard() {
); );
} }
if (!report.data) { if (!data) {
return null; return null;
} }
const data = prepareReport(report.data);
return ( return (
<Box> <Container
<Container>
<Paper
sx={{ sx={{
mt: 4, mt: 4,
p: 2, mb: 4,
display: "flex", background: `linear-gradient(180deg, ${colors.light} 0%, transparent 100%)`,
flexDirection: { xs: "column", sm: "row" },
gap: 2,
alignItems: { xs: "stretch", sm: "flex-end" },
borderRadius: 4, borderRadius: 4,
mb: -2 // pull up to be closer to the dashboard container below p: 2
}} }}
elevation={0}
variant="outlined"
> >
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}> <Box sx={{ display: "flex", justifyContent: "center", mb: 3 }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}> <ToggleButtonGroup
Filter by Payee value={mode}
</Box> exclusive
<Autocomplete onChange={(_, val) => val && setMode(val)}
multiple sx={{
freeSolo borderRadius: 3,
options={loadedPayees} overflow: "hidden",
value={payeeInput} "& .MuiToggleButton-root": {
onChange={(_, val) => setPayeeInput(val as string[])} px: 3,
renderInput={(params) => <TextField {...params} placeholder="Add payees..." />} textTransform: "none",
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }} color: "text.secondary"
/> },
</Box> "&.Mui-selected": {
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}> bgcolor: colors.primary,
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}> color: "white",
Filter by Tags borderColor: colors.primary
</Box> },
<Autocomplete }}
multiple >
freeSolo <ToggleButton value="expense">Expenses</ToggleButton>
options={loadedTags} <ToggleButton value="income">Income</ToggleButton>
value={tagsInput} </ToggleButtonGroup>
onChange={(_, val) => setTagsInput(val as string[])}
renderInput={(params) => <TextField {...params} placeholder="Add tags..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', minWidth: { sm: 220 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Snapshot
</Box>
<Autocomplete
options={snapshotOptions}
value={selectedSnapshotOption}
onChange={(_, option) => setSelectedSnapshotId(option?.value ?? null)}
getOptionLabel={(o) => o.label}
isOptionEqualToValue={(o, v) => o.value === v.value}
renderInput={(params) => <TextField {...params} placeholder="Select snapshot..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 40, py: 0 } }}
size="small"
/>
</Box> </Box>
<Button <Grid container spacing={4} direction="row">
variant="contained" <Grid size={12}>
size="large" <HistoryChart
onClick={() => { header={`${mode === "expense" ? "Expense" : "Income"} Breakdown`}
setAppliedPayees(payeeInput); summary="Interactive chronological tracking"
setAppliedTags(tagsInput); tabs={["Daily", "Weekly", "Monthly"]}
}} data={data.chartData}
disabled={isLoading} period={period}
sx={{ height: 40, borderRadius: 2 }} onPeriodChange={setPeriod}
> comparison={comparison}
Apply setComparison={setComparison}
</Button> colorScheme={colors}
</Paper>
</Container>
<DashboardView
config={configuration}
data={data}
state={state}
stateSetters={stateSetters}
isFetching={report.isFetching}
/> />
</Grid>
{data.topPayees && data.topPayees.length > 0 && (
<Grid size={12}>
<Box sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={700}>
Top {mode === "expense" ? "Payees" : "Payors"}
</Typography>
</Box> </Box>
<Grid container spacing={2}>
{data.topPayees.map((payee: any) => (
<Grid key={payee.payeeName} size={{ xs: 12, sm: 6, md: 2.4 }}>
<ProgressCard
header={payee.payeeName}
progressAmount={payee.amount}
totalAmount={data.totalAmount}
colorTheme={mode === "expense" ? "error" : "success"}
compact
/>
</Grid>
))}
</Grid>
</Grid>
)}
<Grid size={12}>
<LatestItems
title={`Recent ${mode === "expense" ? "Expenses" : "Income"}`}
items={latest || []}
onViewAll={() => {}}
accentColor={colors.primary}
/>
</Grid>
</Grid>
</Container>
); );
} }

View File

@@ -1,323 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Paper,
Typography,
TextField,
Button,
ToggleButtonGroup,
ToggleButton,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Chip,
IconButton,
CircularProgress,
Alert,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
useFetchRequestsList,
useCreateFetchRequest,
useDeleteFetchRequest,
useUploadFile,
} from "./features/fetch-requests";
import type {
FetchRequest,
FetchRequestStatus,
FileSource,
EmailSource,
} from "./features/fetch-requests";
const statusColors: Record<FetchRequestStatus, "default" | "primary" | "warning" | "info" | "success" | "error"> = {
pending: "default",
processing: "info",
raw_expenses_done: "primary",
enriched_done: "warning",
completed: "success",
failed: "error",
};
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString();
}
export default function FetchRequests() {
const [sourceType, setSourceType] = React.useState<"file" | "email">("file");
const [accountName, setAccountName] = React.useState("");
const [payorUsername, setPayorUsername] = React.useState("aetos");
const [format, setFormat] = React.useState("");
const [file, setFile] = React.useState<File | null>(null);
const [uploadedPath, setUploadedPath] = React.useState<string | null>(null);
const [fromEmail, setFromEmail] = React.useState("");
const [subject, setSubject] = React.useState("");
const [rawTerms, setRawTerms] = React.useState("");
const [startDate, setStartDate] = React.useState("");
const [endDate, setEndDate] = React.useState("");
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<FetchRequest | null>(null);
const { data: listData, isLoading, isFetching, refetch } = useFetchRequestsList();
const createMutation = useCreateFetchRequest();
const deleteMutation = useDeleteFetchRequest();
const uploadMutation = useUploadFile();
const requests = listData?.data ?? [];
const handleUpload = async () => {
if (!file) return;
const result = await uploadMutation.mutateAsync(file);
if (result?.saved_as) {
setUploadedPath(result.saved_as);
if (!format) setFormat(file.name.split(".").pop() || "");
setSnackbar({ message: `File uploaded: ${result.saved_as}`, severity: "success" });
}
};
const handleCreate = async () => {
if (!accountName) return;
let source: FileSource | EmailSource;
if (sourceType === "file") {
if (!uploadedPath || !format) return;
source = { path: uploadedPath, format } as FileSource;
} else {
if (!format) return;
const emailSource: EmailSource = { format };
if (fromEmail) emailSource.from_email = fromEmail;
if (subject) emailSource.subject = subject;
if (rawTerms.trim()) emailSource.raw_terms = rawTerms.split(",").map((s) => s.trim()).filter(Boolean);
source = emailSource;
}
try {
await createMutation.mutateAsync({
source,
account_name: accountName,
payor_username: payorUsername,
...(startDate ? { start_date: new Date(startDate).toISOString() } : {}),
...(endDate ? { end_date: new Date(endDate).toISOString() } : {}),
});
setSnackbar({ message: "Fetch request created", severity: "success" });
resetForm();
} catch (err: any) {
setSnackbar({ message: err?.response?.data?.detail || "Failed to create fetch request", severity: "error" });
}
};
const resetForm = () => {
setAccountName("");
setFormat("");
setFile(null);
setUploadedPath(null);
setFromEmail("");
setSubject("");
setRawTerms("");
setStartDate("");
setEndDate("");
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.id);
setSnackbar({ message: "Fetch request deleted", severity: "success" });
} catch {
setSnackbar({ message: "Failed to delete", severity: "error" });
}
setDeleteTarget(null);
};
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Fetch Request Pipeline
</Typography>
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
New Fetch Request
</Typography>
<ToggleButtonGroup
value={sourceType}
exclusive
onChange={(_, val) => val && setSourceType(val)}
sx={{ mb: 3 }}
size="small"
>
<ToggleButton value="file">File Upload</ToggleButton>
<ToggleButton value="email">Email Fetch</ToggleButton>
</ToggleButtonGroup>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
{sourceType === "file" ? (
<>
<Box sx={{ display: "flex", gap: 2, alignItems: "flex-end" }}>
<Button variant="outlined" component="label" startIcon={<CloudUploadIcon />}>
Choose File
<input type="file" hidden onChange={(e) => setFile(e.target.files?.[0] ?? null)} />
</Button>
<Typography variant="body2" sx={{ flex: 1, color: "text.secondary" }}>
{file ? file.name : "No file selected"}
</Typography>
<Button
variant="contained"
onClick={handleUpload}
disabled={!file || uploadMutation.isPending}
>
{uploadMutation.isPending ? "Uploading..." : "Upload"}
</Button>
</Box>
{uploadedPath && (
<Alert severity="success" sx={{ py: 0 }}>
Uploaded as: {uploadedPath}
</Alert>
)}
<TextField label="Format (csv, pdf, ...)" value={format} onChange={(e) => setFormat(e.target.value)} size="small" />
</>
) : (
<>
<TextField label="Format" value={format} onChange={(e) => setFormat(e.target.value)} size="small" helperText="e.g. email, pdf, csv" />
<TextField label="From Email" value={fromEmail} onChange={(e) => setFromEmail(e.target.value)} size="small" />
<TextField label="Subject" value={subject} onChange={(e) => setSubject(e.target.value)} size="small" />
<TextField label="Raw Terms" value={rawTerms} onChange={(e) => setRawTerms(e.target.value)} size="small" helperText="Comma-separated search terms" />
</>
)}
<TextField label="Account Name" value={accountName} onChange={(e) => setAccountName(e.target.value)} size="small" required />
<TextField label="Payor Username" value={payorUsername} onChange={(e) => setPayorUsername(e.target.value)} size="small" helperText="Default: aetos" />
<Box sx={{ display: "flex", gap: 2 }}>
<TextField
label="Start Date"
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
sx={{ flex: 1 }}
/>
<TextField
label="End Date"
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
sx={{ flex: 1 }}
/>
</Box>
<Button
variant="contained"
onClick={handleCreate}
disabled={createMutation.isPending || !accountName || (sourceType === "file" && (!uploadedPath || !format)) || (sourceType === "email" && !format)}
>
{createMutation.isPending ? "Creating..." : "Create Fetch Request"}
</Button>
</Box>
</Paper>
<Paper sx={{ borderRadius: 4 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600}>
Fetch Requests
</Typography>
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : requests.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No fetch requests yet
</Box>
) : (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>ID</TableCell>
<TableCell>Source</TableCell>
<TableCell>Account</TableCell>
<TableCell>Status</TableCell>
<TableCell>Created</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{requests.map((req: FetchRequest) => (
<TableRow key={req.id}>
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{req.id.slice(0, 8)}...
</TableCell>
<TableCell>
{"path" in req.source ? "File" : "Email"}
</TableCell>
<TableCell>{req.account_name}</TableCell>
<TableCell>
<Chip
label={req.status.replace(/_/g, " ")}
color={statusColors[req.status]}
size="small"
/>
</TableCell>
<TableCell>{formatDate(req.created_at)}</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => setDeleteTarget(req)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Paper>
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
</Snackbar>
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Fetch Request?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the fetch request and all associated data.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -20,7 +20,7 @@ import DarkModeIcon from "@mui/icons-material/DarkMode";
import LightModeIcon from "@mui/icons-material/LightMode"; import LightModeIcon from "@mui/icons-material/LightMode";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAuth } from "../react-auth"; import { useAuth } from "../react-auth";
import { ColorModeContext } from "./shared-theme/AppTheme"; import { ColorModeContext } from "./AppTheme";
interface HeaderProps { interface HeaderProps {
routerMapping: { routerMapping: {
@@ -91,32 +91,6 @@ export default function Header({
<span style={{ flexGrow: 1 }} /> <span style={{ flexGrow: 1 }} />
{/* NAV LINKS */}
<Box
sx={{
display: { xs: "none", md: "flex" },
alignItems: "center",
mr: 2,
gap: 1,
}}
>
{[
{ label: "Dashboard", path: "/dashboard" },
{ label: "Fetch", path: "/fetch-requests" },
{ label: "Reports", path: "/reports" },
].map(({ label, path }) => (
<Button
key={path}
color="inherit"
onClick={() => navigate(path)}
sx={{ textTransform: "none", fontWeight: 500, px: 1.5 }}
size="small"
>
{label}
</Button>
))}
</Box>
{/* AUTH SECTION */} {/* AUTH SECTION */}
{isAuthenticated ? ( {isAuthenticated ? (
<> <>

View File

@@ -1,180 +1,70 @@
import * as React from "react"; import * as React from "react";
import { Box, Typography, Button, Container, Grid, Paper, Chip } from "@mui/material"; import { Box, Typography, Button, Container, Stack } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import DashboardIcon from "@mui/icons-material/Dashboard";
import SyncIcon from "@mui/icons-material/Sync";
import BarChartIcon from "@mui/icons-material/BarChart";
import SettingsIcon from "@mui/icons-material/Settings";
import ArrowForwardIcon from "@mui/icons-material/ArrowForward"; import ArrowForwardIcon from "@mui/icons-material/ArrowForward";
import { useAuth } from "../react-auth";
interface FeatureCardProps {
icon: React.ReactNode;
title: string;
description: string;
path: string;
label?: string;
accent: string;
}
function FeatureCard({ icon, title, description, path, label, accent }: FeatureCardProps) {
const navigate = useNavigate();
const theme = useTheme();
return (
<Paper
elevation={0}
onClick={() => navigate(path)}
sx={{
p: 3,
borderRadius: 3,
border: "1px solid",
borderColor: "divider",
cursor: "pointer",
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",
},
"&: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 },
},
}}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, mb: 1.5 }}>
<Box
sx={{
width: 40,
height: 40,
borderRadius: 2,
display: "flex",
alignItems: "center",
justifyContent: "center",
background: alpha(accent, 0.12),
color: accent,
}}
>
{icon}
</Box>
<Typography variant="subtitle1" fontWeight={700}>
{title}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ flex: 1, lineHeight: 1.6 }}>
{description}
</Typography>
{label && (
<Chip
label={label}
size="small"
variant="outlined"
sx={{ mt: 2, alignSelf: "flex-start", textTransform: "capitalize" }}
/>
)}
</Paper>
);
}
export default function Home() { export default function Home() {
const navigate = useNavigate(); const navigate = useNavigate();
const theme = useTheme();
const { currentUser } = useAuth();
const features = [
{
icon: <DashboardIcon />,
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: <SyncIcon />,
title: "Fetch Requests",
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: <BarChartIcon />,
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: <SettingsIcon />,
title: "Admin",
description: "Full CRUD over accounts, expenses, tags, and payors. Manage your data programmatically through the OpenAPI-driven admin panel.",
path: "/admin",
accent: theme.palette.mode === "dark" ? "#e879f9" : "#d946ef",
},
];
return ( return (
<Box <Box
sx={{ sx={{
minHeight: "calc(100vh - 64px)", width: "100%",
minHeight: "calc(100vh - 64px)", // accounting for header
display: "flex", display: "flex",
flexDirection: "column", alignItems: "center",
justifyContent: "center",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
"&::before": { "&::before": {
content: '""', content: '""',
position: "absolute", position: "absolute",
top: "-15%", top: "-20%",
left: "-8%", left: "-10%",
width: "45%", width: "50%",
height: "55%", height: "60%",
background: "radial-gradient(circle, rgba(99,102,241,0.12) 0%, transparent 70%)", background: "radial-gradient(circle, rgba(99,102,241,0.15) 0%, rgba(0,0,0,0) 70%)",
zIndex: 0, zIndex: 0,
}, },
"&::after": { "&::after": {
content: '""', content: '""',
position: "absolute", position: "absolute",
bottom: "-15%", bottom: "-20%",
right: "-8%", right: "-10%",
width: "45%", width: "50%",
height: "55%", height: "60%",
background: "radial-gradient(circle, rgba(236,72,153,0.1) 0%, transparent 70%)", background: "radial-gradient(circle, rgba(236,72,153,0.15) 0%, rgba(0,0,0,0) 70%)",
zIndex: 0, zIndex: 0,
}, },
}} }}
> >
<Container maxWidth="lg" sx={{ position: "relative", zIndex: 1, flex: 1, display: "flex", flexDirection: "column", justifyContent: "center", py: 6 }}> <Container maxWidth="lg" sx={{ position: "relative", zIndex: 1 }}>
<Box <Stack
spacing={4}
alignItems="center"
textAlign="center"
sx={{ sx={{
textAlign: "center", p: { xs: 4, md: 8 },
mb: 6, backdropFilter: "blur(20px)",
backgroundColor: (theme) =>
theme.palette.mode === "dark" ? "rgba(255, 255, 255, 0.03)" : "rgba(255, 255, 255, 0.6)",
border: "1px solid",
borderColor: "divider",
borderRadius: 4,
boxShadow: (theme) =>
theme.palette.mode === "dark"
? "0 8px 32px 0 rgba(0, 0, 0, 0.37)"
: "0 8px 32px 0 rgba(31, 38, 135, 0.07)",
}} }}
> >
<Typography <Typography
variant="h1" variant="h1"
sx={{ sx={{
fontWeight: 800, fontWeight: 800,
fontSize: { xs: "2.5rem", sm: "3.5rem", md: "5rem" }, fontSize: { xs: "3rem", md: "5rem" },
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 50%, #f59e0b 100%)", background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
WebkitBackgroundClip: "text", WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent", WebkitTextFillColor: "transparent",
letterSpacing: "-0.03em",
mb: 2, mb: 2,
}} }}
> >
@@ -182,20 +72,14 @@ export default function Home() {
</Typography> </Typography>
<Typography <Typography
variant="h6" variant="h5"
color="text.secondary" color="text.secondary"
sx={{ sx={{ maxWidth: "600px", lineHeight: 1.6 }}
maxWidth: 580,
mx: "auto",
lineHeight: 1.7,
fontWeight: 400,
fontSize: { xs: "1rem", md: "1.15rem" },
}}
> >
Your intelligent, extensible financial ledger. Import transactions, generate reports, and stay on top of your cashflow. Your intelligent, extensible financial ledger. Control accounts, manage transactions, and track your data dynamically with our OpenAPI-driven architecture.
</Typography> </Typography>
<Box sx={{ mt: 4, display: "flex", gap: 2, justifyContent: "center", flexWrap: "wrap" }}> <Box mt={4}>
<Button <Button
variant="contained" variant="contained"
size="large" size="large"
@@ -203,44 +87,21 @@ export default function Home() {
onClick={() => navigate("/dashboard")} onClick={() => navigate("/dashboard")}
sx={{ sx={{
px: 4, px: 4,
py: 1.4, py: 1.5,
borderRadius: "50px", borderRadius: "50px",
fontWeight: 700, fontWeight: "bold",
background: "linear-gradient(135deg, #6366f1 0%, #ec4899 100%)", background: "linear-gradient(45deg, #6366f1 30%, #ec4899 90%)",
transition: "transform 0.2s ease, box-shadow 0.2s", transition: "transform 0.2s ease-in-out, box-shadow 0.2s",
"&:hover": { "&:hover": {
transform: "translateY(-2px)", transform: "translateY(-3px)",
boxShadow: `0 8px 24px ${alpha(theme.palette.primary.main, 0.35)}`, boxShadow: "0 8px 20px rgba(236,72,153,0.4)",
}, },
}} }}
> >
Enter Dashboard Enter Dashboard
</Button> </Button>
<Button
variant="outlined"
size="large"
onClick={() => navigate("/fetch-requests")}
sx={{
px: 4,
py: 1.4,
borderRadius: "50px",
fontWeight: 600,
borderWidth: 2,
"&:hover": { borderWidth: 2 },
}}
>
Import Data
</Button>
</Box> </Box>
</Box> </Stack>
<Grid container spacing={3}>
{features.map((f) => (
<Grid key={f.title} size={{ xs: 12, sm: 6, md: 3 }}>
<FeatureCard {...f} />
</Grid>
))}
</Grid>
</Container> </Container>
</Box> </Box>
); );

View File

@@ -1,271 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Paper,
Typography,
TextField,
Button,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
IconButton,
CircularProgress,
Alert,
Snackbar,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
Switch,
FormControlLabel,
Chip,
} from "@mui/material";
import DeleteIcon from "@mui/icons-material/Delete";
import AddCircleIcon from "@mui/icons-material/AddCircle";
import RefreshIcon from "@mui/icons-material/Refresh";
import {
useReportSnapshotsList,
useCreateSnapshot,
useDeleteSnapshot,
} from "./features/report-snapshots";
import type { ReportSnapshot } from "./features/report-snapshots";
function formatDate(iso: string) {
const d = new Date(iso);
return d.toLocaleString();
}
export default function ReportSnapshots() {
const [accounts, setAccounts] = React.useState("");
const [ignoreSelf, setIgnoreSelf] = React.useState(false);
const [startDate, setStartDate] = React.useState("");
const [endDate, setEndDate] = React.useState("");
const [minAmount, setMinAmount] = React.useState("");
const [maxAmount, setMaxAmount] = React.useState("");
const [snackbar, setSnackbar] = React.useState<{ message: string; severity: "success" | "error" } | null>(null);
const [deleteTarget, setDeleteTarget] = React.useState<ReportSnapshot | null>(null);
const [createdSnapshotId, setCreatedSnapshotId] = React.useState<string | null>(null);
const { data: listData, isLoading, isFetching, refetch } = useReportSnapshotsList();
const createMutation = useCreateSnapshot();
const deleteMutation = useDeleteSnapshot();
const snapshots = listData?.data ?? [];
const handleCreate = async () => {
try {
const result = await createMutation.mutateAsync({
accounts: accounts.trim() ? accounts.split(",").map((s) => s.trim()).filter(Boolean) : null,
ignore_self: ignoreSelf || null,
start_date: startDate ? new Date(startDate).toISOString() : null,
end_date: endDate ? new Date(endDate).toISOString() : null,
min_amount: minAmount ? parseFloat(minAmount) : null,
max_amount: maxAmount ? parseFloat(maxAmount) : null,
});
const snapshotId = (result as any)?.snapshot_id;
if (snapshotId) {
setCreatedSnapshotId(snapshotId);
setSnackbar({ message: `Snapshot created: ${snapshotId}`, severity: "success" });
} else {
setSnackbar({ message: "Snapshot created", severity: "success" });
}
resetForm();
} catch (err: any) {
setSnackbar({ message: err?.response?.data?.detail || "Failed to create snapshot", severity: "error" });
}
};
const resetForm = () => {
setAccounts("");
setIgnoreSelf(false);
setStartDate("");
setEndDate("");
setMinAmount("");
setMaxAmount("");
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await deleteMutation.mutateAsync(deleteTarget.snapshot_id);
setSnackbar({ message: "Snapshot deleted", severity: "success" });
} catch {
setSnackbar({ message: "Failed to delete snapshot", severity: "error" });
}
setDeleteTarget(null);
};
return (
<Container sx={{ mt: 4, mb: 4 }}>
<Typography variant="h5" fontWeight="bold" gutterBottom>
Report Snapshots
</Typography>
<Paper sx={{ p: 3, mb: 4, borderRadius: 4 }} variant="outlined">
<Typography variant="subtitle1" fontWeight={600} gutterBottom>
Generate New Snapshot
</Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TextField
label="Accounts"
value={accounts}
onChange={(e) => setAccounts(e.target.value)}
size="small"
helperText="Comma-separated account IDs (leave empty for all)"
/>
<FormControlLabel
control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />}
label="Ignore self-transfers"
/>
<Box sx={{ display: "flex", gap: 2 }}>
<TextField
label="Start Date"
type="datetime-local"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
sx={{ flex: 1 }}
/>
<TextField
label="End Date"
type="datetime-local"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
size="small"
InputLabelProps={{ shrink: true }}
sx={{ flex: 1 }}
/>
</Box>
<Box sx={{ display: "flex", gap: 2 }}>
<TextField
label="Min Amount"
type="number"
value={minAmount}
onChange={(e) => setMinAmount(e.target.value)}
size="small"
sx={{ flex: 1 }}
/>
<TextField
label="Max Amount"
type="number"
value={maxAmount}
onChange={(e) => setMaxAmount(e.target.value)}
size="small"
sx={{ flex: 1 }}
/>
</Box>
<Button
variant="contained"
startIcon={<AddCircleIcon />}
onClick={handleCreate}
disabled={createMutation.isPending}
>
{createMutation.isPending ? "Generating..." : "Generate Snapshot"}
</Button>
{createdSnapshotId && (
<Alert severity="success" onClose={() => setCreatedSnapshotId(null)}>
Snapshot created: <strong>{createdSnapshotId}</strong>. Use it in the Dashboard snapshot selector.
</Alert>
)}
</Box>
</Paper>
<Paper sx={{ borderRadius: 4 }} variant="outlined">
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", p: 2, pb: 0 }}>
<Typography variant="subtitle1" fontWeight={600}>
Existing Snapshots
</Typography>
<IconButton onClick={() => refetch()} disabled={isFetching}>
<RefreshIcon />
</IconButton>
</Box>
{isLoading ? (
<Box sx={{ display: "flex", justifyContent: "center", p: 4 }}>
<CircularProgress />
</Box>
) : snapshots.length === 0 ? (
<Box sx={{ p: 4, textAlign: "center", color: "text.secondary" }}>
No snapshots yet
</Box>
) : (
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Snapshot ID</TableCell>
<TableCell>Created</TableCell>
<TableCell>Query</TableCell>
<TableCell align="right">Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{snapshots.map((snap: ReportSnapshot) => (
<TableRow key={snap.id}>
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{snap.snapshot_id.slice(0, 12)}...
</TableCell>
<TableCell>{formatDate(snap.created_at)}</TableCell>
<TableCell>
{snap.query ? (
<Box sx={{ display: "flex", gap: 0.5, flexWrap: "wrap" }}>
{snap.query.accounts && <Chip label={`${snap.query.accounts.length} account(s)`} size="small" variant="outlined" />}
{snap.query.ignore_self && <Chip label="ignore_self" size="small" variant="outlined" />}
{snap.query.start_date && <Chip label="start" size="small" variant="outlined" />}
{snap.query.end_date && <Chip label="end" size="small" variant="outlined" />}
</Box>
) : (
<Typography variant="body2" color="text.secondary"></Typography>
)}
</TableCell>
<TableCell align="right">
<IconButton size="small" onClick={() => setDeleteTarget(snap)}>
<DeleteIcon fontSize="small" />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
)}
</Paper>
<Snackbar
open={!!snackbar}
autoHideDuration={4000}
onClose={() => setSnackbar(null)}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
>
{snackbar ? <Alert severity={snackbar.severity} onClose={() => setSnackbar(null)}>{snackbar.message}</Alert> : undefined}
</Snackbar>
<Dialog open={!!deleteTarget} onClose={() => setDeleteTarget(null)}>
<DialogTitle>Delete Snapshot?</DialogTitle>
<DialogContent>
<DialogContentText>
This will permanently delete the report snapshot.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" disabled={deleteMutation.isPending}>
{deleteMutation.isPending ? "Deleting..." : "Delete"}
</Button>
</DialogActions>
</Dialog>
</Container>
);
}

View File

@@ -1,61 +0,0 @@
import * as React from "react";
import {
ReportData,
GroupKey,
} from "../../features/report";
export type DashboardFlow = "outflows" | "inflows";
export type DashboardPeriodType = "rolling" | "calendar";
export type DashboardSelectedPeriodId = string | null;
export interface DashboardState {
flow: DashboardFlow;
periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId;
selectedGroupKey: GroupKey | null;
comparison: boolean;
}
export interface DashboardStateSetters {
setSelectedPeriodId: (id: DashboardSelectedPeriodId) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleFlow: () => void;
togglePeriodType: () => void;
toggleComparison: () => void;
}
export interface DashboardSection {
id: string;
title: string;
component: React.ComponentType<any>;
summary?: string;
settings?: Record<string, any>;
}
export interface DashboardConfig {
sections: DashboardSection[];
}
export interface DashboardViewProps {
config: DashboardConfig;
data: ReportData;
state: DashboardState;
stateSetters: DashboardStateSetters;
isFetching: boolean;
}
export interface ColorScheme {
primary: string;
surface: string;
text: string;
}
export interface ComponentProps extends DashboardSection {
reportData: ReportData;
state: DashboardState;
stateSetters: DashboardStateSetters;
isFetching: boolean;
colorScheme: ColorScheme;
}

View File

@@ -1,105 +0,0 @@
import * as React from "react";
import {
Box,
Container,
Grid,
ToggleButton,
ToggleButtonGroup,
Button
} from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { DashboardViewProps } from "./Dashboard.models";
export default function DashboardView({
config,
data,
state,
stateSetters,
isFetching,
}: DashboardViewProps) {
const theme = useTheme();
const {
flow,
selectedGroupKey,
} = state;
const colorScheme = flow === "outflows" ? theme.palette.flows.outflows : theme.palette.flows.inflows;
return (
<Container
sx={{
mt: 4,
mb: 4,
background: `linear-gradient(180deg, ${alpha(colorScheme.primary, theme.palette.mode === "dark" ? 0.06 : 0.04)} 0%, transparent 100%)`,
borderRadius: 4,
p: 2,
transition: "background 0.3s ease",
}}
>
<Box
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
mb: 3,
}}
>
<ToggleButtonGroup
value={flow}
exclusive
onChange={stateSetters.toggleFlow}
sx={{
borderRadius: 3,
overflow: "hidden",
"& .MuiToggleButton-root": {
px: 3,
textTransform: "none",
color: "text.secondary",
},
"&.Mui-selected": {
bgcolor: colorScheme.primary,
color: "white",
borderColor: colorScheme.primary,
},
}}
>
<ToggleButton value="outflows">Outflows</ToggleButton>
<ToggleButton value="inflows">Inflows</ToggleButton>
</ToggleButtonGroup>
{selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && (
<Button
size="small"
sx={{ mt: 1, textTransform: "none" }}
onClick={() => stateSetters.setSelectedGroupKey(null)}
>
Clear Drill-down
</Button>
)}
</Box>
<Grid container spacing={4}>
{config.sections.map((section) => {
const Component = section.component;
return (
<Grid key={section.id} size={12}>
<Component
{...section}
reportData={data}
state={state}
stateSetters={stateSetters}
isFetching={isFetching}
colorScheme={colorScheme}
/>
</Grid>
);
})}
</Grid>
</Container>
);
}

View File

@@ -1,2 +0,0 @@
export { default } from "./Dashboard.view";
export * from "./Dashboard.models";

View File

@@ -1,73 +0,0 @@
import { ReportData } from "../../features/report";
import {
mergeBucketPeriods,
getAmount,
PeriodKey,
} from "../report.helpers";
import { ChartDataPoint } from "./HistoryChart.models";
// ─── Tab → PeriodKey ─────────────────────────────────────────
const TAB_TO_KEY: Record<string, PeriodKey> = {
Daily: "daily",
Weekly: "weekly",
Monthly: "monthly",
"All Time": "all",
};
export function tabToKey(tab: string): PeriodKey {
return TAB_TO_KEY[tab] ?? "all";
}
// ─── Comparison ──────────────────────────────────────────────
function attachComparison(
points: ChartDataPoint[],
key: PeriodKey
): ChartDataPoint[] {
const getCompareIndex = (i: number) => {
if (key === "daily") return i - 7;
if (key === "weekly") return i - 4;
if (key === "monthly") return i - 12;
return -1;
};
return points.map((p, i) => {
const ci = getCompareIndex(i);
return {
...p,
compare:
ci >= 0 && points[ci]
? {
id: points[ci].id,
label: points[ci].label,
amount: points[ci].amount,
}
: undefined,
};
});
}
// ─── Main adapter ────────────────────────────────────────────
export function buildChartData(
reportData: ReportData,
key: PeriodKey,
flow: "outflows" | "inflows",
comparison: boolean
): ChartDataPoint[] {
const merged = mergeBucketPeriods(reportData.buckets, key);
let points: ChartDataPoint[] = merged.map((p) => ({
id: p.id,
label: p.label,
amount: getAmount(p),
}));
if (comparison) {
points = attachComparison(points, key);
}
return points;
}

View File

@@ -1,6 +1,5 @@
export interface _ChartDataPoint { export interface _ChartDataPoint {
id: string; id: string;
label: string;
amount: number; amount: number;
highlighted?: boolean; highlighted?: boolean;
} }
@@ -8,3 +7,31 @@ export interface _ChartDataPoint {
export interface ChartDataPoint extends _ChartDataPoint { export interface ChartDataPoint extends _ChartDataPoint {
compare?: _ChartDataPoint; compare?: _ChartDataPoint;
} }
export interface ChartData {
daily?: ChartDataPoint[];
weekly?: Record<string, ChartDataPoint[]>;
monthly?: Record<string, ChartDataPoint[]>;
}
export interface AggregatedDashboardData {
chartData: ChartData;
totalAmount: number;
topPayees: Array<{ payeeName: string; amount: number }>;
}
export interface HistoryChartProps {
header: string;
summary?: string;
tabs: string[];
data: ChartData;
period: "rolling" | "calendar";
onPeriodChange: (p: "rolling" | "calendar") => void;
comparison: boolean;
setComparison: (v: boolean) => void;
colorScheme: {
primary: string;
light: string;
text: string;
};
}

View File

@@ -1,21 +0,0 @@
import * as React from "react";
import { ComponentProps } from "../Dashboard";
import { ChartDataPoint } from "./HistoryChart.models";
export interface HistoryChartProps extends ComponentProps {
settings: {
tabs: string[];
};
}
export interface HistoryChartViewProps extends HistoryChartProps {
activeTab: string;
setActiveTab: (v: string) => void;
currentData: ChartDataPoint[];
visibleData: ChartDataPoint[];
maxAmount: number;
visibleCount: number;
startIndex: number;
setStartIndex: React.Dispatch<React.SetStateAction<number>>;
activeDataKey: string;
}

View File

@@ -1,84 +1,48 @@
import * as React from "react"; import * as React from "react";
import { ChartDataPoint, HistoryChartProps, ChartData } from "./HistoryChart.models";
import HistoryChartView from "./HistoryChart.view"; import HistoryChartView from "./HistoryChart.view";
import { buildChartData, tabToKey } from "./HistoryChart.adapter";
import { HistoryChartProps } from "./HistoryChart.props";
export default function HistoryChart(props: HistoryChartProps) { export default function HistoryChart(props: HistoryChartProps) {
const { const { tabs, data, period, comparison } = props;
settings,
reportData,
state,
stateSetters,
isFetching,
} = props;
const { flow, comparison, selectedPeriodId } = state;
const { setSelectedPeriodId } = stateSetters;
const { tabs } = settings;
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || ""); const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
const [startIndex, setStartIndex] = React.useState(0); const [startIndex, setStartIndex] = React.useState(0);
const activeDataKey = tabToKey(activeTab); const activeDataKey = activeTab.toLowerCase() as keyof ChartData;
const currentData = React.useMemo(() => { let rawData: ChartDataPoint[] = [];
return buildChartData(reportData, activeDataKey, flow, comparison);
}, [reportData, activeDataKey, flow, comparison]); if (activeDataKey === "daily") {
rawData = data.daily || [];
} else {
const section = data[activeDataKey];
rawData = section?.[period] || [];
}
const currentData = rawData;
const maxAmount = const maxAmount =
currentData.length > 0 currentData.length > 0
? Math.max( ? Math.max(
...currentData.flatMap((d) => ...currentData.flatMap((d) =>
comparison comparison ? [d.amount, d.compare?.amount ?? 0] : [d.amount]
? [d.amount, ...(d.compare ? [d.compare.amount] : [])]
: [d.amount]
), ),
1 1
) )
: 1; : 1;
const visibleCountMap = { const visibleCountMap = { daily: 7, weekly: 6, monthly: 4 };
daily: 7, const visibleCount = visibleCountMap[activeDataKey];
weekly: 6,
monthly: 4,
all: 4,
};
const visibleCount = visibleCountMap[activeDataKey] ?? 4;
const total = currentData.length; const total = currentData.length;
const clampedStartIndex = Math.min( const clampedStartIndex = Math.min(startIndex, Math.max(total - visibleCount, 0));
startIndex,
Math.max(total - visibleCount, 0)
);
React.useEffect(() => {
if (startIndex !== clampedStartIndex) {
setStartIndex(clampedStartIndex);
}
}, [startIndex, clampedStartIndex]);
const visibleData = currentData.slice( const visibleData = currentData.slice(
clampedStartIndex, clampedStartIndex,
clampedStartIndex + visibleCount clampedStartIndex + visibleCount
); );
React.useEffect(() => {
setSelectedPeriodId(null);
}, [activeTab]);
React.useEffect(() => {
if (
selectedPeriodId &&
!visibleData.some((p) => p.id === selectedPeriodId)
) {
setSelectedPeriodId(null);
}
}, [visibleData, selectedPeriodId]);
return ( return (
<HistoryChartView <HistoryChartView
{...props} {...props}
@@ -88,7 +52,7 @@ export default function HistoryChart(props: HistoryChartProps) {
visibleData={visibleData} visibleData={visibleData}
maxAmount={maxAmount} maxAmount={maxAmount}
visibleCount={visibleCount} visibleCount={visibleCount}
startIndex={clampedStartIndex} startIndex={startIndex}
setStartIndex={setStartIndex} setStartIndex={setStartIndex}
activeDataKey={activeDataKey} activeDataKey={activeDataKey}
/> />

View File

@@ -25,3 +25,19 @@ export const formatDisplay = (
return `${formatShort(base)} (${sign}${formatShort(Math.abs(diff))})`; return `${formatShort(base)} (${sign}${formatShort(Math.abs(diff))})`;
}; };
export const formatLabel = (label: string, type: string) => {
if (type === "monthly") return label;
if (type === "weekly") {
const parts = label.split(" - ");
if (parts.length === 2) {
const [start, end] = parts;
const startDay = start.split(" ")[0];
const [endDay, month] = end.split(" ");
return `${startDay}${endDay} ${month}`;
}
}
return label;
};

View File

@@ -6,26 +6,37 @@ import {
ToggleButton, ToggleButton,
Paper Paper
} from "@mui/material"; } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import IconButton from "@mui/material/IconButton"; import IconButton from "@mui/material/IconButton";
import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft";
import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import ChevronRightIcon from "@mui/icons-material/ChevronRight";
import { import {
HistoryChartViewProps, ChartDataPoint,
} from "./HistoryChart.props"; HistoryChartProps,
import { formatDisplay } from "./HistoryChart.utils"; } from "./HistoryChart.models";
import { formatDisplay, formatLabel } from "./HistoryChart.utils";
export default function HistoryChartView({ interface ViewProps extends HistoryChartProps {
title, activeTab: string;
setActiveTab: (v: string) => void;
currentData: ChartDataPoint[];
visibleData: ChartDataPoint[];
maxAmount: number;
visibleCount: number;
startIndex: number;
setStartIndex: React.Dispatch<React.SetStateAction<number>>;
activeDataKey: string;
}
export default function HistoryChartView(props: ViewProps) {
const {
header,
summary, summary,
settings, tabs,
period,
state, onPeriodChange,
stateSetters, comparison,
isFetching, setComparison,
colorScheme, colorScheme,
activeTab, activeTab,
setActiveTab, setActiveTab,
currentData, currentData,
@@ -35,55 +46,37 @@ export default function HistoryChartView({
startIndex, startIndex,
setStartIndex, setStartIndex,
activeDataKey, activeDataKey,
}: HistoryChartViewProps) { } = props;
const { flow, periodType, selectedPeriodId, comparison } = state;
const { togglePeriodType, setSelectedPeriodId, toggleComparison } = stateSetters;
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
const total = currentData.length;
const maxStartIndex = Math.max(total - visibleCount, 0);
const clampedStartIndex = Math.min(startIndex, maxStartIndex);
const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => { const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => {
if (newTab !== null) setActiveTab(newTab); if (newTab !== null) setActiveTab(newTab);
}; };
const canGoLeft = clampedStartIndex > 0; const canGoLeft = startIndex > 0;
const canGoRight = clampedStartIndex < maxStartIndex; const canGoRight = startIndex + visibleCount < currentData.length;
const handlePrev = () => { const handlePrev = () => {
if (!canGoLeft) return; if (canGoLeft) setStartIndex((prev) => prev - visibleCount);
setStartIndex((prev) => Math.max(prev - visibleCount, 0));
}; };
const handleNext = () => { const handleNext = () => {
if (!canGoRight) return; if (canGoRight) setStartIndex((prev) => prev + visibleCount);
setStartIndex((prev) => {
const next = prev + visibleCount;
return Math.min(next, maxStartIndex);
});
}; };
return ( return (
<Paper <Paper
sx={{ sx={{
p: { xs: 2.5, sm: 4 }, p: { xs: 2, sm: 4 },
borderRadius: 4, borderRadius: 4,
width: "100%", width: "100%",
boxShadow: "none", boxShadow: "none",
border: "1px solid", border: "1px solid",
borderColor: "divider", borderColor: "divider",
bgcolor: isDark ? "background.paper" : colorScheme.surface, bgcolor: colorScheme.light,
opacity: isFetching ? 0.6 : 1,
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
}} }}
> >
<Typography variant="h6" fontWeight={700} gutterBottom> <Typography variant="h6" fontWeight={700} gutterBottom color={colorScheme.text}>
{title} {header}
</Typography> </Typography>
{summary && ( {summary && (
@@ -93,24 +86,42 @@ export default function HistoryChartView({
)} )}
<ToggleButtonGroup value={activeTab} exclusive onChange={handleTabChange} fullWidth sx={{ mb: 4 }}> <ToggleButtonGroup value={activeTab} exclusive onChange={handleTabChange} fullWidth sx={{ mb: 4 }}>
{settings.tabs.map((tab) => ( {tabs.map((tab) => (
<ToggleButton key={tab} value={tab}> <ToggleButton key={tab} value={tab}>
{tab} {tab}
</ToggleButton> </ToggleButton>
))} ))}
</ToggleButtonGroup> </ToggleButtonGroup>
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}> <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 3 }}>
<ToggleButtonGroup value={periodType} exclusive onChange={togglePeriodType} size="small"> <ToggleButtonGroup value={period} exclusive onChange={(_, v) => v && onPeriodChange(v)} size="small">
<ToggleButton value="rolling">Rolling</ToggleButton> <ToggleButton value="rolling">Rolling</ToggleButton>
<ToggleButton value="calendar">Calendar</ToggleButton> <ToggleButton value="calendar" disabled={activeDataKey === "daily"}>
Calendar
</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
<ToggleButton <ToggleButton
value="compare" value="compare"
selected={comparison} selected={comparison}
onChange={toggleComparison} onChange={() => setComparison(!comparison)}
size="small" size="small"
sx={{
textTransform: "none",
borderRadius: 2,
px: 2,
color: "text.secondary",
border: "1px solid",
borderColor: "divider",
"&.Mui-selected": {
color: "white",
bgcolor: "success.main",
borderColor: "success.main"
},
"&.Mui-selected:hover": {
bgcolor: "success.dark"
}
}}
> >
Compare Compare
</ToggleButton> </ToggleButton>
@@ -119,7 +130,19 @@ export default function HistoryChartView({
{currentData.length > 0 ? ( {currentData.length > 0 ? (
<Box sx={{ position: "relative", mt: 4 }}> <Box sx={{ position: "relative", mt: 4 }}>
{canGoLeft && ( {canGoLeft && (
<IconButton onClick={handlePrev} size="small" sx={{ position: "absolute", left: 0, top: "50%" }}> <IconButton
onClick={handlePrev}
size="small"
sx={{
position: "absolute",
left: 0,
top: "50%",
transform: "translateY(-50%)",
zIndex: 2,
bgcolor: "background.paper",
boxShadow: 1
}}
>
<ChevronLeftIcon fontSize="small" /> <ChevronLeftIcon fontSize="small" />
</IconButton> </IconButton>
)} )}
@@ -130,67 +153,77 @@ export default function HistoryChartView({
const compareHeight = comparison const compareHeight = comparison
? ((point.compare?.amount ?? 0) / maxAmount) * 100 ? ((point.compare?.amount ?? 0) / maxAmount) * 100
: 0; : 0;
const labelHeight = Math.max(currentHeight, compareHeight);
const isSelected = selectedPeriodId === point.id;
const display = formatDisplay(point, activeDataKey, comparison);
return ( return (
<Box <Box key={point.id} sx={{ flex: 1, display: "flex", flexDirection: "column", alignItems: "center", justifyContent: "flex-end", height: "100%" }}>
key={point.id} <Box sx={{ display: "flex", alignItems: "flex-end", gap: comparison ? 0.5 : 0, height: "100%", position: "relative" }}>
onClick={() => <Typography
setSelectedPeriodId(isSelected ? null : point.id) variant="caption"
}
sx={{ sx={{
flex: 1, position: "absolute",
display: "flex", bottom: `${labelHeight}%`,
flexDirection: "column", left: "50%",
alignItems: "center", transform: "translate(-50%, -6px)",
cursor: "pointer", fontSize: "0.65rem",
height: "100%" whiteSpace: "nowrap",
pointerEvents: "none"
}} }}
> >
<Box sx={{ display: "flex", alignItems: "flex-end", gap: 1, height: "100%" }}> {formatDisplay(point, activeTab.toLowerCase(), comparison)}
</Typography>
{comparison && ( {comparison && (
<Box <Box sx={{ width: 6, height: `${compareHeight}%`, bgcolor: `${colorScheme.primary}55`, borderRadius: 2 }} />
sx={{
width: 8,
height: `${compareHeight}%`,
bgcolor: alpha(colorScheme.primary, 0.4),
borderRadius: "4px 4px 0 0"
}}
/>
)} )}
<Box sx={{ width: 4 }} />
<Box <Box
sx={{ sx={{
width: 12, width: 10,
height: `${currentHeight}%`, height: `${currentHeight}%`,
bgcolor: isSelected ? "warning.main" : colorScheme.primary, bgcolor: point.highlighted ? colorScheme.primary : `${colorScheme.primary}99`,
borderRadius: "4px 4px 0 0" borderRadius: 2
}} }}
/> />
</Box> </Box>
<Typography variant="caption"> <Box sx={{ mt: 1, textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", lineHeight: 1.1 }}>
{point.label} <Typography variant="caption" sx={{ fontSize: "0.7rem", opacity: 0.7 }}>
{formatLabel(point.id, activeDataKey)}
</Typography> </Typography>
{comparison && point.compare && ( <Typography
<Typography variant="caption" color="text.secondary"> variant="caption"
{point.compare.label} sx={{
</Typography> fontSize: "0.65rem",
)} color: "grey.400",
visibility: comparison && point.compare && activeDataKey !== "daily" ? "visible" : "hidden"
<Typography variant="caption"> }}
{display} >
{point.compare ? formatLabel(point.compare.id, activeDataKey) : "placeholder"}
</Typography> </Typography>
</Box> </Box>
</Box>
); );
})} })}
</Box> </Box>
{canGoRight && ( {canGoRight && (
<IconButton onClick={handleNext} size="small" sx={{ position: "absolute", right: 0, top: "50%" }}> <IconButton
onClick={handleNext}
size="small"
sx={{
position: "absolute",
right: 0,
top: "50%",
transform: "translateY(-50%)",
zIndex: 2,
bgcolor: "background.paper",
boxShadow: 1
}}
>
<ChevronRightIcon fontSize="small" /> <ChevronRightIcon fontSize="small" />
</IconButton> </IconButton>
)} )}

View File

@@ -1,31 +0,0 @@
import { ReportData, GroupKey } from "../../features/report";
import {
formatCurrency,
extractFilteredTransactions,
} from "../report.helpers";
import { LatestItem } from "./LatestItems.models";
// ─── Main adapter ────────────────────────────────────────────
export function buildLatestItems(
reportData: ReportData,
selectedPeriodId: string | null | undefined,
selectedGroupKey: GroupKey | null | undefined,
flow: "outflows" | "inflows"
): LatestItem[] {
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
return txns
.sort(
(a, b) =>
new Date(b.occurred_at).getTime() -
new Date(a.occurred_at).getTime()
)
.map((t, index) => ({
id: index + 1,
title: t.payee.name,
subtitle: t.tags.map((tag) => tag.name).join(", "),
amount: formatCurrency(t.amount),
timeAgo: new Date(t.occurred_at).toLocaleDateString("en-IN"),
}));
}

View File

@@ -1,7 +1,18 @@
import * as React from "react";
export interface LatestItem { export interface LatestItem {
id: string | number; id: string | number;
icon: React.ReactNode;
iconBgColor?: string;
title: string; title: string;
subtitle: string; subtitle: string;
amount: string; amount: string;
timeAgo: string; timeAgo: string;
} }
export interface LatestItemsListProps {
title?: string;
items: LatestItem[];
onViewAll?: () => void;
accentColor: string;
}

View File

@@ -1,10 +0,0 @@
import { ComponentProps } from "../Dashboard";
import { LatestItem } from "./LatestItems.models";
export interface LatestItemsProps extends ComponentProps {}
export interface LatestItemsViewProps extends LatestItemsProps {
items: LatestItem[];
canExpand: boolean;
onExpand: () => void;
}

View File

@@ -1,40 +1,112 @@
import * as React from "react"; import * as React from "react";
import { buildLatestItems } from "./LatestItems.adapter"; import {
import LatestItemsView from "./LatestItems.view"; List,
import { LatestItemsProps } from "./LatestItems.props"; ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Typography,
Box,
Button,
} from "@mui/material";
export default function LatestItems(props: LatestItemsProps) { export interface LatestItem {
const { id: string | number;
reportData, icon: React.ReactNode;
state, iconBgColor?: string;
stateSetters, title: string;
isFetching, subtitle: string;
} = props; amount: string;
timeAgo: string;
}
const { flow, selectedPeriodId, selectedGroupKey } = state; export interface LatestItemsListProps {
const [visibleCount, setVisibleCount] = React.useState(5); title?: string;
items: LatestItem[];
// Reset count when flow changes to start clean onViewAll?: () => void;
React.useEffect(() => { accentColor: any;
setVisibleCount(5); }
}, [flow]);
const allItems = React.useMemo(() => {
return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, flow);
}, [reportData, selectedPeriodId, selectedGroupKey, flow]);
const visibleItems = React.useMemo(() => {
return allItems.slice(0, visibleCount);
}, [allItems, visibleCount]);
const canExpand = visibleCount < allItems.length;
export default function LatestItems({
title = "Recent Transactions",
items,
onViewAll,
accentColor,
}: LatestItemsListProps) {
return ( return (
<LatestItemsView <Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2 }}>
{...props} {/* Header */}
items={visibleItems} <Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, px: 2 }}>
canExpand={canExpand} <Typography variant="h6" fontWeight="bold">
onExpand={() => setVisibleCount((prev) => prev + 5)} {title}
</Typography>
{onViewAll && (
<Button
variant="text"
color="inherit"
size="small"
sx={{ textTransform: "none", color: "text.secondary", fontWeight: "medium" }}
onClick={onViewAll}
>
view all
</Button>
)}
</Box>
{/* List */}
<List disablePadding>
{items.map((item, index) => (
<ListItem
key={item.id}
sx={{
px: { xs: 1, sm: 2 },
py: 2,
mb: index !== items.length - 1 ? 1 : 0,
borderRadius: 3,
"&:hover": { bgcolor: "action.hover" },
transition: "background-color 0.2s ease",
}}
>
<ListItemAvatar>
<Avatar
variant="rounded"
sx={{
bgcolor: `${accentColor}22`,
color: "inherit",
width: 48,
height: 48,
borderRadius: 3,
mr: 2,
}}
>
{item.icon}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle1" fontWeight={600} color="text.primary">
{item.title}
</Typography>
}
secondary={
<Typography variant="body2" color="text.secondary">
{item.subtitle}
</Typography>
}
/> />
<Box sx={{ textAlign: "right" }}>
<Typography variant="subtitle1" fontWeight={700} color="text.primary">
{item.amount}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{item.timeAgo}
</Typography>
</Box>
</ListItem>
))}
</List>
</Box>
); );
} }

View File

@@ -1,93 +1,6 @@
import * as React from "react"; import LatestItemsListView from "./LatestItems.view";
import { import { LatestItemsListProps } from "./LatestItems.models";
List,
ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Typography,
Box,
IconButton,
} from "@mui/material";
import { alpha } from "@mui/material/styles";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { LatestItemsViewProps } from "./LatestItems.props";
export default function LatestItemsView({ export default function LatestItemsList(props: LatestItemsListProps) {
items, return <LatestItemsListView {...props} />;
title,
canExpand,
onExpand,
isFetching,
colorScheme,
}: LatestItemsViewProps) {
const accentColor = colorScheme?.primary || "";
return (
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2, opacity: isFetching ? 0.6 : 1, transition: "opacity 0.3s ease", pointerEvents: isFetching ? "none" : "auto" }}>
<Box sx={{ mb: 2, px: 2 }}>
<Typography variant="h6" fontWeight="bold">
{title}
</Typography>
</Box>
<List disablePadding>
{items.map((item, index) => (
<ListItem
key={item.id}
sx={{
px: { xs: 1, sm: 2 },
py: 2,
mb: index !== items.length - 1 ? 1 : 0,
borderRadius: 3,
"&:hover": { bgcolor: "action.hover" },
}}
>
<ListItemAvatar>
<Avatar
variant="rounded"
sx={{
bgcolor: alpha(accentColor, 0.13),
width: 48,
height: 48,
borderRadius: 3,
mr: 2,
}}
/>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle1" fontWeight={600}>
{item.title}
</Typography>
}
secondary={
<Typography variant="body2" color="text.secondary">
{item.subtitle}
</Typography>
}
/>
<Box sx={{ textAlign: "right" }}>
<Typography variant="subtitle1" fontWeight={700}>
{item.amount}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.timeAgo}
</Typography>
</Box>
</ListItem>
))}
{canExpand && (
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
<IconButton size="small" onClick={onExpand}>
<ExpandMoreIcon />
</IconButton>
</Box>
)}
</List>
</Box>
);
} }

View File

@@ -0,0 +1,8 @@
export interface ProgressCardProps {
header: string;
summary?: string;
progressAmount: number;
totalAmount: number;
colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
compact?: boolean;
}

View File

@@ -1,14 +0,0 @@
import { ComponentProps } from "../Dashboard";
export interface ProgressCardProps extends ComponentProps {
settings: {
compact: boolean;
};
}
export interface ProgressCardViewProps extends ProgressCardProps {
progressAmount: number;
totalAmount: number;
selected: boolean;
onClick: () => void;
}

View File

@@ -0,0 +1,23 @@
import * as React from "react";
import ProgressCardView from "./ProgressCard.view";
import { ProgressCardProps } from "./ProgressCard.models";
import { getPercentage, formatCurrency } from "./ProgressCard.utils";
export default function ProgressCard(props: ProgressCardProps) {
const { progressAmount, totalAmount, compact = false } = props;
const percentage = getPercentage(progressAmount, totalAmount);
const formattedProgress = formatCurrency(progressAmount);
const formattedTotal = formatCurrency(totalAmount);
return (
<ProgressCardView
{...props}
percentage={percentage}
formattedProgress={formattedProgress}
formattedTotal={formattedTotal}
compact={compact}
/>
);
}

View File

@@ -0,0 +1,15 @@
export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
};
export const formatCurrency = (val: number) => {
const absVal = Math.abs(val);
if (absVal >= 100000) {
return `${(val / 100000).toFixed(2)}L`;
}
if (absVal >= 1000) {
return `${(val / 1000).toFixed(2)}k`;
}
return `${val.toFixed(2)}`;
};

View File

@@ -7,80 +7,69 @@ import {
Divider, Divider,
linearProgressClasses linearProgressClasses
} from "@mui/material"; } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles"; import { ProgressCardProps } from "./ProgressCard.models";
import { getPercentage, formatCurrency } from "../report.helpers";
import { ProgressCardViewProps } from "./ProgressCard.props"; interface ViewProps extends ProgressCardProps {
percentage: number;
formattedProgress: string;
formattedTotal: string;
}
export default function ProgressCardView({ export default function ProgressCardView({
title, header,
settings, colorTheme = "info",
percentage,
isFetching, formattedProgress,
formattedTotal,
colorScheme, compact = false,
}: ViewProps) {
progressAmount,
totalAmount,
selected,
onClick,
}: ProgressCardViewProps) {
const theme = useTheme();
const percentage = getPercentage(progressAmount, totalAmount);
const formattedProgress = formatCurrency(progressAmount);
const formattedTotal = formatCurrency(totalAmount);
return ( return (
<Paper <Paper
elevation={settings.compact ? 2 : 4} elevation={compact ? 2 : 4}
onClick={onClick}
sx={{ sx={{
width: "100%", width: "100%",
p: settings.compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 }, p: compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 },
borderRadius: settings.compact ? 3 : 4, borderRadius: compact ? 3 : 4,
transform: selected ? "scale(1.02)" : "scale(1)", background: (theme) =>
transition: "transform 0.2s ease, box-shadow 0.2s ease", colorTheme === "info"
bgcolor: colorScheme.surface, ? "linear-gradient(135deg, #0284c7 0%, #06b6d4 100%)"
color: colorScheme.text, : `linear-gradient(135deg, ${theme.palette[colorTheme].main} 0%, ${theme.palette[colorTheme].light} 100%)`,
color: "#fff",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: settings.compact ? "flex-start" : "center", alignItems: compact ? "flex-start" : "center",
justifyContent: "center", justifyContent: "center",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
border: selected boxShadow: (theme) =>
? `2px solid ${colorScheme.primary}` `0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${
: "1px solid", theme.palette.mode === "dark"
borderColor: selected ? colorScheme.primary : "divider", ? "#000"
boxShadow: "none", : theme.palette[colorTheme].main
opacity: isFetching ? 0.6 : 1, }`,
pointerEvents: isFetching ? "none" : "auto",
}} }}
> >
<Typography <Typography
variant={settings.compact ? "body2" : "subtitle1"} variant={compact ? "body2" : "subtitle1"}
fontWeight={700} fontWeight={700}
sx={{ sx={{
opacity: 0.95, opacity: 0.9,
mb: settings.compact ? 1.5 : 2, mb: compact ? 1.5 : 2,
width: "100%", width: '100%',
overflow: "hidden", overflow: 'hidden',
textOverflow: "ellipsis", textOverflow: 'ellipsis',
whiteSpace: "nowrap", whiteSpace: 'nowrap',
letterSpacing: 0.5, letterSpacing: 0.5
}} }}
> >
{title} {header}
</Typography> </Typography>
<Box sx={{ mb: settings.compact ? 2 : 3, width: "100%" }}> <Box sx={{ mb: compact ? 2 : 3, width: '100%' }}>
<Typography <Typography
variant={settings.compact ? "h5" : "h3"} variant={compact ? "h5" : "h3"}
fontWeight={900} fontWeight={900}
sx={{ sx={{ mb: 0.5, lineHeight: 1.2 }}
mb: 0.5,
lineHeight: 1.2,
}}
> >
{formattedProgress} {formattedProgress}
</Typography> </Typography>
@@ -88,38 +77,36 @@ export default function ProgressCardView({
<Divider <Divider
sx={{ sx={{
my: 1, my: 1,
borderColor: "divider", borderColor: "rgba(255,255,255,0.4)",
width: "100%", width: "100%",
}} }}
/> />
<Typography <Typography
variant={settings.compact ? "caption" : "body2"} variant={compact ? "caption" : "body2"}
sx={{ sx={{
opacity: 0.85, opacity: 0.8,
fontWeight: 500, fontWeight: 400,
display: "block", display: "block"
color: alpha(colorScheme.text, 0.85),
}} }}
> >
of {formattedTotal} {formattedTotal}
</Typography> </Typography>
</Box> </Box>
<Box sx={{ width: "100%", mt: "auto" }}> <Box sx={{ width: "100%", mt: 'auto' }}>
<LinearProgress <LinearProgress
variant="determinate" variant="determinate"
value={percentage} value={percentage}
sx={{ sx={{
height: settings.compact ? 6 : 10, height: compact ? 6 : 10,
borderRadius: 5, borderRadius: 5,
[`&.${linearProgressClasses.colorPrimary}`]: { [`&.${linearProgressClasses.colorPrimary}`]: {
backgroundColor: alpha(theme.palette.divider, 0.5), backgroundColor: "rgba(0, 0, 0, 0.2)",
}, },
[`& .${linearProgressClasses.bar}`]: { [`& .${linearProgressClasses.bar}`]: {
borderRadius: 5, borderRadius: 5,
backgroundColor: colorScheme.primary, backgroundColor: "#fff",
boxShadow: `0 0 8px ${alpha(colorScheme.primary, 0.4)}`,
}, },
}} }}
/> />

View File

@@ -1,31 +0,0 @@
import { GroupKey, ReportData } from "../../features/report";
import {
extractFilteredTransactions,
aggregateTransactions,
} from "../report.helpers";
export interface PayeeItem {
name: string;
amount: number;
}
export function extractTopPayees(
reportData: ReportData,
flow: "outflows" | "inflows",
selectedPeriodId?: string | null,
selectedGroupKey?: GroupKey | null
): { items: PayeeItem[]; total: number } {
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
const { items, total } = aggregateTransactions(txns, (txn) => {
if (txn.payee && txn.payee.name) {
return [txn.payee.name];
}
return [];
});
return {
items,
total,
};
}

View File

@@ -1,83 +0,0 @@
import * as React from "react";
import { Box, Paper, Typography } from "@mui/material";
import ProgressCardView from "./ProgressCard.view";
import { extractTopPayees } from "./TopPayees.adapter";
import { ProgressCardProps } from "./ProgressCard.props";
export default function TopPayees(props: ProgressCardProps) {
const {
title,
reportData,
state,
stateSetters,
isFetching,
} = props
const { flow, selectedPeriodId, selectedGroupKey } = state;
const { setSelectedGroupKey } = stateSetters;
const { items, total } = React.useMemo(() => {
return extractTopPayees(reportData, flow, selectedPeriodId, selectedGroupKey);
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
return (
<Paper
sx={{
p: { xs: 2.5, sm: 4 },
borderRadius: 4,
width: "100%",
boxShadow: "none",
border: "1px solid",
borderColor: "divider",
bgcolor: "background.paper",
opacity: isFetching ? 0.6 : 1,
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
}}
>
<Typography variant="h6" fontWeight={700} gutterBottom>
{title}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
md: "repeat(4, 1fr)",
},
gap: 2,
}}
>
{items.map((item) => {
const isSelected = !!selectedGroupKey?.payee?.includes(item.name);
return (
<ProgressCardView
{...props}
key={item.name}
title={item.name}
progressAmount={item.amount}
totalAmount={total}
selected={isSelected}
onClick={() => {
if (setSelectedGroupKey) {
let newKey = selectedGroupKey ? { ...selectedGroupKey } : {};
if (isSelected) {
delete newKey.payee;
} else {
newKey.payee = [item.name];
}
setSelectedGroupKey(Object.keys(newKey).length ? newKey : null);
}
}}
/>
);
})}
</Box>
</Paper>
);
}

View File

@@ -1,31 +0,0 @@
import { ReportData, GroupKey } from "../../features/report";
import {
extractFilteredTransactions,
aggregateTransactions,
} from "../report.helpers";
export interface TagItem {
tag: string;
amount: number;
}
export function extractTopTags(
reportData: ReportData,
flow: "outflows" | "inflows",
selectedPeriodId?: string | null,
selectedGroupKey?: GroupKey | null
): { items: TagItem[]; total: number } {
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey);
const { items, total } = aggregateTransactions(txns, (txn) => {
if (txn.tags && txn.tags.length > 0) {
return txn.tags.map((t) => (typeof t === "string" ? t : t.name));
}
return ["Untagged"];
});
return {
items: items.map((item) => ({ tag: item.name, amount: item.amount })),
total,
};
}

View File

@@ -1,83 +0,0 @@
import * as React from "react";
import { Box, Paper, Typography } from "@mui/material";
import ProgressCardView from "./ProgressCard.view";
import { extractTopTags } from "./TopTags.adapter";
import { ProgressCardProps } from "./ProgressCard.props";
export default function TopTags(props: ProgressCardProps) {
const {
title,
reportData,
state,
stateSetters,
isFetching,
} = props
const { flow, selectedPeriodId, selectedGroupKey } = state;
const { setSelectedGroupKey } = stateSetters;
const { items, total } = React.useMemo(() => {
return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey);
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
return (
<Paper
sx={{
p: { xs: 2.5, sm: 4 },
borderRadius: 4,
width: "100%",
boxShadow: "none",
border: "1px solid",
borderColor: "divider",
bgcolor: "background.paper",
opacity: isFetching ? 0.6 : 1,
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
}}
>
<Typography variant="h6" fontWeight={700} gutterBottom>
{title}
</Typography>
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
md: "repeat(4, 1fr)",
},
gap: 2,
}}
>
{items.map((item) => {
const isSelected = !!selectedGroupKey?.tags?.includes(item.tag);
return (
<ProgressCardView
{...props}
key={item.tag}
title={item.tag}
progressAmount={item.amount}
totalAmount={total}
selected={isSelected}
onClick={() => {
if (setSelectedGroupKey) {
let newKey = selectedGroupKey ? { ...selectedGroupKey } : {};
if (isSelected) {
delete newKey.tags;
} else {
newKey.tags = [item.tag];
}
setSelectedGroupKey(Object.keys(newKey).length ? newKey : null);
}
}}
/>
);
})}
</Box>
</Paper>
);
}

View File

@@ -1,2 +1,2 @@
export { default } from "./ProgressCard.view"; export { default } from "./ProgressCard";
export * from "./ProgressCard.props"; export * from "./ProgressCard.models";

View File

@@ -1,230 +0,0 @@
import {
ReportPeriod,
ReportBucket,
GroupKey,
PeriodType,
ReportData,
Transaction,
} from "../features/report";
// ─── Types ────────────────────────────────────────────────────
export type PeriodKey = PeriodType;
export type DecoratedPeriod = ReportPeriod & {
id: string;
label: string;
};
// ─── Period helpers ───────────────────────────────────────────
const PREFIX_TO_KEY: Record<string, PeriodKey> = {
D: "daily",
W: "weekly",
M: "monthly",
ALL: "all",
};
/**
* Derive the period key from a decorated-period id.
* E.g. `"W:2026-04-28_2026-05-04"` → `"weekly"`
*/
export function periodIdToKey(periodId: string): PeriodKey {
const prefix = periodId.split(":")[0];
return PREFIX_TO_KEY[prefix] ?? "all";
}
// ─── Metric helpers ───────────────────────────────────────────
export function getAmount(period: ReportPeriod): number {
return period.metric.sum;
}
function mergeMetric(a: ReportPeriod["metric"], b: ReportPeriod["metric"]) {
const sum = a.sum + b.sum;
const count = a.count + b.count;
return {
...a,
sum,
count,
average: count > 0 ? sum / count : 0,
transactions:
a.transactions || b.transactions
? [...(a.transactions || []), ...(b.transactions || [])]
: undefined,
};
}
/**
* Merge periods with the same id across all buckets, summing
* their metrics and concatenating transactions.
*
* Returns sorted by start date ascending.
*/
export function mergeBucketPeriods(
buckets: ReportBucket[],
key: PeriodKey
): DecoratedPeriod[] {
const map = new Map<string, DecoratedPeriod>();
for (const bucket of buckets) {
const periods = (bucket.periods[key] || []) as DecoratedPeriod[];
for (const p of periods) {
const existing = map.get(p.id);
if (!existing) {
map.set(p.id, {
...p,
metric: { ...p.metric },
});
} else {
map.set(p.id, {
...existing,
metric: mergeMetric(existing.metric, p.metric),
});
}
}
}
return Array.from(map.values()).sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()
);
}
// ─── Formatting ───────────────────────────────────────────────
export const formatCurrency = (val: number) => {
const absVal = Math.abs(val);
if (absVal >= 100000) {
return `${(val / 100000).toFixed(2)}L`;
}
if (absVal >= 1000) {
return `${(val / 1000).toFixed(2)}k`;
}
return `${val.toFixed(2)}`;
};
export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
};
// ─── Group filtering ──────────────────────────────────────────
/**
* Check if a bucket's group_key matches the selected GroupKey.
* Every dimension present in `selected` must exist in the bucket
* and contain all the selected values.
*/
export function matchesGroupKey(
bucket: ReportBucket,
selected: GroupKey
): boolean {
for (const [dim, values] of Object.entries(selected)) {
const bucketValues = bucket.group_key[dim];
if (!bucketValues) return false;
if (!(values as string[]).every((v) => bucketValues.includes(v)))
return false;
}
return true;
}
/**
* Return only buckets matching the selected group key,
* or all buckets if no selection.
*/
export function filterBuckets(
buckets: ReportBucket[],
selectedGroupKey: GroupKey | null
): ReportBucket[] {
if (!selectedGroupKey) return buckets;
return buckets.filter((b) => matchesGroupKey(b, selectedGroupKey));
}
export function extractFilteredTransactions(
reportData: ReportData,
selectedPeriodId: string | null | undefined,
selectedGroupKey: GroupKey | null | undefined
): Transaction[] {
let txns: Transaction[] = [];
if (selectedPeriodId) {
const key = periodIdToKey(selectedPeriodId);
const periods = mergeBucketPeriods(reportData.buckets, key);
const selected = periods.find((p) => p.id === selectedPeriodId);
txns = selected?.metric.transactions || [];
} else {
const periods = mergeBucketPeriods(reportData.buckets, "all");
if (periods.length > 0) {
const period = periods.reduce((latest, p) =>
new Date(p.start).getTime() > new Date(latest.start).getTime()
? p
: latest
, periods[0]);
txns = period?.metric.transactions || [];
}
}
if (selectedGroupKey) {
txns = txns.filter((txn) => {
let match = true;
if (selectedGroupKey.tags && selectedGroupKey.tags.length > 0) {
if (!txn.tags) {
match = false;
} else {
const txnTags = txn.tags.map((t: any) =>
typeof t === "string" ? t : t.name
);
if (
!selectedGroupKey.tags.every((selectedTag) =>
txnTags.includes(selectedTag)
)
) {
match = false;
}
}
}
if (match && selectedGroupKey.payee && selectedGroupKey.payee.length > 0) {
if (!txn.payee || !txn.payee.name) {
match = false;
} else {
if (!selectedGroupKey.payee.includes(txn.payee.name)) {
match = false;
}
}
}
return match;
});
}
return txns;
}
export function aggregateTransactions(
transactions: Transaction[],
keyExtractor: (txn: Transaction) => string[],
limit = 4
): { items: { name: string; amount: number }[]; total: number } {
const map = new Map<string, number>();
for (const txn of transactions) {
const keys = keyExtractor(txn);
for (const key of keys) {
map.set(key, (map.get(key) || 0) + txn.amount);
}
}
const items = Array.from(map.entries()).map(([name, amount]) => ({
name,
amount,
}));
items.sort((a, b) => b.amount - a.amount);
const top = items.slice(0, limit);
const total = top.reduce((sum, item) => sum + item.amount, 0);
return { items: top, total };
}

View File

@@ -1,40 +0,0 @@
import HistoryChart from "./components/HistoryChart";
import LatestItems from "./components/LatestItems";
import { DashboardConfig } from "./components/Dashboard";
import TopTags from "./components/ProgressCard/TopTags";
import TopPayees from "./components/ProgressCard/TopPayees";
export const configuration: DashboardConfig = {
sections: [
{
id: "breakdown",
title: "Breakdown",
summary: "Interactive chronological tracking",
component: HistoryChart,
settings: {
tabs: ["Weekly", "Monthly"],
},
},
{
id: "top-categories",
title: 'Top Categories',
component: TopTags,
settings: {
compact: true,
},
},
{
id: "top-payees",
title: 'Top Payees',
component: TopPayees,
settings: {
compact: true,
},
},
{
id: "items",
title: 'Recent Transactions',
component: LatestItems,
},
],
};

View File

@@ -0,0 +1,40 @@
import * as React from "react";
import MonetizationOnIcon from "@mui/icons-material/MonetizationOn";
import { LatestItem } from "../../components/LatestItems";
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, {
sx: { color: "#388e3c" }
});
export function mapToLatestItems(
items: any[],
type: "expense" | "income"
): LatestItem[] {
const isValid = (amt: number) =>
type === "expense" ? amt < 0 : amt > 0;
return items
.filter((item: any) => isValid(Number(item.amount) || 0))
.slice(0, 5)
.map((exp: any, index: number) => {
const time = new Date(
exp.occurred_at || exp.created_at || Date.now()
).getTime();
const diffDays = Math.floor(
Math.abs(Date.now() - time) / (1000 * 60 * 60 * 24)
);
return {
id: exp.id || index,
icon: DEFAULT_ICON,
iconBgColor:
type === "expense" ? "#ffebee" : "#e8f5e9",
title: exp.payee?.name || exp.payee || "Unknown Payee",
subtitle:
exp.category?.name || exp.account?.name || "Transaction",
amount: `Rs ${Math.abs(exp.amount || 0)}`,
timeAgo: diffDays === 0 ? "Today" : `${diffDays} days ago`
};
});
}

View File

@@ -0,0 +1,3 @@
export {
useDashboardData
} from './useDashboardData'

View File

@@ -0,0 +1,52 @@
import { useResourceByName } from "../../../react-openapi";
import { mapToLatestItems } from "./dashboard.mapper";
import { mapReportToDashboard } from "../report/report.mapper";
export function useDashboardData(type: "expense" | "income") {
const { useList: useExpenseList } = useResourceByName("expenses");
const { useList: useReportList } = useResourceByName("reports");
// Fetch latest transactions
const latestQuery = useExpenseList({
limit: 100,
sort: "-occurred_at"
});
// Fetch reports for aggregation
const weeklyReport = useReportList({ period: "weekly", rolling: true });
const monthlyReport = useReportList({ period: "monthly", rolling: true });
const payeeReport = useReportList({ period: "weekly", rolling: true, group_by: "payee" });
const isLoading =
latestQuery.isLoading ||
weeklyReport.isLoading ||
monthlyReport.isLoading ||
payeeReport.isLoading;
const error =
latestQuery.error ||
weeklyReport.error ||
monthlyReport.error ||
payeeReport.error;
const latest = latestQuery.data?.data
? mapToLatestItems(latestQuery.data.data, type)
: [];
const aggregatedData =
weeklyReport.data?.data && monthlyReport.data?.data && payeeReport.data?.data
? mapReportToDashboard(
(weeklyReport.data.data as any).buckets,
(monthlyReport.data.data as any).buckets,
(payeeReport.data.data as any).buckets,
type
)
: null;
return {
data: aggregatedData,
latest: latest,
isLoading,
error,
};
}

View File

@@ -1,38 +0,0 @@
export type FetchRequestStatus = "pending" | "processing" | "raw_expenses_done" | "enriched_done" | "completed" | "failed";
export interface FileSource {
path: string;
format: string;
}
export interface EmailSource {
format: string;
from_email?: string;
subject?: string;
raw_terms?: string[];
}
export interface FetchRequestCreate {
source: FileSource | EmailSource;
account_name: string;
payor_username?: string;
start_date?: string;
end_date?: string;
}
export interface FetchRequest extends FetchRequestCreate {
id: string;
status: FetchRequestStatus;
fingerprint: string;
completed_at?: string | null;
error_message?: string | null;
created_at: string;
}
export interface UploadResult {
original_filename: string;
saved_as: string;
content_type: string;
url: string;
absolute_path: string;
}

View File

@@ -1,15 +0,0 @@
export type {
FetchRequest,
FetchRequestCreate,
FetchRequestStatus,
FileSource,
EmailSource,
UploadResult,
} from "./fetch-requests.models";
export {
useFetchRequestsList,
useFetchRequest,
useCreateFetchRequest,
useDeleteFetchRequest,
useUploadFile,
} from "./useFetchRequests";

View File

@@ -1,43 +0,0 @@
import { useResourceByName } from "../../../react-openapi";
import { api } from "../../../react-openapi/api/client";
import { useMutation } from "@tanstack/react-query";
export function useFetchRequestsList(params?: {
status?: string;
account_name?: string;
source_type?: string;
}) {
const { useList } = useResourceByName("fetch-requests");
return useList(params);
}
export function useFetchRequest(id: string) {
const { useRead } = useResourceByName("fetch-requests");
return useRead(id);
}
export function useCreateFetchRequest() {
const { useCreate } = useResourceByName("fetch-requests");
return useCreate();
}
export function useDeleteFetchRequest() {
const { useDelete } = useResourceByName("fetch-requests");
return useDelete();
}
export function useUploadFile() {
return useMutation({
mutationFn: async (file: File) => {
const arrayBuffer = await file.arrayBuffer();
const binary = new Uint8Array(arrayBuffer);
const res = await api.post("/uploads", binary, {
headers: {
"Content-Type": file.type,
"Content-Disposition": `attachment; filename="${file.name}"`,
},
});
return res.data;
},
});
}

View File

@@ -1,9 +0,0 @@
export type {
ReportSnapshot,
ReportQuery,
} from "./report-snapshots.models";
export {
useReportSnapshotsList,
useCreateSnapshot,
useDeleteSnapshot,
} from "./useReportSnapshots";

View File

@@ -1,15 +0,0 @@
export interface ReportQuery {
accounts?: string[] | null;
ignore_self?: boolean | null;
start_date?: string | null;
end_date?: string | null;
min_amount?: number | null;
max_amount?: number | null;
}
export interface ReportSnapshot {
id: string;
snapshot_id: string;
created_at: string;
query?: ReportQuery;
}

View File

@@ -1,16 +0,0 @@
import { useResourceByName } from "../../../react-openapi";
export function useReportSnapshotsList() {
const { useList } = useResourceByName("reports");
return useList();
}
export function useCreateSnapshot() {
const { useCreate } = useResourceByName("reports");
return useCreate();
}
export function useDeleteSnapshot() {
const { useDelete } = useResourceByName("reports");
return useDelete();
}

View File

@@ -1,15 +1,3 @@
export { export {
useReport useReport
} from './useReport' } from './useReport'
export type {
Transaction,
ReportData,
ReportBucket,
ReportPeriod,
ReportQuery,
GroupKey,
PeriodType,
} from './report.models'
export {
prepareReport
} from './report.utils'

View File

@@ -0,0 +1,100 @@
import {
AggregatedDashboardData,
ChartData,
ChartDataPoint,
} from "../../components/HistoryChart";
type ReportBucket = any;
const sumBucket = (bucket: ReportBucket, flow: "expenses" | "incomes") =>
bucket.groups.reduce(
(acc: number, g: any) => acc + (g?.[flow]?.sum || 0),
0
);
const toLabel = (start: string, end: string, type: "weekly" | "monthly") => {
const s = new Date(start);
const e = new Date(end);
if (type === "monthly") {
return s.toLocaleString("default", { month: "short" });
}
return `${s.getDate()}${e.getDate()} ${e.toLocaleString("default", {
month: "short",
})}`;
};
const toPoints = (
buckets: ReportBucket[],
type: "weekly" | "monthly",
flow: "expenses" | "incomes"
): ChartDataPoint[] => {
return buckets.map((b, i) => {
const amount = sumBucket(b, flow);
const prev = buckets[i - 1];
return {
id: toLabel(b.start, b.end, type),
amount,
compare: prev
? {
id: toLabel(prev.start, prev.end, type),
amount: sumBucket(prev, flow),
}
: undefined,
};
});
};
export function mapReportToDashboard(
weekly: ReportBucket[],
monthly: ReportBucket[],
payeeBuckets: ReportBucket[],
type: "expense" | "income"
): AggregatedDashboardData {
const flow = type === "expense" ? "expenses" : "incomes";
const chartData: ChartData = {
daily: [],
weekly: {
rolling: toPoints(weekly, "weekly", flow),
calendar: toPoints(weekly, "weekly", flow),
},
monthly: {
rolling: toPoints(monthly, "monthly", flow),
calendar: toPoints(monthly, "monthly", flow),
},
};
const totalAmount = weekly.reduce(
(acc, b) => acc + sumBucket(b, flow),
0
);
const payeeMap: Record<string, number> = {};
const sourceForPayees = (payeeBuckets && payeeBuckets.length > 0) ? payeeBuckets : weekly;
for (const b of sourceForPayees) {
for (const g of b.groups) {
const key = g.group_key || "Unknown";
const amt = g?.[flow]?.sum || 0;
payeeMap[key] = (payeeMap[key] || 0) + amt;
}
}
const topPayees = Object.entries(payeeMap)
// .filter(([name]) => name !== "Unknown")
.map(([payeeName, amount]) => ({ payeeName, amount }))
.sort((a, b) => b.amount - a.amount)
.slice(0, 5);
return {
chartData,
totalAmount,
topPayees,
};
}

View File

@@ -1,112 +0,0 @@
export interface Payor {
id?: string;
name: string;
username: string;
email: string;
}
export interface Payee {
type: "merchant" | "person" | "transfer" | "other";
name: string;
}
export interface Account {
id: string;
name: string;
number: string;
type: "cash" | "bank" | "credit_card" | "wallet" | "other";
currency: string;
is_active?: boolean;
}
export interface Tag {
id: string;
name: string;
icon: string;
parent_id?: string | null;
}
export interface Transaction {
id: string;
payor: Payor;
payee: Payee;
amount: number;
account: Account;
tags: Tag[];
occurred_at: string;
created_at: string;
}
// -----------------------------
// Metrics
// -----------------------------
export interface ReportMetric {
sum: number;
count: number;
average: number;
transactions?: Transaction[];
}
// -----------------------------
// Period
// -----------------------------
export type PeriodType = "daily" | "weekly" | "monthly" | "all";
export interface ReportPeriod {
start: string;
end: string;
metric: ReportMetric;
}
// -----------------------------
// Group (bucket)
// -----------------------------
export type GroupKey = {
[dimension: string]: string[];
};
export interface ReportBucket {
group_key: GroupKey;
periods: {
daily?: ReportPeriod[];
weekly?: ReportPeriod[];
monthly?: ReportPeriod[];
all?: ReportPeriod[];
};
}
// -----------------------------
// Report Query
// -----------------------------
export interface ReportQuery {
accounts?: string[] | null;
ignore_self?: boolean | null;
start_date?: string | null;
end_date?: string | null;
min_amount?: number | null;
max_amount?: number | null;
}
// -----------------------------
// Final Report
// -----------------------------
export interface ReportData {
snapshot_id?: string | null;
flow?: "inflows" | "outflows" | null;
periods: PeriodType[];
tags?: string[] | null;
payee?: string[] | null;
buckets: ReportBucket[];
query: ReportQuery;
}

View File

@@ -1,117 +0,0 @@
import {
ReportData,
ReportPeriod,
PeriodType,
} from "./report.models";
/* ---------- ID BUILDING ---------- */
function formatDate(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function buildPeriodId(
type: PeriodType,
start: Date,
end: Date
): string {
const s = formatDate(start);
const e = formatDate(end);
switch (type) {
case "daily":
return `D:${s}_${e}`;
case "weekly":
return `W:${s}_${e}`;
case "monthly":
return `M:${s}_${e}`;
case "all":
return `ALL:${s}_${e}`;
default:
return `${s}_${e}`;
}
}
/* ---------- LABEL BUILDING ---------- */
const dayFmt = new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "short",
timeZone: "UTC",
});
const monthDayFmt = new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
const monthFmt = new Intl.DateTimeFormat("en-GB", {
month: "short",
timeZone: "UTC",
});
const yearFmt = new Intl.DateTimeFormat("en-GB", {
year: "numeric",
timeZone: "UTC",
});
function buildLabel(
type: PeriodType,
start: Date,
end: Date
): string {
switch (type) {
case "daily":
return dayFmt.format(start);
case "weekly": {
const sDay = start.getUTCDate();
const m = monthFmt.format(start);
return `${sDay} ${m}`;
}
case "monthly":
return `${monthFmt.format(start)} ${yearFmt.format(start)}`;
default:
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
}
}
/* ---------- MAIN ---------- */
function decoratePeriods(
type: PeriodType,
periods: ReportPeriod[]
): (ReportPeriod & { id: string; label: string })[] {
return periods.map((p) => ({
...p,
id: buildPeriodId(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
label: buildLabel(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
}));
}
export function prepareReport(reportData: ReportData): ReportData {
return {
...reportData,
buckets: reportData.buckets.map((bucket) => {
const newPeriods: typeof bucket.periods = {};
for (const type of reportData.periods) {
const arr = bucket.periods[type];
if (arr) {
newPeriods[type] = decoratePeriods(type, arr);
}
}
return {
...bucket,
periods: newPeriods,
};
}),
};
}

View File

@@ -1,21 +1,14 @@
import { useResourceByName } from "../../../react-openapi"; import { useResourceByName } from "../../../react-openapi";
export interface ReportParams { export interface ReportParams {
snapshot_id?: string; period: "weekly" | "monthly" | "yearly" | "fyly";
periods?: ("daily" | "weekly" | "monthly" | "all")[]; rolling?: boolean;
flow?: "inflows" | "outflows"; report_date?: string;
payee?: string[]; group_by?: ("flow" | "payee" | "tags")[];
tags?: string[]; ignore_self?: boolean;
} }
export function useReport(params: ReportParams) { export function useReport(params: ReportParams) {
const { useRead } = useResourceByName("reports"); const { useList } = useResourceByName("reports");
return useList(params);
return useRead(
params.snapshot_id ? params.snapshot_id : "latest",
{
...params,
periods: params.periods,
}
);
} }

View File

@@ -12,8 +12,6 @@ import {
} from "@mui/material"; } from "@mui/material";
import Home from './Home'; import Home from './Home';
import Dashboard from './Dashboard'; import Dashboard from './Dashboard';
import FetchRequests from './FetchRequests';
import ReportSnapshots from './ReportSnapshots';
import { Admin, AppProvider } from '../react-openapi'; import { Admin, AppProvider } from '../react-openapi';
import { configuration, profileConfiguration } from './openapi-config'; import { configuration, profileConfiguration } from './openapi-config';
import { Buffer } from 'buffer'; import { Buffer } from 'buffer';
@@ -21,7 +19,7 @@ import process from 'process';
import { AuthProvider } from "../react-auth"; import { AuthProvider } from "../react-auth";
import Header from './Header'; import Header from './Header';
import Footer from './Footer'; import Footer from './Footer';
import AppTheme from './shared-theme/AppTheme'; import AppTheme from './AppTheme';
window.Buffer = Buffer; window.Buffer = Buffer;
window.process = process; window.process = process;
@@ -35,8 +33,6 @@ const routerMapping = [
{ path: "/", component: Home, headerTitle: "Home" }, { path: "/", component: Home, headerTitle: "Home" },
{ path: "/home", component: Home, headerTitle: "Home" }, { path: "/home", component: Home, headerTitle: "Home" },
{ path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" }, { path: "/dashboard", component: Dashboard, headerTitle: "Dashboard" },
{ path: "/fetch-requests", component: FetchRequests, headerTitle: "Fetch Requests" },
{ path: "/reports", component: ReportSnapshots, headerTitle: "Reports" },
{ path: "/admin/*", component: Admin, headerTitle: "Admin" }, { path: "/admin/*", component: Admin, headerTitle: "Admin" },
]; ];

View File

@@ -40,9 +40,9 @@ export const configuration: Record<string, ResourceOverride> = {
}, },
pagination: true, pagination: true,
}, },
// reports: { reports: {
// hidden: true hidden: true
// } }
}; };
export const profileConfiguration = { export const profileConfiguration = {

View File

@@ -1,103 +1,53 @@
import * as React from "react"; import * as React from 'react';
import { import { ThemeProvider, createTheme } from '@mui/material/styles';
ThemeProvider, import type { ThemeOptions } from '@mui/material/styles';
createTheme, import { inputsCustomizations } from './customizations/inputs';
CssBaseline, import { dataDisplayCustomizations } from './customizations/dataDisplay';
Box, import { feedbackCustomizations } from './customizations/feedback';
} from "@mui/material"; import { navigationCustomizations } from './customizations/navigation';
import { surfacesCustomizations } from './customizations/surfaces';
import { colorSchemes, typography, shadows, shape } from './themePrimitives';
import { getDesignTokens } from "./themePrimitives"; interface AppThemeProps {
import { getSemanticColors } from "./themeConfig";
import { inputsCustomizations } from "./customizations/inputs";
import { dataDisplayCustomizations } from "./customizations/dataDisplay";
import { feedbackCustomizations } from "./customizations/feedback";
import { navigationCustomizations } from "./customizations/navigation";
import { surfacesCustomizations } from "./customizations/surfaces";
export type ColorMode = "light" | "dark";
type ColorModeContextValue = {
mode: ColorMode;
setMode: (mode: ColorMode) => void;
toggleColorMode: () => void;
};
export const ColorModeContext =
React.createContext<ColorModeContextValue>({
mode: "light",
setMode: () => {},
toggleColorMode: () => {},
});
type AppThemeProps = {
children: React.ReactNode; children: React.ReactNode;
defaultMode?: ColorMode; /**
}; * This is for the docs site. You can ignore it or remove it.
*/
export default function AppTheme({ disableCustomTheme?: boolean;
children, themeComponents?: ThemeOptions['components'];
defaultMode = "light", }
}: AppThemeProps) {
const [mode, setMode] =
React.useState<ColorMode>(defaultMode);
const toggleColorMode = React.useCallback(() => {
setMode((prev) =>
prev === "light" ? "dark" : "light"
);
}, []);
const contextValue = React.useMemo(
() => ({
mode,
setMode,
toggleColorMode,
}),
[mode, toggleColorMode]
);
const semantic = React.useMemo(
() => getSemanticColors(mode),
[mode]
);
const theme = React.useMemo(
() =>
createTheme({
...getDesignTokens(mode),
semantic,
export default function AppTheme(props: AppThemeProps) {
const { children, disableCustomTheme, themeComponents } = props;
const theme = React.useMemo(() => {
return disableCustomTheme
? {}
: createTheme({
// For more details about CSS variables configuration, see https://mui.com/material-ui/customization/css-theme-variables/configuration/
cssVariables: {
colorSchemeSelector: 'data-mui-color-scheme',
cssVarPrefix: 'template',
},
colorSchemes, // Recently added in v6 for building light & dark mode app, see https://mui.com/material-ui/customization/palette/#color-schemes
typography,
shadows,
shape,
components: { components: {
...inputsCustomizations, ...inputsCustomizations,
...dataDisplayCustomizations, ...dataDisplayCustomizations,
...feedbackCustomizations, ...feedbackCustomizations,
...navigationCustomizations, ...navigationCustomizations,
...surfacesCustomizations, ...surfacesCustomizations,
...themeComponents,
}, },
}), });
[mode, semantic] }, [disableCustomTheme, themeComponents]);
); if (disableCustomTheme) {
return <React.Fragment>{children}</React.Fragment>;
}
return ( return (
<ColorModeContext.Provider value={contextValue}> <ThemeProvider theme={theme} disableTransitionOnChange>
<ThemeProvider theme={theme}>
<CssBaseline />
<Box
sx={{
"--bg-page": semantic.surface.page,
"--bg-card": semantic.surface.card,
"--bg-elevated": semantic.surface.elevated,
"--border-default": semantic.border.default,
"--border-subtle": semantic.border.subtle,
"--text-primary": semantic.text.primary,
"--text-secondary": semantic.text.secondary,
"--text-muted": semantic.text.muted,
}}
>
{children} {children}
</Box>
</ThemeProvider> </ThemeProvider>
</ColorModeContext.Provider>
); );
} }

View File

@@ -0,0 +1,89 @@
import * as React from 'react';
import DarkModeIcon from '@mui/icons-material/DarkModeRounded';
import LightModeIcon from '@mui/icons-material/LightModeRounded';
import Box from '@mui/material/Box';
import IconButton, { IconButtonOwnProps } from '@mui/material/IconButton';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import { useColorScheme } from '@mui/material/styles';
export default function ColorModeIconDropdown(props: IconButtonOwnProps) {
const { mode, systemMode, setMode } = useColorScheme();
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const open = Boolean(anchorEl);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const handleMode = (targetMode: 'system' | 'light' | 'dark') => () => {
setMode(targetMode);
handleClose();
};
if (!mode) {
return (
<Box
data-screenshot="toggle-mode"
sx={(theme) => ({
verticalAlign: 'bottom',
display: 'inline-flex',
width: '2.25rem',
height: '2.25rem',
borderRadius: (theme.vars || theme).shape.borderRadius,
border: '1px solid',
borderColor: (theme.vars || theme).palette.divider,
})}
/>
);
}
const resolvedMode = (systemMode || mode) as 'light' | 'dark';
const icon = {
light: <LightModeIcon />,
dark: <DarkModeIcon />,
}[resolvedMode];
return (
<React.Fragment>
<IconButton
data-screenshot="toggle-mode"
onClick={handleClick}
disableRipple
size="small"
aria-controls={open ? 'color-scheme-menu' : undefined}
aria-haspopup="true"
aria-expanded={open ? 'true' : undefined}
{...props}
>
{icon}
</IconButton>
<Menu
anchorEl={anchorEl}
id="account-menu"
open={open}
onClose={handleClose}
onClick={handleClose}
slotProps={{
paper: {
variant: 'outlined',
elevation: 0,
sx: {
my: '4px',
},
},
}}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
>
<MenuItem selected={mode === 'system'} onClick={handleMode('system')}>
System
</MenuItem>
<MenuItem selected={mode === 'light'} onClick={handleMode('light')}>
Light
</MenuItem>
<MenuItem selected={mode === 'dark'} onClick={handleMode('dark')}>
Dark
</MenuItem>
</Menu>
</React.Fragment>
);
}

View File

@@ -0,0 +1,28 @@
import * as React from 'react';
import { useColorScheme } from '@mui/material/styles';
import MenuItem from '@mui/material/MenuItem';
import Select, { SelectProps } from '@mui/material/Select';
export default function ColorModeSelect(props: SelectProps) {
const { mode, setMode } = useColorScheme();
if (!mode) {
return null;
}
return (
<Select
value={mode}
onChange={(event) =>
setMode(event.target.value as 'system' | 'light' | 'dark')
}
SelectDisplayProps={{
// @ts-ignore
'data-screenshot': 'toggle-mode',
}}
{...props}
>
<MenuItem value="system">System</MenuItem>
<MenuItem value="light">Light</MenuItem>
<MenuItem value="dark">Dark</MenuItem>
</Select>
);
}

View File

@@ -14,8 +14,8 @@ export const feedbackCustomizations: Components<Theme> = {
color: orange[500], color: orange[500],
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
backgroundColor: alpha(orange[900], 0.35), backgroundColor: `${alpha(orange[900], 0.5)}`,
border: `1px solid ${alpha(orange[800], 0.3)}`, border: `1px solid ${alpha(orange[800], 0.5)}`,
}), }),
}), }),
}, },

View File

@@ -125,15 +125,15 @@ export const inputsCustomizations: Components<Theme> = {
backgroundColor: gray[200], backgroundColor: gray[200],
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
backgroundColor: 'hsla(0, 0%, 100%, 0.06)', backgroundColor: gray[800],
borderColor: (theme.vars || theme).palette.divider, borderColor: gray[700],
'&:hover': { '&:hover': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)', backgroundColor: gray[900],
borderColor: 'hsla(0, 0%, 100%, 0.15)', borderColor: gray[600],
}, },
'&:active': { '&:active': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)', backgroundColor: gray[900],
}, },
}), }),
}, },
@@ -183,12 +183,12 @@ export const inputsCustomizations: Components<Theme> = {
backgroundColor: gray[200], backgroundColor: gray[200],
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
color: 'hsl(0, 0%, 92%)', color: gray[50],
'&:hover': { '&:hover': {
backgroundColor: 'hsla(0, 0%, 100%, 0.08)', backgroundColor: gray[700],
}, },
'&:active': { '&:active': {
backgroundColor: 'hsla(0, 0%, 100%, 0.12)', backgroundColor: alpha(gray[700], 0.7),
}, },
}), }),
}, },
@@ -241,14 +241,14 @@ export const inputsCustomizations: Components<Theme> = {
backgroundColor: gray[200], backgroundColor: gray[200],
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
backgroundColor: 'hsla(0, 0%, 100%, 0.06)', backgroundColor: gray[800],
borderColor: (theme.vars || theme).palette.divider, borderColor: gray[700],
'&:hover': { '&:hover': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)', backgroundColor: gray[900],
borderColor: 'hsla(0, 0%, 100%, 0.15)', borderColor: gray[600],
}, },
'&:active': { '&:active': {
backgroundColor: 'hsla(0, 0%, 100%, 0.1)', backgroundColor: gray[900],
}, },
}), }),
variants: [ variants: [
@@ -288,7 +288,7 @@ export const inputsCustomizations: Components<Theme> = {
[`& .${toggleButtonGroupClasses.selected}`]: { [`& .${toggleButtonGroupClasses.selected}`]: {
color: '#fff', color: '#fff',
}, },
boxShadow: `0 2px 8px ${alpha(brand[700], 0.3)}`, boxShadow: `0 4px 16px ${alpha(brand[700], 0.5)}`,
}), }),
}), }),
}, },
@@ -302,7 +302,7 @@ export const inputsCustomizations: Components<Theme> = {
fontWeight: 500, fontWeight: 500,
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
color: gray[400], color: gray[400],
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.25)', boxShadow: '0 4px 16px rgba(0, 0, 0, 0.5)',
[`&.${toggleButtonClasses.selected}`]: { [`&.${toggleButtonClasses.selected}`]: {
color: brand[300], color: brand[300],
}, },

View File

@@ -49,8 +49,9 @@ export const navigationCustomizations: Components<Theme> = {
}, },
}, },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
background: (theme.vars || theme).palette.background.paper, background: gray[900],
boxShadow: '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)', boxShadow:
'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
}), }),
}), }),
}, },
@@ -83,17 +84,17 @@ export const navigationCustomizations: Components<Theme> = {
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
borderRadius: (theme.vars || theme).shape.borderRadius, borderRadius: (theme.vars || theme).shape.borderRadius,
borderColor: (theme.vars || theme).palette.divider, borderColor: gray[700],
backgroundColor: (theme.vars || theme).palette.background.paper, backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'inset 0 1px 0 hsla(0, 0%, 100%, 0.05)', boxShadow: `inset 0 1px 0 1px ${alpha(gray[700], 0.15)}, inset 0 -1px 0 1px hsla(220, 0%, 0%, 0.7)`,
'&:hover': { '&:hover': {
borderColor: 'hsla(0, 0%, 100%, 0.15)', borderColor: alpha(gray[700], 0.7),
backgroundColor: (theme.vars || theme).palette.background.paper, backgroundColor: (theme.vars || theme).palette.background.paper,
boxShadow: 'none', boxShadow: 'none',
}, },
[`&.${selectClasses.focused}`]: { [`&.${selectClasses.focused}`]: {
outlineOffset: 0, outlineOffset: 0,
borderColor: 'hsl(210, 55%, 55%)', borderColor: gray[900],
}, },
'&:before, &:after': { '&:before, &:after': {
display: 'none', display: 'none',
@@ -107,7 +108,7 @@ export const navigationCustomizations: Components<Theme> = {
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
'&:focus-visible': { '&:focus-visible': {
backgroundColor: (theme.vars || theme).palette.background.default, backgroundColor: gray[900],
}, },
}), }),
}), }),
@@ -150,7 +151,6 @@ export const navigationCustomizations: Components<Theme> = {
styleOverrides: { styleOverrides: {
paper: ({ theme }) => ({ paper: ({ theme }) => ({
backgroundColor: (theme.vars || theme).palette.background.default, backgroundColor: (theme.vars || theme).palette.background.default,
borderRight: `1px solid ${(theme.vars || theme).palette.divider}`,
}), }),
}, },
}, },
@@ -204,8 +204,8 @@ export const navigationCustomizations: Components<Theme> = {
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
':hover': { ':hover': {
color: (theme.vars || theme).palette.text.primary, color: (theme.vars || theme).palette.text.primary,
backgroundColor: alpha((theme.vars || theme).palette.common.white, 0.08), backgroundColor: gray[800],
borderColor: (theme.vars || theme).palette.divider, borderColor: gray[700],
}, },
[`&.${tabClasses.selected}`]: { [`&.${tabClasses.selected}`]: {
color: '#fff', color: '#fff',

View File

@@ -40,7 +40,7 @@ export const surfacesCustomizations: Components<Theme> = {
'&:hover': { backgroundColor: gray[50] }, '&:hover': { backgroundColor: gray[50] },
'&:focus-visible': { backgroundColor: 'transparent' }, '&:focus-visible': { backgroundColor: 'transparent' },
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
'&:hover': { backgroundColor: alpha(theme.palette.common.white, 0.06) }, '&:hover': { backgroundColor: gray[800] },
}), }),
}), }),
}, },
@@ -67,7 +67,7 @@ export const surfacesCustomizations: Components<Theme> = {
border: `1px solid ${(theme.vars || theme).palette.divider}`, border: `1px solid ${(theme.vars || theme).palette.divider}`,
boxShadow: 'none', boxShadow: 'none',
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
backgroundColor: (theme.vars || theme).palette.background.paper, backgroundColor: gray[800],
}), }),
variants: [ variants: [
{ {
@@ -79,7 +79,7 @@ export const surfacesCustomizations: Components<Theme> = {
boxShadow: 'none', boxShadow: 'none',
background: 'hsl(0, 0%, 100%)', background: 'hsl(0, 0%, 100%)',
...theme.applyStyles('dark', { ...theme.applyStyles('dark', {
background: alpha((theme.vars || theme).palette.background.paper, 0.6), background: alpha(gray[900], 0.4),
}), }),
}, },
}, },

View File

@@ -1,72 +0,0 @@
import { gray } from "./themePrimitives";
import { alpha } from "@mui/material/styles";
declare module "@mui/material/styles" {
interface Theme {
semantic: SemanticColors;
}
interface ThemeOptions {
semantic?: SemanticColors;
}
}
export type SemanticColorMode = "light" | "dark";
export interface SemanticColors {
surface: {
page: string;
card: string;
elevated: string;
};
border: {
default: string;
subtle: string;
};
text: {
primary: string;
secondary: string;
muted: string;
};
}
const darkBg = 'hsl(0, 0%, 9%)';
const darkPaper = 'hsl(0, 0%, 14%)';
const darkElevated = 'hsl(0, 0%, 19%)';
export function getSemanticColors(mode: SemanticColorMode): SemanticColors {
if (mode === "dark") {
return {
surface: {
page: darkBg,
card: darkPaper,
elevated: darkElevated,
},
border: {
default: 'hsla(0, 0%, 100%, 0.08)',
subtle: 'hsla(0, 0%, 100%, 0.04)',
},
text: {
primary: 'hsl(0, 0%, 92%)',
secondary: 'hsl(0, 0%, 60%)',
muted: 'hsl(0, 0%, 45%)',
},
};
}
return {
surface: {
page: "hsl(0, 0%, 99%)",
card: "hsl(220, 35%, 97%)",
elevated: gray[100],
},
border: {
default: alpha(gray[300], 0.4),
subtle: alpha(gray[200], 0.3),
},
text: {
primary: gray[800],
secondary: gray[600],
muted: gray[500],
},
};
}

View File

@@ -23,10 +23,6 @@ declare module '@mui/material/styles' {
interface Palette { interface Palette {
baseShadow: string; baseShadow: string;
flows: {
outflows: { primary: string; surface: string; text: string };
inflows: { primary: string; surface: string; text: string };
};
} }
} }
@@ -56,9 +52,7 @@ export const gray = {
500: 'hsl(220, 20%, 42%)', 500: 'hsl(220, 20%, 42%)',
600: 'hsl(220, 20%, 35%)', 600: 'hsl(220, 20%, 35%)',
700: 'hsl(220, 20%, 25%)', 700: 'hsl(220, 20%, 25%)',
750: 'hsl(220, 20%, 18%)',
800: 'hsl(220, 30%, 6%)', 800: 'hsl(220, 30%, 6%)',
850: 'hsl(220, 22%, 11%)',
900: 'hsl(220, 35%, 3%)', 900: 'hsl(220, 35%, 3%)',
}; };
@@ -101,14 +95,10 @@ export const red = {
900: 'hsl(0, 93%, 6%)', 900: 'hsl(0, 93%, 6%)',
}; };
const darkBg = 'hsl(0, 0%, 9%)';
const darkPaper = 'hsl(0, 0%, 14%)';
const darkElevated = 'hsl(0, 0%, 19%)';
export const getDesignTokens = (mode: PaletteMode) => { export const getDesignTokens = (mode: PaletteMode) => {
customShadows[1] = customShadows[1] =
mode === 'dark' mode === 'dark'
? '0 4px 16px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.3)' ? 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px'
: 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px'; : 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px';
return { return {
@@ -121,9 +111,9 @@ export const getDesignTokens = (mode: PaletteMode) => {
contrastText: brand[50], contrastText: brand[50],
...(mode === 'dark' && { ...(mode === 'dark' && {
contrastText: brand[50], contrastText: brand[50],
light: 'hsl(210, 50%, 65%)', light: brand[300],
main: 'hsl(210, 55%, 55%)', main: brand[400],
dark: 'hsl(210, 50%, 35%)', dark: brand[700],
}), }),
}, },
info: { info: {
@@ -132,10 +122,10 @@ export const getDesignTokens = (mode: PaletteMode) => {
dark: brand[600], dark: brand[600],
contrastText: gray[50], contrastText: gray[50],
...(mode === 'dark' && { ...(mode === 'dark' && {
contrastText: 'hsl(210, 30%, 80%)', contrastText: brand[300],
light: 'hsl(210, 40%, 50%)', light: brand[500],
main: 'hsl(210, 35%, 40%)', main: brand[700],
dark: 'hsl(210, 30%, 25%)', dark: brand[900],
}), }),
}, },
warning: { warning: {
@@ -143,9 +133,9 @@ export const getDesignTokens = (mode: PaletteMode) => {
main: orange[400], main: orange[400],
dark: orange[800], dark: orange[800],
...(mode === 'dark' && { ...(mode === 'dark' && {
light: 'hsl(45, 60%, 55%)', light: orange[400],
main: 'hsl(45, 55%, 45%)', main: orange[500],
dark: 'hsl(45, 50%, 30%)', dark: orange[700],
}), }),
}, },
error: { error: {
@@ -153,9 +143,9 @@ export const getDesignTokens = (mode: PaletteMode) => {
main: red[400], main: red[400],
dark: red[800], dark: red[800],
...(mode === 'dark' && { ...(mode === 'dark' && {
light: 'hsl(0, 55%, 60%)', light: red[400],
main: 'hsl(0, 55%, 50%)', main: red[500],
dark: 'hsl(0, 50%, 35%)', dark: red[700],
}), }),
}, },
success: { success: {
@@ -163,46 +153,34 @@ export const getDesignTokens = (mode: PaletteMode) => {
main: green[400], main: green[400],
dark: green[800], dark: green[800],
...(mode === 'dark' && { ...(mode === 'dark' && {
light: 'hsl(120, 40%, 55%)', light: green[400],
main: 'hsl(120, 40%, 45%)', main: green[500],
dark: 'hsl(120, 35%, 30%)', dark: green[700],
}), }),
}, },
grey: { grey: {
...gray, ...gray,
}, },
divider: mode === 'dark' ? 'hsla(0, 0%, 100%, 0.08)' : alpha(gray[300], 0.4), divider: mode === 'dark' ? alpha(gray[700], 0.6) : alpha(gray[300], 0.4),
background: { background: {
default: 'hsl(0, 0%, 99%)', default: 'hsl(0, 0%, 99%)',
paper: 'hsl(220, 35%, 97%)', paper: 'hsl(220, 35%, 97%)',
...(mode === 'dark' && { default: darkBg, paper: darkPaper }), ...(mode === 'dark' && { default: gray[900], paper: 'hsl(220, 30%, 7%)' }),
}, },
text: { text: {
primary: gray[800], primary: gray[800],
secondary: gray[600], secondary: gray[600],
warning: orange[400], warning: orange[400],
...(mode === 'dark' && { primary: 'hsl(0, 0%, 92%)', secondary: 'hsl(0, 0%, 60%)' }), ...(mode === 'dark' && { primary: 'hsl(0, 0%, 100%)', secondary: gray[400] }),
}, },
action: { action: {
hover: alpha(gray[200], 0.2), hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`, selected: `${alpha(gray[200], 0.3)}`,
...(mode === 'dark' && { ...(mode === 'dark' && {
hover: 'hsla(0, 0%, 100%, 0.06)', hover: alpha(gray[600], 0.2),
selected: 'hsla(0, 0%, 100%, 0.1)', selected: alpha(gray[600], 0.3),
}), }),
}, },
flows: {
outflows: {
primary: mode === 'dark' ? 'hsl(0, 55%, 60%)' : '#d32f2f',
surface: mode === 'dark' ? 'hsla(0, 35%, 25%, 0.6)' : '#fdecea',
text: mode === 'dark' ? 'hsl(0, 60%, 80%)' : '#b71c1c',
},
inflows: {
primary: mode === 'dark' ? 'hsl(120, 40%, 55%)' : '#2e7d32',
surface: mode === 'dark' ? 'hsla(120, 25%, 22%, 0.6)' : '#e8f5e9',
text: mode === 'dark' ? 'hsl(120, 40%, 78%)' : '#1b5e20',
},
},
}, },
typography: { typography: {
fontFamily: 'Inter, sans-serif', fontFamily: 'Inter, sans-serif',
@@ -307,18 +285,6 @@ export const colorSchemes = {
hover: alpha(gray[200], 0.2), hover: alpha(gray[200], 0.2),
selected: `${alpha(gray[200], 0.3)}`, selected: `${alpha(gray[200], 0.3)}`,
}, },
flows: {
outflows: {
primary: '#d32f2f',
surface: '#fdecea',
text: '#b71c1c',
},
inflows: {
primary: '#2e7d32',
surface: '#e8f5e9',
text: '#1b5e20',
},
},
baseShadow: baseShadow:
'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px', 'hsla(220, 30%, 5%, 0.07) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.07) 0px 8px 16px -5px',
}, },
@@ -327,60 +293,49 @@ export const colorSchemes = {
palette: { palette: {
primary: { primary: {
contrastText: brand[50], contrastText: brand[50],
light: 'hsl(210, 50%, 65%)', light: brand[300],
main: 'hsl(210, 55%, 55%)', main: brand[400],
dark: 'hsl(210, 50%, 35%)', dark: brand[700],
}, },
info: { info: {
contrastText: 'hsl(210, 30%, 80%)', contrastText: brand[300],
light: 'hsl(210, 40%, 50%)', light: brand[500],
main: 'hsl(210, 35%, 40%)', main: brand[700],
dark: 'hsl(210, 30%, 25%)', dark: brand[900],
}, },
warning: { warning: {
light: 'hsl(45, 60%, 55%)', light: orange[400],
main: 'hsl(45, 55%, 45%)', main: orange[500],
dark: 'hsl(45, 50%, 30%)', dark: orange[700],
}, },
error: { error: {
light: 'hsl(0, 55%, 60%)', light: red[400],
main: 'hsl(0, 55%, 50%)', main: red[500],
dark: 'hsl(0, 50%, 35%)', dark: red[700],
}, },
success: { success: {
light: 'hsl(120, 40%, 55%)', light: green[400],
main: 'hsl(120, 40%, 45%)', main: green[500],
dark: 'hsl(120, 35%, 30%)', dark: green[700],
}, },
grey: { grey: {
...gray, ...gray,
}, },
divider: 'hsla(0, 0%, 100%, 0.08)', divider: alpha(gray[700], 0.6),
background: { background: {
default: darkBg, default: gray[900],
paper: darkPaper, paper: 'hsl(220, 30%, 7%)',
}, },
text: { text: {
primary: 'hsl(0, 0%, 92%)', primary: 'hsl(0, 0%, 100%)',
secondary: 'hsl(0, 0%, 60%)', secondary: gray[400],
}, },
action: { action: {
hover: 'hsla(0, 0%, 100%, 0.06)', hover: alpha(gray[600], 0.2),
selected: 'hsla(0, 0%, 100%, 0.1)', selected: alpha(gray[600], 0.3),
}, },
flows: { baseShadow:
outflows: { 'hsla(220, 30%, 5%, 0.7) 0px 4px 16px 0px, hsla(220, 25%, 10%, 0.8) 0px 8px 16px -5px',
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)',
}, },
}, },
}; };