16 Commits

13 changed files with 552 additions and 61 deletions

View File

@@ -9,6 +9,7 @@
rel="stylesheet" rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap" href="https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&display=swap"
/> />
<link rel="icon" type="image/svg+xml" href="/favicon.png" />
<title>khata - Aetoskia</title> <title>khata - Aetoskia</title>
</head> </head>
<body> <body>

BIN
public/favicon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -49,8 +49,8 @@ export default function EnhancedTable({
config, config,
data, data,
total, total,
paginationModel, paginationModel: externalPaginationModel,
onPaginationModelChange, onPaginationModelChange: externalOnPaginationModelChange,
loading = false, loading = false,
onEdit, onEdit,
onDelete, onDelete,
@@ -60,6 +60,14 @@ export default function EnhancedTable({
const theme = useTheme(); const theme = useTheme();
const isMobile = useMediaQuery(theme.breakpoints.down('md')); const isMobile = useMediaQuery(theme.breakpoints.down('md'));
const navigate = useNavigate(); const navigate = useNavigate();
const isServer = config.filterOptions?.mode !== "client";
const [internalPaginationModel, setInternalPaginationModel] = React.useState<GridPaginationModel>({
page: 0,
pageSize: 10,
});
const paginationModel = isServer ? externalPaginationModel : internalPaginationModel;
const onPaginationModelChange = isServer ? externalOnPaginationModelChange : setInternalPaginationModel;
const columns: GridColDef[] = React.useMemo(() => { const columns: GridColDef[] = React.useMemo(() => {
const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => { const cols: GridColDef[] = Object.entries(config.fields).map(([key, field]) => {
@@ -122,6 +130,15 @@ export default function EnhancedTable({
return cols; return cols;
}, [config, onDelete, navigate, onNavigateToResource]); }, [config, onDelete, navigate, onNavigateToResource]);
const mobilePageSize = 10;
const [mobilePage, setMobilePage] = React.useState(0);
const mobileTotalPages = Math.ceil(data.length / mobilePageSize) || 1;
const mobileData = data.slice(mobilePage * mobilePageSize, (mobilePage + 1) * mobilePageSize);
React.useEffect(() => {
if (mobilePage >= mobileTotalPages) setMobilePage(0);
}, [data.length, mobilePage, mobileTotalPages]);
if (isMobile) { if (isMobile) {
return ( return (
<Box> <Box>
@@ -132,7 +149,7 @@ export default function EnhancedTable({
</Button> </Button>
</Box> </Box>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}> <Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{data.map((row) => ( {mobileData.map((row) => (
<Box key={row[config.primaryKey] || Math.random()}> <Box key={row[config.primaryKey] || Math.random()}>
<MobileCardRow <MobileCardRow
row={row} row={row}
@@ -145,6 +162,17 @@ export default function EnhancedTable({
</Box> </Box>
))} ))}
</Box> </Box>
<Box sx={{ display: 'flex', justifyContent: 'center', gap: 1, mt: 2, flexWrap: 'wrap' }}>
<Button size="small" disabled={mobilePage === 0} onClick={() => setMobilePage(mobilePage - 1)}>
Previous
</Button>
<Typography variant="body2" sx={{ alignSelf: 'center', px: 1 }}>
Page {mobilePage + 1} of {mobileTotalPages}
</Typography>
<Button size="small" disabled={mobilePage >= mobileTotalPages - 1} onClick={() => setMobilePage(mobilePage + 1)}>
Next
</Button>
</Box>
</Box> </Box>
); );
} }
@@ -161,20 +189,18 @@ export default function EnhancedTable({
rows={data || []} rows={data || []}
columns={columns} columns={columns}
autoHeight autoHeight
paginationMode={config.pagination ? 'server' : 'client'} paginationMode={isServer ? 'server' : 'client'}
rowCount={(() => { {...(isServer ? {
if (!config.pagination) return data.length; rowCount: (() => {
if (total !== undefined) return total; if (total !== undefined) return total;
const page = paginationModel?.page || 0;
// Graceful fallback for missing total count const pageSize = paginationModel?.pageSize || 10;
const page = paginationModel?.page || 0; if (data.length < pageSize) {
const pageSize = paginationModel?.pageSize || 10; return page * pageSize + data.length;
if (data.length < pageSize) { }
return page * pageSize + data.length; return (page + 2) * pageSize;
} })(),
// Enable 'Next' button by pretending there's at least one more page } : {})}
return (page + 2) * pageSize;
})()}
loading={loading} loading={loading}
paginationModel={paginationModel || { page: 0, pageSize: 10 }} paginationModel={paginationModel || { page: 0, pageSize: 10 }}
onPaginationModelChange={onPaginationModelChange} onPaginationModelChange={onPaginationModelChange}
@@ -234,7 +260,7 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
<Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}> <Typography variant="caption" color="text.secondary" sx={{ display: 'block' }}>
{field.label} {field.label}
</Typography> </Typography>
<Typography variant="body2" sx={{ fontWeight: 500, wordBreak: 'break-all' }}> <Typography variant="body2" component="div" sx={{ fontWeight: 500, wordBreak: 'break-all' }}>
<FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile /> <FieldRenderer params={{ value: row[key], row }} field={field} fieldKey={key} config={config} onNavigate={onNavigate} navigate={navigate} isMobile />
</Typography> </Typography>
</Box> </Box>

View File

@@ -0,0 +1,313 @@
import * as React from "react";
import {
Box,
Button,
Chip,
Paper,
TextField,
Autocomplete,
Typography,
} from "@mui/material";
import DoneIcon from "@mui/icons-material/Done";
import FilterListIcon from "@mui/icons-material/FilterList";
import { ResourceField, ResourceMode } from "../types/config";
function FilterAutocomplete({
options,
value,
label,
onChange,
}: {
options: string[];
value: string[];
label: string;
onChange: (val: string[]) => void;
}) {
const listboxRef = React.useRef<HTMLUListElement>(null);
const scrollPosRef = React.useRef(0);
const [open, setOpen] = React.useState(false);
const [frozenValue, setFrozenValue] = React.useState<string[]>(value);
const toggleDropdown = () => {
setOpen(prev => {
const next = !prev;
setFrozenValue(value);
return next;
});
};
const sortedOptions = React.useMemo(() => {
const sel = new Set(frozenValue);
const picked: string[] = [];
const rest: string[] = [];
for (const o of options) {
if (sel.has(o)) picked.push(o);
else rest.push(o);
}
return [...picked, ...rest];
}, [options, frozenValue]);
return (
<Autocomplete
multiple
freeSolo
disableCloseOnSelect
open={open}
onOpen={toggleDropdown}
onClose={toggleDropdown}
options={sortedOptions}
value={value}
getOptionKey={(option) => option}
onChange={(_, val) => onChange(val.length > 0 ? val : [])}
ListboxProps={{
ref: listboxRef,
onScroll: (e) => { scrollPosRef.current = (e.target as HTMLUListElement).scrollTop; },
}}
renderOption={(props, option, { selected }) => {
const { key, ...rest } = props;
return (
<li key={key} {...rest}>
{selected ? <DoneIcon sx={{ fontSize: 14, mr: 1, color: 'primary.main' }} /> : <Box sx={{ width: 22, mr: 1 }} />}
{option}
</li>
);
}}
renderTags={(tagValue, getTagProps) => {
const maxChips = 1;
return (
<>
{tagValue.slice(0, maxChips).map((tag, index) => {
const { key, ...tagProps } = getTagProps({ index });
return <Chip
key={key}
{...tagProps}
label={tag.length > 10 ? `${tag.slice(0, 8)}..` : tag}
size="small"
onClick={toggleDropdown}
sx={{ cursor: 'pointer' }}
/>;
})}
{tagValue.length > maxChips && (
<Chip
label={`+${tagValue.length - maxChips}`}
size="small"
onClick={toggleDropdown}
sx={{ cursor: 'pointer' }}
/>
)}
</>
);
}}
renderInput={(params) => <TextField {...params} placeholder={`Add ${label}...`} />}
sx={{ '& .MuiOutlinedInput-root': { minHeight: '3rem', py: 0.5 } }}
/>
);
}
function extractOptions(
fieldName: string,
field: ResourceField,
data: any[]
): string[] {
const values = new Set<string>();
if (field.options) return field.options;
if (!data) return [];
const pull = (item: any): string | null => {
if (item == null) return null;
if (typeof item === "string") return item;
if (typeof item !== "object") return String(item);
const df = field.displayField;
if (!df) { debugger; return null; }
if (Array.isArray(df)) {
const parts = df.map((k) => item[k]).filter((v) => v != null);
if (parts.length > 0) return parts.join(" ");
} else {
const v = item[df];
if (v != null) return String(v);
}
debugger;
return null;
};
for (const row of data) {
const v = row[fieldName];
if (v == null) continue;
if (Array.isArray(v)) {
for (const el of v) {
const label = pull(el);
if (label) values.add(label);
}
} else {
const label = pull(v);
if (label) values.add(label);
}
}
// console.log('extracted', fieldName, Array.from(values).sort())
return Array.from(values).sort();
}
function renderFilterInput(
fieldName: string,
field: ResourceField,
options: string[],
value: any,
onChange: (key: string, val: any) => void
) {
const filterType = field.filterType;
if (filterType === "number-range") {
const rangeVal = (value as { min?: string; max?: string }) || {};
return (
<Box sx={{ display: "flex", gap: 1 }}>
<TextField type="number" placeholder="Min" size="small" value={rangeVal.min ?? ""}
onChange={(e) => onChange("min", e.target.value || undefined)} sx={{ width: 100 }} />
<TextField type="number" placeholder="Max" size="small" value={rangeVal.max ?? ""}
onChange={(e) => onChange("max", e.target.value || undefined)} sx={{ width: 100 }} />
</Box>
);
}
if (filterType === "date-range") {
const rangeVal = (value as { start?: string; end?: string }) || {};
return (
<Box sx={{ display: "flex", gap: 1 }}>
<TextField type="datetime-local" placeholder="From" size="small" value={rangeVal.start ?? ""}
onChange={(e) => onChange("start", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
<TextField type="datetime-local" placeholder="To" size="small" value={rangeVal.end ?? ""}
onChange={(e) => onChange("end", e.target.value || undefined)} InputLabelProps={{ shrink: true }} sx={{ width: 170 }} />
</Box>
);
}
const selected = Array.isArray(value) ? value : [];
return (
<FilterAutocomplete
options={options}
value={selected}
label={field.label}
onChange={(val) => onChange("value", val.length > 0 ? val : undefined)}
/>
);
}
export interface FilterBarProps {
fields: Record<string, ResourceField>;
filterableFields: string[];
mode: ResourceMode;
data?: any[];
appliedValues: Record<string, any>;
onApply: (values: Record<string, any>) => void;
onClear: () => void;
}
export default function FilterBar({
fields,
filterableFields,
data,
appliedValues,
onApply,
onClear,
}: FilterBarProps) {
const [open, setOpen] = React.useState(false);
const [draft, setDraft] = React.useState<Record<string, any>>(() => ({ ...appliedValues }));
React.useEffect(() => {
if (!open) setDraft({ ...appliedValues });
}, [appliedValues, open]);
if (!filterableFields || filterableFields.length === 0) return null;
const activeCount = Object.keys(appliedValues).filter((k) => {
const v = appliedValues[k];
if (v == null || v === "") return false;
if (typeof v === "object" && Object.values(v).every((x) => x == null || x === "")) return false;
return true;
}).length;
const handleApply = () => onApply({ ...draft });
const handleClear = () => {
setDraft({});
onClear();
};
const updateDraft = (fieldName: string, key: string, val: any) => {
setDraft((prev) => {
if (key === "value") {
return { ...prev, [fieldName]: val };
}
const existing = prev[fieldName] || {};
return { ...prev, [fieldName]: { ...existing, [key]: val } };
});
};
return (
<Paper variant="outlined" sx={{ mb: 2, borderRadius: 2, overflow: "hidden" }}>
<Box
sx={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
px: 2,
py: 1,
cursor: "pointer",
"&:hover": { bgcolor: "action.hover" },
}}
onClick={() => setOpen((o) => !o)}
>
<Box sx={{ display: "flex", alignItems: "center", gap: 1 }}>
<FilterListIcon fontSize="small" color="action" />
<Typography variant="subtitle2" fontWeight={600}>
{open ? "Hide Filters" : "Show Filters"}
</Typography>
</Box>
{activeCount > 0 && (
<Typography variant="caption" color="primary" fontWeight={600}>
{activeCount} active
</Typography>
)}
</Box>
{open && (
<Box sx={{ px: 2, pb: 2, borderTop: "1px solid", borderColor: "divider", pt: 2 }}>
<Box sx={{ display: "flex", flexWrap: "wrap", gap: 2, alignItems: "flex-end" }}>
{filterableFields.map((fieldName) => {
const field = fields[fieldName];
if (!field) return null;
const needsOptions = !field.filterType || field.filterType === "autocomplete" || field.filterType === "multiselect";
const options = needsOptions ? extractOptions(fieldName, field, data ?? []) : [];
const raw = draft[fieldName];
return (
<Box key={fieldName} sx={{ display: "flex", flexDirection: "column", flex: { xs: '0 0 100%', sm: 1 }, minWidth: { sm: 200 } }}>
<Box sx={{ typography: "caption", mb: 0.5, color: "text.secondary" }}>
{field.label}
</Box>
{renderFilterInput(fieldName, field, options, raw, (key, val) =>
updateDraft(fieldName, key, val)
)}
</Box>
);
})}
</Box>
<Box sx={{ mt: 2, display: "flex", gap: 1 }}>
<Button variant="contained" onClick={handleApply}>
Apply
</Button>
<Button variant="outlined" onClick={handleClear}>
Clear
</Button>
</Box>
</Box>
)}
</Paper>
);
}

View File

@@ -1,10 +1,12 @@
import * as React from 'react'; import * as React from 'react';
import { Box, Typography, Paper, CircularProgress } from '@mui/material'; import { Box, Paper, CircularProgress } from '@mui/material';
import { ResourceConfig } from '../types/config'; import { ResourceConfig } from '../types/config';
import type { ResourceField } from '../types/config';
import { useResource } from '../hooks/useResource'; import { useResource } from '../hooks/useResource';
import GenericForm from './GenericForm'; import GenericForm from './GenericForm';
import EnhancedTable from './EnhancedTable'; import EnhancedTable from './EnhancedTable';
import { useParams, useLocation, useNavigate, Routes, Route } from 'react-router-dom'; import FilterBar from './FilterBar';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
interface ResourceViewProps { interface ResourceViewProps {
config: ResourceConfig; config: ResourceConfig;
@@ -13,36 +15,132 @@ interface ResourceViewProps {
import { GridPaginationModel } from '@mui/x-data-grid'; import { GridPaginationModel } from '@mui/x-data-grid';
function getFilterDisplayFields(field: ResourceField): string[] {
if (!field.displayField) return [];
return (Array.isArray(field.displayField) ? field.displayField : [field.displayField]).filter(
(df): df is string => !!df
);
}
function applyClientFilters(
data: any[],
filters: Record<string, any>,
fields: Record<string, ResourceField>
): any[] {
const entries = Object.entries(filters).filter(([_, v]) => {
if (v == null || v === "" || (Array.isArray(v) && v.length === 0)) return false;
if (typeof v === "object" && !Array.isArray(v) && Object.values(v).every((x) => x == null || x === "")) return false;
return true;
});
if (entries.length === 0) return data;
return data.filter((item) =>
entries.every(([fieldName, filterValue]) => {
const field = fields[fieldName];
if (!field) return true;
const itemValue = item[fieldName];
if (typeof filterValue === "object" && !Array.isArray(filterValue)) {
if (field.type === "number") {
if (filterValue.min != null && filterValue.min !== "" && Number(itemValue) < Number(filterValue.min)) return false;
if (filterValue.max != null && filterValue.max !== "" && Number(itemValue) > Number(filterValue.max)) return false;
return true;
}
if (field.type === "datetime" || field.type === "date") {
const itemTime = new Date(itemValue).getTime();
if (filterValue.start && new Date(filterValue.start).getTime() > itemTime) return false;
if (filterValue.end && new Date(filterValue.end).getTime() < itemTime) return false;
return true;
}
return true;
}
if (Array.isArray(filterValue)) {
if (field.type === "array" && Array.isArray(itemValue)) {
return itemValue.some((el: any) => {
if (el != null && typeof el === "object") {
const dispFields = getFilterDisplayFields(field);
return dispFields.some((df) => filterValue.includes(String(el[df])));
}
return filterValue.includes(String(el));
});
}
if (itemValue && typeof itemValue === "object") {
const dispFields = getFilterDisplayFields(field);
const itemDisplay = dispFields.map((df) => itemValue[df]).filter((v) => v != null).join(" ");
return filterValue.includes(itemDisplay);
}
return filterValue.includes(String(itemValue));
}
if (!filterValue) return true;
if (field.type === "boolean") {
return String(itemValue) === filterValue;
}
if (field.type === "array" && Array.isArray(itemValue)) {
return itemValue.some((el: any) => {
if (el != null && typeof el === "object") {
const dispFields = getFilterDisplayFields(field);
return dispFields.some((df) => String(el[df]) === String(filterValue));
}
return String(el) === String(filterValue);
});
}
if (itemValue && typeof itemValue === "object") {
const dispFields = getFilterDisplayFields(field);
return dispFields.some((df) => String(itemValue[df]) === String(filterValue));
}
return String(itemValue) === String(filterValue);
})
);
}
export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) { export default function ResourceView({ config, onNavigateToResource }: ResourceViewProps) {
const { id } = useParams(); const { id } = useParams();
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const isCreate = location.pathname.endsWith('/create'); const isCreate = location.pathname.endsWith('/create');
const isEdit = location.pathname.includes('/edit/'); const isEdit = location.pathname.includes('/edit/');
const isView = !!id && !isEdit; const isView = !!id && !isEdit;
const isList = !id && !isCreate; const isList = !id && !isCreate;
const isServer = config.filterOptions?.mode !== "client";
const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({ const [paginationModel, setPaginationModel] = React.useState<GridPaginationModel>({
page: 0, page: 0,
pageSize: 10, pageSize: 10,
}); });
const [appliedFilters, setAppliedFilters] = React.useState<Record<string, any>>({});
const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config); const { useList, useRead, useCreate, useUpdate, useDelete } = useResource(config);
// Determine query parameters based on pagination config
const queryParams = React.useMemo(() => { const queryParams = React.useMemo(() => {
if (!config.pagination) return {}; if (!isServer) return { limit: 10000 };
return { return {
skip: paginationModel.page * paginationModel.pageSize, skip: paginationModel.page * paginationModel.pageSize,
limit: paginationModel.pageSize, limit: paginationModel.pageSize,
}; };
}, [config.pagination, paginationModel]); }, [isServer, paginationModel]);
const listQuery = useList(queryParams); const listQuery = useList(queryParams);
const itemQuery = useRead(id || ""); const itemQuery = useRead(id || "");
const paginatedData = listQuery.data || { data: [], total: undefined }; const rawData = listQuery.data?.data || [];
const totalCount = listQuery.data?.total;
const filteredData = React.useMemo(
() => (isServer ? rawData : applyClientFilters(rawData, appliedFilters, config.fields)),
[isServer, rawData, appliedFilters, config.fields]
);
const createMutation = useCreate(); const createMutation = useCreate();
const updateMutation = useUpdate(); const updateMutation = useUpdate();
const deleteMutation = useDelete(); const deleteMutation = useDelete();
@@ -80,18 +178,31 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
return ( return (
<Box> <Box>
{isList ? ( {isList ? (
<EnhancedTable <Box>
config={config} {!isServer && config.filterOptions?.fields && config.filterOptions.fields.length > 0 && (
data={paginatedData.data || []} <FilterBar
total={paginatedData.total} fields={config.fields}
paginationModel={paginationModel} filterableFields={config.filterOptions.fields}
onPaginationModelChange={setPaginationModel} mode={config.filterOptions?.mode || "server"}
loading={listQuery.isFetching} data={rawData}
onEdit={handleEdit} appliedValues={appliedFilters}
onDelete={handleDelete} onApply={setAppliedFilters}
onCreate={handleCreate} onClear={() => setAppliedFilters({})}
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)} />
/> )}
<EnhancedTable
config={config}
data={filteredData}
total={isServer ? totalCount : filteredData.length}
paginationModel={isServer ? paginationModel : undefined}
onPaginationModelChange={isServer ? setPaginationModel : undefined}
loading={listQuery.isFetching}
onEdit={handleEdit}
onDelete={handleDelete}
onCreate={handleCreate}
onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
/>
</Box>
) : ( ) : (
<Paper sx={{ p: 4 }}> <Paper sx={{ p: 4 }}>
<GenericForm <GenericForm

View File

@@ -1,7 +1,8 @@
export { default as Admin } from "./Admin"; export { default as Admin } from "./Admin";
export { api, auth, initializeApiClients } from "./api/client"; export { api, auth, initializeApiClients } from "./api/client";
export { getAppConfig } from "./config"; export { getAppConfig } from "./config";
export type { AppConfig, ResourceConfig, ResourceField } from "./types/config"; export type { AppConfig, ResourceConfig, ResourceField, ResourceMode } from "./types/config";
export { AppProvider } from "./providers/AppProvider"; export { AppProvider } from "./providers/AppProvider";
export { ConfigContext, useConfig } from "./providers/ConfigContext"; export { ConfigContext, useConfig } from "./providers/ConfigContext";
export { useResource, useResourceByName } from "./hooks/useResource"; export { useResource, useResourceByName } from "./hooks/useResource";
export { default as FilterBar } from "./components/FilterBar";

View File

@@ -20,8 +20,11 @@ export interface ResourceField {
displayField?: string | string[]; displayField?: string | string[];
formatter?: (value: any) => string; formatter?: (value: any) => string;
relation?: string; // Name of the target resource relation?: string; // Name of the target resource
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
} }
export type ResourceMode = "server" | "client";
export interface ResourceConfig { export interface ResourceConfig {
name: string; name: string;
label: string; label: string;
@@ -31,6 +34,10 @@ export interface ResourceConfig {
fields: Record<string, ResourceField>; fields: Record<string, ResourceField>;
pagination?: boolean; pagination?: boolean;
hidden?: boolean; hidden?: boolean;
filterOptions?: {
mode?: ResourceMode;
fields?: string[];
};
} }
export interface AppConfig { export interface AppConfig {

View File

@@ -7,10 +7,15 @@ export interface FieldOverride {
displayField?: string | string[]; displayField?: string | string[];
display?: boolean; display?: boolean;
formatter?: (value: any) => string; formatter?: (value: any) => string;
filterType?: "autocomplete" | "multiselect" | "number-range" | "date-range";
} }
export interface ResourceOverride { export interface ResourceOverride {
fields?: Record<string, FieldOverride>; fields?: Record<string, FieldOverride>;
pagination?: boolean; pagination?: boolean;
hidden?: boolean; hidden?: boolean;
filterOptions?: {
mode?: "server" | "client";
fields?: string[];
};
} }

View File

@@ -154,15 +154,21 @@ export async function loadConfigFromOpenApi(baseUrl: string, configuration: Reco
const resourceOverride = configuration[name] || {}; const resourceOverride = configuration[name] || {};
const fo = resourceOverride.filterOptions || {};
resources.push({ resources.push({
name, name,
label: schema.title || label, label: schema.title || label,
pluralLabel: pluralLabel, pluralLabel: pluralLabel,
endpoint: listPath, endpoint: listPath,
primaryKey: "id", // Strict default, no heuristics primaryKey: "id",
fields, fields,
pagination: resourceOverride.pagination, pagination: resourceOverride.pagination,
hidden: resourceOverride.hidden, hidden: resourceOverride.hidden,
filterOptions: {
mode: fo.mode || "server",
fields: fo.fields,
},
}); });
} }

View File

@@ -309,8 +309,7 @@ export default function Dashboard() {
getOptionLabel={(o) => o.label} getOptionLabel={(o) => o.label}
isOptionEqualToValue={(o, v) => o.value === v.value} isOptionEqualToValue={(o, v) => o.value === v.value}
renderInput={(params) => <TextField {...params} placeholder="Select snapshot..." />} renderInput={(params) => <TextField {...params} placeholder="Select snapshot..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 40, py: 0 } }} sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
size="small"
/> />
</Box> </Box>

View File

@@ -28,6 +28,7 @@ import {
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import CloudUploadIcon from "@mui/icons-material/CloudUpload"; import CloudUploadIcon from "@mui/icons-material/CloudUpload";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { import {
useFetchRequestsList, useFetchRequestsList,
useCreateFetchRequest, useCreateFetchRequest,
@@ -256,7 +257,7 @@ export default function FetchRequests() {
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>ID</TableCell> <TableCell>Fingerprint</TableCell>
<TableCell>Source</TableCell> <TableCell>Source</TableCell>
<TableCell>Account</TableCell> <TableCell>Account</TableCell>
<TableCell>Status</TableCell> <TableCell>Status</TableCell>
@@ -268,7 +269,19 @@ export default function FetchRequests() {
{requests.map((req: FetchRequest) => ( {requests.map((req: FetchRequest) => (
<TableRow key={req.id}> <TableRow key={req.id}>
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}> <TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{req.id.slice(0, 8)}... <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{req.fingerprint}
<IconButton
size="small"
onClick={() => {
navigator.clipboard.writeText(req.fingerprint);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</TableCell> </TableCell>
<TableCell> <TableCell>
{"path" in req.source ? "File" : "Email"} {"path" in req.source ? "File" : "Email"}

View File

@@ -28,6 +28,7 @@ import {
import DeleteIcon from "@mui/icons-material/Delete"; import DeleteIcon from "@mui/icons-material/Delete";
import AddCircleIcon from "@mui/icons-material/AddCircle"; import AddCircleIcon from "@mui/icons-material/AddCircle";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
import ContentCopyIcon from "@mui/icons-material/ContentCopy";
import { import {
useReportSnapshotsList, useReportSnapshotsList,
useCreateSnapshot, useCreateSnapshot,
@@ -41,8 +42,7 @@ function formatDate(iso: string) {
} }
export default function ReportSnapshots() { export default function ReportSnapshots() {
const [accounts, setAccounts] = React.useState(""); const [ignoreSelf, setIgnoreSelf] = React.useState(true);
const [ignoreSelf, setIgnoreSelf] = React.useState(false);
const [startDate, setStartDate] = React.useState(""); const [startDate, setStartDate] = React.useState("");
const [endDate, setEndDate] = React.useState(""); const [endDate, setEndDate] = React.useState("");
const [minAmount, setMinAmount] = React.useState(""); const [minAmount, setMinAmount] = React.useState("");
@@ -60,7 +60,6 @@ export default function ReportSnapshots() {
const handleCreate = async () => { const handleCreate = async () => {
try { try {
const result = await createMutation.mutateAsync({ const result = await createMutation.mutateAsync({
accounts: accounts.trim() ? accounts.split(",").map((s) => s.trim()).filter(Boolean) : null,
ignore_self: ignoreSelf || null, ignore_self: ignoreSelf || null,
start_date: startDate ? new Date(startDate).toISOString() : null, start_date: startDate ? new Date(startDate).toISOString() : null,
end_date: endDate ? new Date(endDate).toISOString() : null, end_date: endDate ? new Date(endDate).toISOString() : null,
@@ -81,7 +80,6 @@ export default function ReportSnapshots() {
}; };
const resetForm = () => { const resetForm = () => {
setAccounts("");
setIgnoreSelf(false); setIgnoreSelf(false);
setStartDate(""); setStartDate("");
setEndDate(""); setEndDate("");
@@ -112,14 +110,6 @@ export default function ReportSnapshots() {
</Typography> </Typography>
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}> <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 <FormControlLabel
control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />} control={<Switch checked={ignoreSelf} onChange={(e) => setIgnoreSelf(e.target.checked)} />}
label="Ignore self-transfers" label="Ignore self-transfers"
@@ -215,7 +205,19 @@ export default function ReportSnapshots() {
{snapshots.map((snap: ReportSnapshot) => ( {snapshots.map((snap: ReportSnapshot) => (
<TableRow key={snap.id}> <TableRow key={snap.id}>
<TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}> <TableCell sx={{ fontFamily: "monospace", fontSize: "0.8rem" }}>
{snap.snapshot_id.slice(0, 12)}... <Box sx={{ display: "flex", alignItems: "center", gap: 0.5 }}>
{snap.snapshot_id}
<IconButton
size="small"
onClick={() => {
navigator.clipboard.writeText(snap.snapshot_id);
setSnackbar({ message: "Copied!", severity: "success" });
}}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<ContentCopyIcon sx={{ fontSize: 14 }} />
</IconButton>
</Box>
</TableCell> </TableCell>
<TableCell>{formatDate(snap.created_at)}</TableCell> <TableCell>{formatDate(snap.created_at)}</TableCell>
<TableCell> <TableCell>

View File

@@ -2,9 +2,14 @@ import { ResourceOverride } from "../react-openapi/types/overrides";
export const configuration: Record<string, ResourceOverride> = { export const configuration: Record<string, ResourceOverride> = {
expenses: { expenses: {
filterOptions: {
mode: "client",
fields: ["account", "payee", "tags", "occurred_at", "amount"],
},
fields: { fields: {
payee: { payee: {
displayField: "name", displayField: "name",
filterType: "autocomplete",
}, },
payor: { payor: {
display: false, display: false,
@@ -12,11 +17,14 @@ export const configuration: Record<string, ResourceOverride> = {
}, },
account: { account: {
displayField: "name", displayField: "name",
filterType: "multiselect",
}, },
tags: { tags: {
displayField: ["name", "icon"], displayField: ["name", "icon"],
filterType: "autocomplete",
}, },
occurred_at: { occurred_at: {
filterType: "date-range",
formatter: (val: string) => { formatter: (val: string) => {
const date = new Date(val); const date = new Date(val);
const day = date.getDate(); const day = date.getDate();
@@ -34,15 +42,14 @@ export const configuration: Record<string, ResourceOverride> = {
return `${day}${suffix(day)} ${month} ${year}`; return `${day}${suffix(day)} ${month} ${year}`;
} }
}, },
amount: {
filterType: "number-range",
},
created_at: { created_at: {
display: false display: false
} }
}, },
pagination: true,
}, },
// reports: {
// hidden: true
// }
}; };
export const profileConfiguration = { export const profileConfiguration = {