5 Commits

Author SHA1 Message Date
f5322b8467 cleanup 2026-05-09 13:30:01 +05:30
1423f889ba cleanup 2026-05-09 13:29:49 +05:30
4c8552051c weekly label fix 2026-05-07 19:45:53 +05:30
f025a7d9bf expand fixes 2026-05-07 17:32:16 +05:30
052c5a3026 enabled latest items 2026-05-07 17:29:09 +05:30
53 changed files with 961 additions and 2227 deletions

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

@@ -3,236 +3,29 @@ import {
Box, Box,
Container, Container,
CircularProgress, CircularProgress,
Alert, Alert
TextField,
Paper,
Autocomplete,
Button
} from "@mui/material"; } from "@mui/material";
import DashboardView from "./components/Dashboard"; import ConfigurableDashboard from "./components/Dashboard";
import {
DashboardState,
DashboardStateSetters,
DashboardFlow,
} from "./components/Dashboard";
import { configuration } from "./dashboard-config"; import { configuration } from "./dashboard-config";
import { import {
useReport, useReport,
prepareReport, prepareReport,
} from "./features/report"; } 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>({
flow: "outflows",
periodType: "rolling",
selectedPeriodId: null,
selectedGroupKey: null,
comparison: false,
});
const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]);
const [appliedTags, setAppliedTags] = React.useState<string[]>([]);
const [payeeInput, setPayeeInput] = React.useState<string[]>([]);
const [tagsInput, setTagsInput] = React.useState<string[]>([]);
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({ const report = useReport({
snapshot_id: selectedSnapshotId ?? undefined, periods: ["weekly", "monthly", "full"],
periods: ["daily", "weekly", "monthly", "all"], rolling: true,
flow: state.flow, include_transactions: true,
payee: appliedPayees.length > 0 ? appliedPayees : undefined, group_by: ["tags"],
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,
}));
},
[]
);
const togglePeriodType =
React.useCallback(() => {
setState((prev) => ({
...prev,
periodType:
prev.periodType ===
"rolling"
? "calendar"
: "rolling",
}));
}, []);
const toggleComparison =
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 isLoading = report.isLoading;
const error = report.error; const error = report.error;
if (isLoading && !report.data) {
if (isLoading) {
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 +41,15 @@ export default function Dashboard() {
); );
} }
if (!report.data) { if (!report) {
return null; return null;
} }
const data = prepareReport(report.data); const data = prepareReport(report.data?.data);
return ( return (
<Box> <ConfigurableDashboard
<Container> config={configuration}
<Paper data={data}
sx={{ />
mt: 4,
p: 2,
display: "flex",
flexDirection: { xs: "column", sm: "row" },
gap: 2,
alignItems: { xs: "stretch", sm: "flex-end" },
borderRadius: 4,
mb: -2 // pull up to be closer to the dashboard container below
}}
elevation={0}
variant="outlined"
>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Filter by Payee
</Box>
<Autocomplete
multiple
freeSolo
options={loadedPayees}
value={payeeInput}
onChange={(_, val) => setPayeeInput(val as string[])}
renderInput={(params) => <TextField {...params} placeholder="Add payees..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Filter by Tags
</Box>
<Autocomplete
multiple
freeSolo
options={loadedTags}
value={tagsInput}
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>
<Button
variant="contained"
size="large"
onClick={() => {
setAppliedPayees(payeeInput);
setAppliedTags(tagsInput);
}}
disabled={isLoading}
sx={{ height: 40, borderRadius: 2 }}
>
Apply
</Button>
</Paper>
</Container>
<DashboardView
config={configuration}
data={data}
state={state}
stateSetters={stateSetters}
isFetching={report.isFetching}
/>
</Box>
); );
} }

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 +1,51 @@
import * as React from "react"; import * as React from "react";
import { import {
ReportData, ReportData
GroupKey,
} from "../../features/report"; } from "../../features/report";
export type DashboardFlow = "outflows" | "inflows"; export type DashboardMode = "expense" | "income";
export type DashboardPeriodType = "rolling" | "calendar"; export type DashboardPeriodType = "rolling" | "calendar";
export type DashboardSelectedPeriodId = string | null; export type DashboardSelectedPeriodId = string | null;
export interface DashboardState { export interface DashboardState {
flow: DashboardFlow; mode: DashboardMode;
periodType: DashboardPeriodType; periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId; selectedPeriodId: DashboardSelectedPeriodId;
selectedGroupKey: GroupKey | null;
comparison: boolean; comparison: boolean;
} }
export interface DashboardStateSetters {
setSelectedPeriodId: (id: DashboardSelectedPeriodId) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleFlow: () => void;
togglePeriodType: () => void;
toggleComparison: () => void;
}
export interface DashboardSection { export interface DashboardSection {
id: string; id: string;
title: string; title?: string;
component: React.ComponentType<any>;
summary?: string; summary?: string;
component: React.ComponentType<any>;
settings?: Record<string, any>; settings?: Record<string, any>;
isList?: boolean;
style?: {
size?: number;
[key: string]: any;
};
}
export interface ColorDefinition {
primary: string;
background?: string;
text?: string;
}
export interface ThemeAwarePalette {
light: ColorDefinition;
dark: ColorDefinition;
} }
export interface DashboardConfig { export interface DashboardConfig {
sections: DashboardSection[]; sections: DashboardSection[];
style?: {
palette?: Record<DashboardMode, ThemeAwarePalette>;
};
} }
export interface DashboardViewProps { export interface DashboardProps {
config: DashboardConfig; config: DashboardConfig;
data: ReportData; 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

@@ -0,0 +1,49 @@
import * as React from "react";
import DashboardView from "./Dashboard.view";
import { DashboardProps, DashboardState } from "./Dashboard.models";
export default function Dashboard(props: DashboardProps) {
const [state, setState] = React.useState<DashboardState>({
mode: "expense",
periodType: "rolling",
selectedPeriodId: null,
comparison: false,
});
const toggleMode = () => {
setState(prev => ({
...prev,
mode: prev.mode === "expense" ? "income" : "expense",
}));
};
const togglePeriodType = () => {
setState(prev => ({
...prev,
periodType: prev.periodType === "rolling" ? "calendar" : "rolling",
}));
};
const toggleComparison = () => {
setState(prev => ({
...prev,
comparison: !prev.comparison,
}));
};
const setSelectedPeriodId = (selectedPeriodId: typeof state.selectedPeriodId) => {
setState(prev => ({ ...prev, selectedPeriodId }));
};
return (
<DashboardView
{...props}
state={state}
setState={setState}
toggleMode={toggleMode}
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
/>
);
}

View File

@@ -3,98 +3,127 @@ import {
Box, Box,
Container, Container,
Grid, Grid,
Typography,
ToggleButton, ToggleButton,
ToggleButtonGroup, ToggleButtonGroup
Button
} from "@mui/material"; } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles"; import { useTheme, alpha } from "@mui/material/styles";
import { DashboardViewProps } from "./Dashboard.models"; import { DashboardProps, DashboardState } from "./Dashboard.models";
interface ViewProps extends DashboardProps {
state: DashboardState;
setState: React.Dispatch<React.SetStateAction<DashboardState>>;
toggleMode: () => void;
togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void;
toggleComparison: () => void;
}
export default function DashboardView({ export default function DashboardView({
config, config,
data, data,
state, state,
stateSetters, setState,
isFetching, toggleMode,
}: DashboardViewProps) { togglePeriodType,
toggleComparison,
setSelectedPeriodId,
}: ViewProps) {
const theme = useTheme(); const theme = useTheme();
const themeMode = theme.palette.mode;
const { mode, periodType, comparison, selectedPeriodId } = state;
const { // Resolve colors with fallbacks
flow, const colors = React.useMemo(() => {
selectedGroupKey, const palette = config.style?.palette?.[mode];
} = state; const modeColors = palette ? palette[themeMode] : null;
const colorScheme = flow === "outflows" ? theme.palette.flows.outflows : theme.palette.flows.inflows; if (modeColors) {
return {
primary: modeColors.primary,
light: modeColors.background || alpha(modeColors.primary, 0.1),
text: modeColors.text || (themeMode === 'light' ? theme.palette.text.primary : '#fff')
};
}
// Fallback to standard theme colors
const themeColor = mode === 'expense' ? theme.palette.error : theme.palette.success;
return {
primary: themeColor.main,
light: alpha(themeColor.main, themeMode === 'light' ? 0.08 : 0.15),
text: themeColor.main
};
}, [config.style?.palette, mode, themeMode, theme.palette]);
return ( return (
<Container <Container
sx={{ sx={{
mt: 4, mt: 4,
mb: 4, mb: 4,
background: `linear-gradient(180deg, ${alpha(colorScheme.primary, theme.palette.mode === "dark" ? 0.06 : 0.04)} 0%, transparent 100%)`, background: `linear-gradient(180deg, ${colors.light} 0%, transparent 100%)`,
borderRadius: 4, borderRadius: 4,
p: 2, p: 2,
transition: "background 0.3s ease", transition: 'background 0.3s ease'
}} }}
> >
<Box <Box sx={{ display: "flex", justifyContent: "center", mb: 3 }}>
sx={{
display: "flex",
flexDirection: "column",
alignItems: "center",
mb: 3,
}}
>
<ToggleButtonGroup <ToggleButtonGroup
value={flow} value={mode}
exclusive exclusive
onChange={stateSetters.toggleFlow} onChange={toggleMode}
sx={{ sx={{
borderRadius: 3, borderRadius: 3,
overflow: "hidden", overflow: "hidden",
"& .MuiToggleButton-root": { "& .MuiToggleButton-root": {
px: 3, px: 3,
textTransform: "none", textTransform: "none",
color: "text.secondary", color: "text.secondary"
}, },
"&.Mui-selected": { "&.Mui-selected": {
bgcolor: colorScheme.primary, bgcolor: colors.primary,
color: "white", color: "white",
borderColor: colorScheme.primary, borderColor: colors.primary
}, },
}} }}
> >
<ToggleButton value="outflows">Outflows</ToggleButton> <ToggleButton value="expense">Expenses</ToggleButton>
<ToggleButton value="inflows">Inflows</ToggleButton> <ToggleButton value="income">Income</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
{selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && (
<Button
size="small"
sx={{ mt: 1, textTransform: "none" }}
onClick={() => stateSetters.setSelectedGroupKey(null)}
>
Clear Drill-down
</Button>
)}
</Box> </Box>
<Grid container spacing={4}> <Grid container spacing={4}>
{config.sections.map((section) => { {config.sections.map((section) => {
const Component = section.component; const Component = section.component;
return ( return (
<Grid key={section.id} size={12}> <Grid key={section.id} size={section.style?.size || 12 as any}>
{section.title && !section.isList && (
<Box sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={700}>
{section.title}
</Typography>
</Box>
)}
<Component <Component
{...section} {...section.settings}
header={section.title}
summary={section.summary}
reportData={data} reportData={data}
title={section.title}
accentColor={colors.primary}
colorScheme={colors}
state={state} // State management
stateSetters={stateSetters} mode={mode}
isFetching={isFetching}
colorScheme={colorScheme} periodType={periodType}
comparison={comparison}
selectedPeriodId={selectedPeriodId}
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
/> />
</Grid> </Grid>
); );

View File

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

View File

@@ -9,14 +9,15 @@ import { ChartDataPoint } from "./HistoryChart.models";
// ─── Tab → PeriodKey ───────────────────────────────────────── // ─── Tab → PeriodKey ─────────────────────────────────────────
const TAB_TO_KEY: Record<string, PeriodKey> = { const TAB_TO_KEY: Record<string, PeriodKey> = {
Daily: "daily",
Weekly: "weekly", Weekly: "weekly",
Monthly: "monthly", Monthly: "monthly",
"All Time": "all", Yearly: "yearly",
"Financial Year": "fyly",
"All Time": "full",
}; };
export function tabToKey(tab: string): PeriodKey { export function tabToKey(tab: string): PeriodKey {
return TAB_TO_KEY[tab] ?? "all"; return TAB_TO_KEY[tab] ?? "full";
} }
// ─── Comparison ────────────────────────────────────────────── // ─── Comparison ──────────────────────────────────────────────
@@ -26,9 +27,10 @@ function attachComparison(
key: PeriodKey key: PeriodKey
): ChartDataPoint[] { ): ChartDataPoint[] {
const getCompareIndex = (i: number) => { const getCompareIndex = (i: number) => {
if (key === "daily") return i - 7;
if (key === "weekly") return i - 4; if (key === "weekly") return i - 4;
if (key === "monthly") return i - 12; if (key === "monthly") return i - 12;
if (key === "yearly") return i - 1;
if (key === "fyly") return i - 1;
return -1; return -1;
}; };
@@ -54,7 +56,7 @@ function attachComparison(
export function buildChartData( export function buildChartData(
reportData: ReportData, reportData: ReportData,
key: PeriodKey, key: PeriodKey,
flow: "outflows" | "inflows", mode: "expense" | "income",
comparison: boolean comparison: boolean
): ChartDataPoint[] { ): ChartDataPoint[] {
const merged = mergeBucketPeriods(reportData.buckets, key); const merged = mergeBucketPeriods(reportData.buckets, key);
@@ -62,7 +64,7 @@ export function buildChartData(
let points: ChartDataPoint[] = merged.map((p) => ({ let points: ChartDataPoint[] = merged.map((p) => ({
id: p.id, id: p.id,
label: p.label, label: p.label,
amount: getAmount(p), amount: getAmount(p, mode),
})); }));
if (comparison) { if (comparison) {

View File

@@ -1,3 +1,10 @@
import {
DashboardMode,
DashboardPeriodType,
DashboardSelectedPeriodId
} from "../Dashboard";
import { ReportData } from "../../features/report";
export interface _ChartDataPoint { export interface _ChartDataPoint {
id: string; id: string;
label: string; label: string;
@@ -8,3 +15,26 @@ export interface _ChartDataPoint {
export interface ChartDataPoint extends _ChartDataPoint { export interface ChartDataPoint extends _ChartDataPoint {
compare?: _ChartDataPoint; compare?: _ChartDataPoint;
} }
export interface HistoryChartProps {
header: string;
summary?: string;
tabs: string[];
reportData: ReportData;
colorScheme: {
primary: string;
light: string;
text: string;
};
mode: DashboardMode;
periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId;
comparison: boolean;
togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void;
toggleComparison: () => void;
}

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,31 +1,26 @@
import * as React from "react"; import * as React from "react";
import { HistoryChartProps } from "./HistoryChart.models";
import HistoryChartView from "./HistoryChart.view"; import HistoryChartView from "./HistoryChart.view";
import { buildChartData, tabToKey } from "./HistoryChart.adapter"; 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 {
settings, tabs,
reportData, reportData,
state, mode,
stateSetters, comparison,
selectedPeriodId,
isFetching, setSelectedPeriodId
} = props; } = 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 = tabToKey(activeTab);
const currentData = React.useMemo(() => { const currentData = React.useMemo(() => {
return buildChartData(reportData, activeDataKey, flow, comparison); return buildChartData(reportData, activeDataKey, mode, comparison);
}, [reportData, activeDataKey, flow, comparison]); }, [reportData, activeDataKey, mode, comparison]);
const maxAmount = const maxAmount =
currentData.length > 0 currentData.length > 0
@@ -40,10 +35,11 @@ export default function HistoryChart(props: HistoryChartProps) {
: 1; : 1;
const visibleCountMap = { const visibleCountMap = {
daily: 7,
weekly: 6, weekly: 6,
monthly: 4, monthly: 4,
all: 4, yearly: 4,
fyly: 4,
full: 4,
}; };
const visibleCount = visibleCountMap[activeDataKey] ?? 4; const visibleCount = visibleCountMap[activeDataKey] ?? 4;

View File

@@ -11,34 +11,49 @@ 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,
} from "./HistoryChart.models";
import { formatDisplay } from "./HistoryChart.utils"; import { formatDisplay } from "./HistoryChart.utils";
export default function HistoryChartView({ interface ViewProps extends HistoryChartProps {
title, activeTab: string;
summary, setActiveTab: (v: string) => void;
settings, currentData: ChartDataPoint[];
visibleData: ChartDataPoint[];
maxAmount: number;
visibleCount: number;
startIndex: number;
setStartIndex: React.Dispatch<React.SetStateAction<number>>;
activeDataKey: string;
}
state, export default function HistoryChartView(props: ViewProps) {
stateSetters, const {
isFetching, header,
summary,
tabs,
colorScheme,
colorScheme, mode,
periodType,
selectedPeriodId,
comparison,
activeTab, togglePeriodType,
setActiveTab, setSelectedPeriodId,
currentData, toggleComparison,
visibleData,
maxAmount,
visibleCount,
startIndex,
setStartIndex,
activeDataKey,
}: HistoryChartViewProps) {
const { flow, periodType, selectedPeriodId, comparison } = state; activeTab,
const { togglePeriodType, setSelectedPeriodId, toggleComparison } = stateSetters; setActiveTab,
currentData,
visibleData,
maxAmount,
visibleCount,
startIndex,
setStartIndex,
activeDataKey,
} = props;
const theme = useTheme(); const theme = useTheme();
const isDark = theme.palette.mode === "dark"; const isDark = theme.palette.mode === "dark";
@@ -76,14 +91,11 @@ export default function HistoryChartView({
boxShadow: "none", boxShadow: "none",
border: "1px solid", border: "1px solid",
borderColor: "divider", borderColor: "divider",
bgcolor: isDark ? "background.paper" : colorScheme.surface, bgcolor: isDark ? "background.paper" : 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>
{title} {header}
</Typography> </Typography>
{summary && ( {summary && (
@@ -93,7 +105,7 @@ 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>

View File

@@ -1,21 +1,52 @@
import { ReportData, GroupKey } from "../../features/report"; import { ReportData, Transaction } from "../../features/report";
import { import {
mergeBucketPeriods,
periodIdToKey,
formatCurrency, formatCurrency,
extractFilteredTransactions,
} from "../report.helpers"; } from "../report.helpers";
import { LatestItem } from "./LatestItems.models"; import { LatestItem } from "./LatestItems.models";
// ─── Transaction extraction ─────────────────────────────────
function extractTransactions(
reportData: ReportData,
selectedPeriodId: string | null,
mode: "expense" | "income"
): Transaction[] {
if (selectedPeriodId) {
const key = periodIdToKey(selectedPeriodId);
const periods = mergeBucketPeriods(reportData.buckets, key);
const selected = periods.find((p) => p.id === selectedPeriodId);
if (!selected) return [];
return mode === "expense"
? (selected.expenses.transactions || [])
: (selected.incomes.transactions || []);
}
const periods = mergeBucketPeriods(reportData.buckets, "full");
if (!periods.length) return [];
const full = periods[0];
return mode === "expense"
? (full.expenses.transactions || [])
: (full.incomes.transactions || []);
}
// ─── Main adapter ──────────────────────────────────────────── // ─── Main adapter ────────────────────────────────────────────
export function buildLatestItems( export function buildLatestItems(
reportData: ReportData, reportData: ReportData,
selectedPeriodId: string | null | undefined, selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null | undefined, mode: "expense" | "income"
flow: "outflows" | "inflows"
): LatestItem[] { ): LatestItem[] {
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey); const txns = extractTransactions(reportData, selectedPeriodId, mode);
return txns return txns
.filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0))
.sort( .sort(
(a, b) => (a, b) =>
new Date(b.occurred_at).getTime() - new Date(b.occurred_at).getTime() -

View File

@@ -5,3 +5,10 @@ export interface LatestItem {
amount: string; amount: string;
timeAgo: string; timeAgo: string;
} }
export interface LatestItemsViewProps {
items: LatestItem[];
accentColor: string;
canExpand: boolean;
onExpand: () => void;
}

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,38 +1,40 @@
import * as React from "react"; import * as React from "react";
import { ReportData } from "../../features/report";
import { buildLatestItems } from "./LatestItems.adapter"; import { buildLatestItems } from "./LatestItems.adapter";
import LatestItemsView from "./LatestItems.view"; import LatestItemsView from "./LatestItems.view";
import { LatestItemsProps } from "./LatestItems.props";
export default function LatestItems(props: LatestItemsProps) { type Props = {
const { reportData: ReportData;
reportData, mode: "expense" | "income";
state, selectedPeriodId: string | null;
stateSetters, accentColor: string;
isFetching, };
} = props;
const { flow, selectedPeriodId, selectedGroupKey } = state; export default function LatestItems({
reportData,
mode,
selectedPeriodId,
accentColor,
}: Props) {
const [visibleCount, setVisibleCount] = React.useState(5); const [visibleCount, setVisibleCount] = React.useState(5);
// Reset count when flow changes to start clean
React.useEffect(() => {
setVisibleCount(5);
}, [flow]);
const allItems = React.useMemo(() => { const allItems = React.useMemo(() => {
return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, flow); return buildLatestItems(reportData, selectedPeriodId, mode);
}, [reportData, selectedPeriodId, selectedGroupKey, flow]); }, [reportData, selectedPeriodId, mode]);
const isPeriodSelected = Boolean(selectedPeriodId);
const visibleItems = React.useMemo(() => { const visibleItems = React.useMemo(() => {
if (!isPeriodSelected) return allItems.slice(0, 5);
return allItems.slice(0, visibleCount); return allItems.slice(0, visibleCount);
}, [allItems, visibleCount]); }, [allItems, isPeriodSelected, visibleCount]);
const canExpand = visibleCount < allItems.length; const canExpand = isPeriodSelected && visibleCount < allItems.length;
return ( return (
<LatestItemsView <LatestItemsView
{...props}
items={visibleItems} items={visibleItems}
accentColor={accentColor}
canExpand={canExpand} canExpand={canExpand}
onExpand={() => setVisibleCount((prev) => prev + 5)} onExpand={() => setVisibleCount((prev) => prev + 5)}
/> />

View File

@@ -9,25 +9,20 @@ import {
Box, Box,
IconButton, IconButton,
} from "@mui/material"; } from "@mui/material";
import { alpha } from "@mui/material/styles";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { LatestItemsViewProps } from "./LatestItems.props"; import { LatestItemsViewProps } from "./LatestItems.models";
export default function LatestItemsView({ export default function LatestItemsView({
items, items,
title, accentColor,
canExpand, canExpand,
onExpand, onExpand,
isFetching,
colorScheme,
}: LatestItemsViewProps) { }: LatestItemsViewProps) {
const accentColor = colorScheme?.primary || "";
return ( 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={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2 }}>
<Box sx={{ mb: 2, px: 2 }}> <Box sx={{ mb: 2, px: 2 }}>
<Typography variant="h6" fontWeight="bold"> <Typography variant="h6" fontWeight="bold">
{title} Recent Transactions
</Typography> </Typography>
</Box> </Box>
@@ -47,7 +42,7 @@ export default function LatestItemsView({
<Avatar <Avatar
variant="rounded" variant="rounded"
sx={{ sx={{
bgcolor: alpha(accentColor, 0.13), bgcolor: `${accentColor}22`,
width: 48, width: 48,
height: 48, height: 48,
borderRadius: 3, borderRadius: 3,

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 "../report.helpers";
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

@@ -8,79 +8,77 @@ import {
linearProgressClasses linearProgressClasses
} from "@mui/material"; } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles"; import { useTheme, alpha } from "@mui/material/styles";
import { getPercentage, formatCurrency } from "../report.helpers"; import { ProgressCardProps } from "./ProgressCard.models";
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 theme = useTheme();
const isDark = theme.palette.mode === "dark";
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", const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main;
bgcolor: colorScheme.surface, const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light;
color: colorScheme.text, return isDark
? `linear-gradient(135deg, ${alpha(baseColor, 0.9)} 0%, ${alpha(baseColor, 0.3)} 100%)`
: `linear-gradient(135deg, ${baseColor} 0%, ${lightColor} 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 border: isDark ? "1px solid rgba(255,255,255,0.1)" : "none",
? `2px solid ${colorScheme.primary}` boxShadow: (theme) =>
: "1px solid", `0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${
borderColor: selected ? colorScheme.primary : "divider", isDark
boxShadow: "none", ? "rgba(0,0,0,0.5)"
opacity: isFetching ? 0.6 : 1, : theme.palette[colorTheme]?.main || theme.palette.primary.main
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.95,
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,
textShadow: isDark ? '0 1px 2px rgba(0,0,0,0.3)' : 'none'
}} }}
> >
{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, textShadow: isDark ? '0 2px 4px rgba(0,0,0,0.3)' : 'none' }}
mb: 0.5,
lineHeight: 1.2,
}}
> >
{formattedProgress} {formattedProgress}
</Typography> </Typography>
@@ -88,38 +86,38 @@ export default function ProgressCardView({
<Divider <Divider
sx={{ sx={{
my: 1, my: 1,
borderColor: "divider", borderColor: "rgba(255,255,255,0.25)",
width: "100%", width: "100%",
}} }}
/> />
<Typography <Typography
variant={settings.compact ? "caption" : "body2"} variant={compact ? "caption" : "body2"}
sx={{ sx={{
opacity: 0.85, opacity: 0.85,
fontWeight: 500, fontWeight: 500,
display: "block", display: "block",
color: alpha(colorScheme.text, 0.85), color: "rgba(255,255,255,0.9)"
}} }}
> >
of {formattedTotal} of {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.25)",
}, },
[`& .${linearProgressClasses.bar}`]: { [`& .${linearProgressClasses.bar}`]: {
borderRadius: 5, borderRadius: 5,
backgroundColor: colorScheme.primary, backgroundColor: "#fff",
boxShadow: `0 0 8px ${alpha(colorScheme.primary, 0.4)}`, boxShadow: '0 0 8px rgba(255,255,255,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,9 +1,32 @@
import { ReportData, GroupKey } from "../../features/report"; import { ReportData } from "../../features/report";
import { import {
extractFilteredTransactions, getAmount,
aggregateTransactions, DecoratedPeriod,
} from "../report.helpers"; } from "../report.helpers";
// ─── Helpers ─────────────────────────────────────────────────
function findPeriod(
periods: DecoratedPeriod[],
selectedPeriodId?: string | null
) {
if (!periods.length) return null;
if (selectedPeriodId) {
const match = periods.find((p) => p.id === selectedPeriodId);
if (match) return match;
}
// fallback → latest
return periods.reduce((latest, p) =>
new Date(p.start).getTime() > new Date(latest.start).getTime()
? p
: latest
);
}
// ─── Main adapter ────────────────────────────────────────────
export interface TagItem { export interface TagItem {
tag: string; tag: string;
amount: number; amount: number;
@@ -11,21 +34,41 @@ export interface TagItem {
export function extractTopTags( export function extractTopTags(
reportData: ReportData, reportData: ReportData,
flow: "outflows" | "inflows", mode: "expense" | "income",
selectedPeriodId?: string | null, selectedPeriodId?: string | null
selectedGroupKey?: GroupKey | null
): { items: TagItem[]; total: number } { ): { items: TagItem[]; total: number } {
const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey); const tagMap = new Map<string, number>();
const { items, total } = aggregateTransactions(txns, (txn) => { for (const bucket of reportData.buckets) {
if (txn.tags && txn.tags.length > 0) { const tags = bucket.group_key.tags;
return txn.tags.map((t) => (typeof t === "string" ? t : t.name)); if (!tags || tags.length === 0) continue;
// Prefer FULL if available
const fullPeriods = (bucket.periods.full || []) as DecoratedPeriod[];
const periodsToUse = selectedPeriodId
? (Object.values(bucket.periods).flat() as DecoratedPeriod[])
: fullPeriods;
const period = findPeriod(periodsToUse, selectedPeriodId);
if (!period) continue;
const amount = getAmount(period, mode);
for (const tag of tags) {
tagMap.set(tag, (tagMap.get(tag) || 0) + amount);
} }
return ["Untagged"]; }
});
return { const arr = Array.from(tagMap.entries()).map(([tag, amount]) => ({
items: items.map((item) => ({ tag: item.name, amount: item.amount })), tag,
total, amount,
}; }));
arr.sort((a, b) => b.amount - a.amount);
const top = arr.slice(0, 4);
const total = top.reduce((sum, t) => sum + t.amount, 0);
return { items: top, total };
} }

View File

@@ -1,83 +1,48 @@
import * as React from "react"; import * as React from "react";
import { Box, Paper, Typography } from "@mui/material"; import { Box } from "@mui/material";
import ProgressCardView from "./ProgressCard.view"; import { ReportData } from "../../features/report";
import ProgressCard from "./ProgressCard";
import { extractTopTags } from "./TopTags.adapter"; import { extractTopTags } from "./TopTags.adapter";
import { ProgressCardProps } from "./ProgressCard.props";
export default function TopTags(props: ProgressCardProps) { type Props = {
const { reportData: ReportData;
title, mode: "expense" | "income";
selectedPeriodId?: string | null;
reportData, compact?: boolean;
state, };
stateSetters,
isFetching,
} = props
const { flow, selectedPeriodId, selectedGroupKey } = state;
const { setSelectedGroupKey } = stateSetters;
export default function TopTags({
reportData,
mode,
selectedPeriodId,
compact = true,
}: Props) {
const { items, total } = React.useMemo(() => { const { items, total } = React.useMemo(() => {
return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey); return extractTopTags(reportData, mode, selectedPeriodId);
}, [reportData, flow, selectedPeriodId, selectedGroupKey]); }, [reportData, mode, selectedPeriodId]);
return ( return (
<Paper <Box
sx={{ sx={{
p: { xs: 2.5, sm: 4 }, display: "grid",
borderRadius: 4, gridTemplateColumns: {
width: "100%", xs: "1fr",
boxShadow: "none", sm: "repeat(2, 1fr)",
border: "1px solid", md: "repeat(4, 1fr)",
borderColor: "divider", },
bgcolor: "background.paper", gap: 2,
opacity: isFetching ? 0.6 : 1,
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
}} }}
> >
<Typography variant="h6" fontWeight={700} gutterBottom> {items.map((item) => (
{title} <ProgressCard
</Typography> key={item.tag}
header={item.tag}
<Box progressAmount={item.amount}
sx={{ totalAmount={total}
display: "grid", compact={compact}
gridTemplateColumns: { colorTheme={mode === "expense" ? "error" : "success"}
xs: "1fr", />
sm: "repeat(2, 1fr)", ))}
md: "repeat(4, 1fr)", </Box>
},
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,15 +1,11 @@
import { import {
ReportPeriod, ReportPeriod,
ReportBucket, ReportBucket,
GroupKey,
PeriodType,
ReportData,
Transaction,
} from "../features/report"; } from "../features/report";
// ─── Types ──────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────
export type PeriodKey = PeriodType; export type PeriodKey = "weekly" | "monthly" | "yearly" | "fyly" | "full";
export type DecoratedPeriod = ReportPeriod & { export type DecoratedPeriod = ReportPeriod & {
id: string; id: string;
@@ -19,10 +15,11 @@ export type DecoratedPeriod = ReportPeriod & {
// ─── Period helpers ─────────────────────────────────────────── // ─── Period helpers ───────────────────────────────────────────
const PREFIX_TO_KEY: Record<string, PeriodKey> = { const PREFIX_TO_KEY: Record<string, PeriodKey> = {
D: "daily",
W: "weekly", W: "weekly",
M: "monthly", M: "monthly",
ALL: "all", Y: "yearly",
FY: "fyly",
FULL: "full",
}; };
/** /**
@@ -31,16 +28,19 @@ const PREFIX_TO_KEY: Record<string, PeriodKey> = {
*/ */
export function periodIdToKey(periodId: string): PeriodKey { export function periodIdToKey(periodId: string): PeriodKey {
const prefix = periodId.split(":")[0]; const prefix = periodId.split(":")[0];
return PREFIX_TO_KEY[prefix] ?? "all"; return PREFIX_TO_KEY[prefix] ?? "full";
} }
// ─── Metric helpers ─────────────────────────────────────────── // ─── Metric helpers ───────────────────────────────────────────
export function getAmount(period: ReportPeriod): number { export function getAmount(
return period.metric.sum; period: ReportPeriod,
mode: "expense" | "income"
): number {
return mode === "expense" ? period.expenses.sum : period.incomes.sum;
} }
function mergeMetric(a: ReportPeriod["metric"], b: ReportPeriod["metric"]) { function mergeMetric(a: ReportPeriod["expenses"], b: ReportPeriod["expenses"]) {
const sum = a.sum + b.sum; const sum = a.sum + b.sum;
const count = a.count + b.count; const count = a.count + b.count;
@@ -77,12 +77,14 @@ export function mergeBucketPeriods(
if (!existing) { if (!existing) {
map.set(p.id, { map.set(p.id, {
...p, ...p,
metric: { ...p.metric }, expenses: { ...p.expenses },
incomes: { ...p.incomes },
}); });
} else { } else {
map.set(p.id, { map.set(p.id, {
...existing, ...existing,
metric: mergeMetric(existing.metric, p.metric), expenses: mergeMetric(existing.expenses, p.expenses),
incomes: mergeMetric(existing.incomes, p.incomes),
}); });
} }
} }
@@ -110,121 +112,3 @@ export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0; if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100)); 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

@@ -2,7 +2,6 @@ import HistoryChart from "./components/HistoryChart";
import LatestItems from "./components/LatestItems"; import LatestItems from "./components/LatestItems";
import { DashboardConfig } from "./components/Dashboard"; import { DashboardConfig } from "./components/Dashboard";
import TopTags from "./components/ProgressCard/TopTags"; import TopTags from "./components/ProgressCard/TopTags";
import TopPayees from "./components/ProgressCard/TopPayees";
export const configuration: DashboardConfig = { export const configuration: DashboardConfig = {
sections: [ sections: [
@@ -13,28 +12,57 @@ export const configuration: DashboardConfig = {
component: HistoryChart, component: HistoryChart,
settings: { settings: {
tabs: ["Weekly", "Monthly"], tabs: ["Weekly", "Monthly"],
// tabs: ["Weekly", "Monthly", "Yearly", "Financial Year", "All Time"],
}, },
}, style: {
{ size: 12,
id: "top-categories",
title: 'Top Categories',
component: TopTags,
settings: {
compact: true,
}, },
}, },
{ {
id: "top-payees", id: "top-payees",
title: 'Top Payees', title: 'Top Payees',
component: TopPayees, component: TopTags,
settings: { settings: {
compact: true, compact: true,
}, },
style: {
size: 12,
},
}, },
{ {
id: "items", id: "items",
title: 'Recent Transactions',
component: LatestItems, component: LatestItems,
style: {
size: 12,
},
}, },
], ],
style: {
palette: {
expense: {
light: {
primary: "#d32f2f",
background: "#fdecea",
text: "#b71c1c"
},
dark: {
primary: "#f44336",
background: "rgba(244, 67, 54, 0.15)",
text: "#ffcdd2"
}
},
income: {
light: {
primary: "#2e7d32",
background: "#e8f5e9",
text: "#1b5e20"
},
dark: {
primary: "#4caf50",
background: "rgba(76, 175, 80, 0.15)",
text: "#c8e6c9"
}
}
}
}
}; };

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

@@ -6,9 +6,6 @@ export type {
ReportData, ReportData,
ReportBucket, ReportBucket,
ReportPeriod, ReportPeriod,
ReportQuery,
GroupKey,
PeriodType,
} from './report.models' } from './report.models'
export { export {
prepareReport prepareReport

View File

@@ -1,40 +1,29 @@
export interface Payor { export interface Payor {
id?: string;
name: string; name: string;
username: string;
email: string;
} }
export interface Payee { export interface Payee {
type: "merchant" | "person" | "transfer" | "other";
name: string; name: string;
} }
export interface Account { export interface Account {
id: string;
name: string; name: string;
number: string; number: string;
type: "cash" | "bank" | "credit_card" | "wallet" | "other";
currency: string;
is_active?: boolean;
} }
export interface Tag { export interface Tag {
id: string;
name: string; name: string;
icon: string; icon: string;
parent_id?: string | null; description: string;
} }
export interface Transaction { export interface Transaction {
id: string;
payor: Payor; payor: Payor;
payee: Payee; payee: Payee;
amount: number; amount: number;
account: Account; account: Account;
tags: Tag[]; tags: Tag[];
occurred_at: string; occurred_at: Date;
created_at: string;
} }
// ----------------------------- // -----------------------------
@@ -52,12 +41,12 @@ export interface ReportMetric {
// Period // Period
// ----------------------------- // -----------------------------
export type PeriodType = "daily" | "weekly" | "monthly" | "all";
export interface ReportPeriod { export interface ReportPeriod {
start: string; start: Date;
end: string; end: Date;
metric: ReportMetric;
expenses: ReportMetric;
incomes: ReportMetric;
} }
// ----------------------------- // -----------------------------
@@ -65,48 +54,37 @@ export interface ReportPeriod {
// ----------------------------- // -----------------------------
export type GroupKey = { export type GroupKey = {
[dimension: string]: string[]; payee?: string[];
tags?: string[];
flow?: string[];
}; };
export interface ReportBucket { export interface ReportBucket {
group_key: GroupKey; group_key: GroupKey;
periods: { periods: {
daily?: ReportPeriod[];
weekly?: ReportPeriod[]; weekly?: ReportPeriod[];
monthly?: ReportPeriod[]; monthly?: ReportPeriod[];
all?: ReportPeriod[]; yearly?: ReportPeriod[];
fyly?: ReportPeriod[];
full?: 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 // Final Report
// ----------------------------- // -----------------------------
export interface ReportData { export interface ReportData {
snapshot_id?: string | null; periods: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[];
flow?: "inflows" | "outflows" | null; rolling: boolean;
report_date?: string;
periods: PeriodType[]; group_by: ("payee" | "tags")[];
tags?: string[] | null; ignore_self: boolean;
payee?: string[] | null; include_transactions: boolean;
buckets: ReportBucket[]; buckets: ReportBucket[];
query: ReportQuery;
} }

View File

@@ -1,7 +1,6 @@
import { import {
ReportData, ReportData,
ReportPeriod, ReportPeriod
PeriodType,
} from "./report.models"; } from "./report.models";
/* ---------- ID BUILDING ---------- */ /* ---------- ID BUILDING ---------- */
@@ -14,7 +13,7 @@ function formatDate(d: Date): string {
} }
function buildPeriodId( function buildPeriodId(
type: PeriodType, type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
start: Date, start: Date,
end: Date end: Date
): string { ): string {
@@ -22,14 +21,16 @@ function buildPeriodId(
const e = formatDate(end); const e = formatDate(end);
switch (type) { switch (type) {
case "daily":
return `D:${s}_${e}`;
case "weekly": case "weekly":
return `W:${s}_${e}`; return `W:${s}_${e}`;
case "monthly": case "monthly":
return `M:${s}_${e}`; return `M:${s}_${e}`;
case "all": case "yearly":
return `ALL:${s}_${e}`; return `Y:${s}_${e}`;
case "fyly":
return `FY:${s}_${e}`;
case "full":
return `FULL:${s}_${e}`;
default: default:
return `${s}_${e}`; return `${s}_${e}`;
} }
@@ -59,15 +60,19 @@ const yearFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: "UTC", timeZone: "UTC",
}); });
function sameMonth(a: Date, b: Date) {
return (
a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth()
);
}
function buildLabel( function buildLabel(
type: PeriodType, type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
start: Date, start: Date,
end: Date end: Date
): string { ): string {
switch (type) { switch (type) {
case "daily":
return dayFmt.format(start);
case "weekly": { case "weekly": {
const sDay = start.getUTCDate(); const sDay = start.getUTCDate();
const m = monthFmt.format(start); const m = monthFmt.format(start);
@@ -77,6 +82,15 @@ function buildLabel(
case "monthly": case "monthly":
return `${monthFmt.format(start)} ${yearFmt.format(start)}`; return `${monthFmt.format(start)} ${yearFmt.format(start)}`;
case "yearly":
return yearFmt.format(start);
case "fyly": {
const startY = start.getUTCFullYear();
const endY = end.getUTCFullYear();
return `FY ${startY}${String(endY).slice(-2)}`;
}
default: default:
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`; return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
} }
@@ -85,7 +99,7 @@ function buildLabel(
/* ---------- MAIN ---------- */ /* ---------- MAIN ---------- */
function decoratePeriods( function decoratePeriods(
type: PeriodType, type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
periods: ReportPeriod[] periods: ReportPeriod[]
): (ReportPeriod & { id: string; label: string })[] { ): (ReportPeriod & { id: string; label: string })[] {
return periods.map((p) => ({ return periods.map((p) => ({

View File

@@ -1,21 +1,20 @@
import { useResourceByName } from "../../../react-openapi"; import { useResourceByName } from "../../../react-openapi";
export interface ReportParams { export interface ReportParams {
snapshot_id?: string; periods?: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[];
periods?: ("daily" | "weekly" | "monthly" | "all")[]; rolling?: boolean;
flow?: "inflows" | "outflows"; report_date?: string;
payee?: string[]; group_by?: ("payee" | "tags")[];
tags?: string[]; ignore_self?: boolean;
include_transactions?: boolean;
} }
export function useReport(params: ReportParams) { export function useReport(params: ReportParams) {
const { useRead } = useResourceByName("reports"); const { useList } = useResourceByName("reports");
return useRead( return useList({
params.snapshot_id ? params.snapshot_id : "latest", ...params,
{ periods: params.periods,
...params, group_by: params.group_by,
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,
components: {
...inputsCustomizations,
...dataDisplayCustomizations,
...feedbackCustomizations,
...navigationCustomizations,
...surfacesCustomizations,
},
}),
[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: {
...inputsCustomizations,
...dataDisplayCustomizations,
...feedbackCustomizations,
...navigationCustomizations,
...surfacesCustomizations,
...themeComponents,
},
});
}, [disableCustomTheme, themeComponents]);
if (disableCustomTheme) {
return <React.Fragment>{children}</React.Fragment>;
}
return ( return (
<ColorModeContext.Provider value={contextValue}> <ThemeProvider theme={theme} disableTransitionOnChange>
<ThemeProvider theme={theme}> {children}
<CssBaseline /> </ThemeProvider>
<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}
</Box>
</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)',
}, },
}, },
}; };