design overhaul + expenses page

- Stripe-style theme: single indigo (#635BFF) brand, 6px radius,
  Inter scale; rewritten themePrimitives + MUI customizations
  (solid primary, near-black secondary, severity-aware Alert)
- Sticky Header (brand-left nav, theme toggle, mobile drawer),
  inline Footer, route fade-in, per-route document.title
- Home hero + feature cards (dead /dashboard,/reports links replaced)
- Split-panel AuthPage with validation and password visibility
- Data pages: PageHeader/EmptyState, breadcrumbed Fetch Request
  pages, admin table polish (numeric alignment, row-hover actions,
  skeletons, empty states), single-accent admin SideMenu
- Global ToastProvider wired into app shell
- New /expenses page: month-grouped feed, single-open accordion
  with inline detail (account, tags, amount, timestamps)
This commit is contained in:
2026-08-17 15:14:57 +05:30
parent 51762f8d18
commit 9808a1f39b
22 changed files with 1567 additions and 790 deletions

View File

@@ -58,9 +58,14 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
if (!data) {
return (
<Typography variant="body1" color="text.secondary" sx={{ py: 4 }}>
Record not found
</Typography>
<Box sx={{ py: 6, textAlign: "center" }}>
<Typography variant="body2" color="text.secondary">
Record not found
</Typography>
<Button sx={{ mt: 1.5 }} onClick={() => navigate(`${basePath}/${resource.name}`)}>
Back to list
</Button>
</Box>
);
}
@@ -82,7 +87,7 @@ export function ResourceDetail({ resource, basePath }: ResourceDetailProps) {
<Button startIcon={<ArrowBackIcon />} onClick={() => navigate(`${basePath}/${resource.name}`)}>
Back
</Button>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1 }}>
<Typography variant="h5" fontWeight={700} sx={{ flex: 1, letterSpacing: "-0.02em" }}>
{applyDisplayFormat(
Object.fromEntries(
resource.orderedFields.map((field) => {

View File

@@ -20,11 +20,13 @@ import {
DialogContent,
DialogActions,
Grid,
Skeleton,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import EditIcon from "@mui/icons-material/Edit";
import DeleteIcon from "@mui/icons-material/Delete";
import VisibilityIcon from "@mui/icons-material/Visibility";
import StorageIcon from "@mui/icons-material/Storage";
import type { ResourceConfig, FieldConfig } from "../types";
import { useResource } from "../context/useResource";
import { useAppContext } from "../context/AppContext";
@@ -38,6 +40,10 @@ interface ResourceListProps {
basePath: string;
}
function isNumericColumn(col: FieldConfig): boolean {
return col.type === "integer" || col.type === "number";
}
function matchRow(row: any, filters: Record<string, string>, fields: FieldConfig[], allResources: ResourceConfig[]): boolean {
for (const field of fields) {
if (!field.filterable) continue;
@@ -265,12 +271,16 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
/>
)}
<TableContainer component={Paper} variant="outlined">
<TableContainer component={Paper} variant="outlined" sx={{ borderRadius: 3 }}>
<Table size="small">
<TableHead>
<TableRow>
{visibleColumns.map((col) => (
<TableCell key={col.name} sx={{ fontWeight: 700 }}>
<TableCell
key={col.name}
align={isNumericColumn(col) ? "right" : "left"}
sx={{ fontWeight: 700, fontSize: "0.75rem", py: 1, whiteSpace: "nowrap" }}
>
{col.sortable ? (
<TableSortLabel
active={sortField === col.name}
@@ -284,16 +294,45 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
)}
</TableCell>
))}
{hasActions && <TableCell align="right" sx={{ fontWeight: 700 }}>Actions</TableCell>}
{hasActions && <TableCell align="right" sx={{ fontWeight: 700, fontSize: "0.75rem", py: 1 }}>Actions</TableCell>}
</TableRow>
</TableHead>
<TableBody>
{displayData.length === 0 ? (
{crud.loading && displayData.length === 0 ? (
Array.from({ length: 6 }).map((_, i) => (
<TableRow key={`skeleton-${i}`}>
{visibleColumns.map((col) => (
<TableCell key={col.name} align={isNumericColumn(col) ? "right" : "left"} sx={{ py: 1 }}>
<Skeleton variant="text" width={col.type === "integer" || col.type === "number" ? 56 : "80%"} />
</TableCell>
))}
{hasActions && (
<TableCell align="right" sx={{ py: 1 }}>
<Skeleton variant="rectangular" width={72} height={24} sx={{ ml: "auto", borderRadius: 1 }} />
</TableCell>
)}
</TableRow>
))
) : displayData.length === 0 ? (
<TableRow>
<TableCell colSpan={visibleColumns.length + (hasActions ? 1 : 0)} align="center">
<Typography variant="body2" color="text.secondary" sx={{ py: 4 }}>
{isStreaming ? "Waiting for events\u2026" : "No records found"}
</Typography>
<Box sx={{ py: 5, display: "flex", flexDirection: "column", alignItems: "center", gap: 1 }}>
<StorageIcon sx={{ fontSize: 32, color: "text.disabled" }} />
<Typography variant="body2" color="text.secondary">
{isStreaming ? "Waiting for events…" : "No records found"}
</Typography>
{!isStreaming && resource.operations.create && (
<Button
variant="contained"
size="small"
startIcon={<AddIcon />}
onClick={() => navigate(`${basePath}/${resource.name}/new`)}
sx={{ mt: 1 }}
>
Create
</Button>
)}
</Box>
</TableCell>
</TableRow>
) : (
@@ -303,7 +342,7 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
<TableRow
key={rowId}
hover
sx={{ cursor: "pointer" }}
sx={{ cursor: "pointer", "&:hover .row-actions": { opacity: 1 } }}
onClick={() => {
if (isStreaming) {
setDetailRow(row);
@@ -322,34 +361,56 @@ export function ResourceList({ resource, basePath }: ResourceListProps) {
fmt = col.inlineDisplayFormat;
}
return (
<TableCell key={col.name}>
<TableCell
key={col.name}
align={isNumericColumn(col) ? "right" : "left"}
sx={{
py: 1,
fontSize: "0.8125rem",
...(isNumericColumn(col) && { fontVariantNumeric: "tabular-nums" }),
}}
>
<ListCellRenderer field={col} value={value} displayFormat={fmt} basePath={basePath} />
</TableCell>
);
})}
{hasActions && (
<TableCell align="right" onClick={(e) => e.stopPropagation()}>
{resource.operations.get && !isStreaming && (
<Tooltip title="View">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.update && (
<Tooltip title="Edit">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.delete && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
<TableCell
align="right"
onClick={(e) => e.stopPropagation()}
>
<Box
className="row-actions"
sx={{
opacity: { xs: 1, md: 0 },
display: "flex",
justifyContent: "flex-end",
gap: 0.5,
transition: "opacity 160ms ease",
}}
>
{resource.operations.get && !isStreaming && (
<Tooltip title="View">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}`)}>
<VisibilityIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.update && (
<Tooltip title="Edit">
<IconButton size="small" onClick={() => navigate(`${basePath}/${resource.name}/${rowId}/edit`)}>
<EditIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
{resource.operations.delete && (
<Tooltip title="Delete">
<IconButton size="small" onClick={() => handleDelete(rowId)} color="error">
<DeleteIcon fontSize="small" />
</IconButton>
</Tooltip>
)}
</Box>
</TableCell>
)}
</TableRow>

View File

@@ -30,11 +30,6 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down("md"));
const colors = [
"#6366f1", "#10b981", "#f59e0b", "#ef4444", "#8b5cf6",
"#ec4899", "#14b8a6", "#f97316", "#06b6d4", "#84cc16",
];
const content = (
<Box>
<Toolbar>
@@ -43,7 +38,7 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
</Typography>
</Toolbar>
<List sx={{ px: 1 }}>
{resources.map((r, i) => {
{resources.map((r) => {
const listPath = `${basePath}/${r.name}`;
const active = location.pathname.startsWith(listPath);
return (
@@ -58,13 +53,15 @@ export function SideMenu({ resources, basePath, mobileOpen, onClose }: SideMenuP
borderRadius: 2,
mb: 0.5,
"&.Mui-selected": {
bgcolor: `${colors[i % colors.length]}15`,
"&:hover": { bgcolor: `${colors[i % colors.length]}20` },
bgcolor: "primary.main",
color: "primary.contrastText",
"&:hover": { bgcolor: "primary.main" },
"& .MuiListItemIcon-root": { color: "primary.contrastText" },
},
}}
>
<ListItemIcon sx={{ minWidth: 36 }}>
<CircleIcon sx={{ color: colors[i % colors.length], fontSize: 12 }} />
<CircleIcon sx={{ fontSize: 12 }} />
</ListItemIcon>
<ListItemText
primary={r.displayName}