diff --git a/react-openapi/index.ts b/react-openapi/index.ts
index 963d1f2..0dd032d 100644
--- a/react-openapi/index.ts
+++ b/react-openapi/index.ts
@@ -5,6 +5,10 @@ export { useAppContext } from "./src/context/AppContext";
export { useResource } from "./src/context/useResource";
export { ListCellRenderer, DetailFieldRenderer, applyDisplayFormat } from "./src/components/fields";
export { FormFieldRenderer } from "./src/components/fields/FormFieldRenderer";
+export { MultiEnumField } from "./src/components/fields/renderers/MultiEnumField";
+export { extractFields } from "./src/transformers/field-config";
+export { useFkFieldOptions } from "./src/hooks/useFkFieldOptions";
+export type { FkOption } from "./src/hooks/useFkFieldOptions";
export { CurrencyField, formatCurrency } from "./src/components/fields/renderers/CurrencyField";
export { FkMultiSelectField } from "./src/components/fields/renderers/FkMultiSelectField";
export { SseStreamView } from "./src/components/SseStreamView";
diff --git a/react-openapi/src/components/fields/FormFieldRenderer.tsx b/react-openapi/src/components/fields/FormFieldRenderer.tsx
index 4b21f66..eb6a896 100644
--- a/react-openapi/src/components/fields/FormFieldRenderer.tsx
+++ b/react-openapi/src/components/fields/FormFieldRenderer.tsx
@@ -6,6 +6,7 @@ import { NumberField } from "./renderers/NumberField";
import { DateField } from "./renderers/DateField";
import { BooleanField } from "./renderers/BooleanField";
import { EnumField } from "./renderers/EnumField";
+import { MultiEnumField } from "./renderers/MultiEnumField";
import { FkSelectField } from "./renderers/FkSelectField";
import { FkMultiSelectField } from "./renderers/FkMultiSelectField";
import { FileUploadField } from "./renderers/FileUploadField";
@@ -81,6 +82,17 @@ export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fk
);
}
+ if (field.isArray && field.enumValues) {
+ return (
+
+ );
+ }
+
if (field.enumValues) {
return (
void;
+ error?: string;
+}
+
+export function MultiEnumField({ field, value, onChange, error }: Props) {
+ const selected: string[] = Array.isArray(value) ? value : [];
+
+ return (
+ onChange(newVal)}
+ renderOption={(props, option, { selected: isSelected }) => {
+ const { key, ...rest } = props as any;
+ return (
+
+ {isSelected ? (
+
+ ) : (
+
+ )}
+ {option}
+
+ );
+ }}
+ renderTags={(tagValue, getTagProps) =>
+ tagValue.map((tag, index) => {
+ const { key, ...tagProps } = getTagProps({ index });
+ return ;
+ })
+ }
+ renderInput={(params) => (
+
+ )}
+ sx={{
+ "& .MuiAutocomplete-popupIndicator, & .MuiAutocomplete-clearIndicator": { width: 20, height: 20, fontSize: 16 },
+ }}
+ disabled={field.readOnly}
+ />
+ );
+}
diff --git a/react-openapi/src/hooks/useFkFieldOptions.ts b/react-openapi/src/hooks/useFkFieldOptions.ts
new file mode 100644
index 0000000..47497a7
--- /dev/null
+++ b/react-openapi/src/hooks/useFkFieldOptions.ts
@@ -0,0 +1,82 @@
+import { useCallback, useEffect, useState } from "react";
+import type { FieldConfig, FKFieldConfig } from "../types";
+import { useAppContext } from "../context/AppContext";
+import { getApi } from "./useApi";
+
+export interface FkOption {
+ value: any;
+ label: string;
+}
+
+function applyFormat(obj: any, format: string): string {
+ if (!obj || typeof obj !== "object") return String(obj ?? "");
+ return format.replace(/\{(\w+)\}/g, (_, key) => String(obj[key] ?? ""));
+}
+
+/**
+ * Loads select options for FK-annotated fields of a field set.
+ * Option values bind to `fk.value` when set (e.g. account names),
+ * otherwise to the target resource's primary key.
+ */
+export function useFkFieldOptions(fields: FieldConfig[]) {
+ const { resources } = useAppContext();
+ const [fkOptions, setFkOptions] = useState>({});
+ const [fkLoading, setFkLoading] = useState>({});
+
+ const loadFkOptions = useCallback(
+ async (fieldName: string, fk: FKFieldConfig) => {
+ setFkLoading((prev) => ({ ...prev, [fieldName]: true }));
+ try {
+ const targetRes = resources.find((r) => r.name === fk.resource);
+ if (!targetRes) return;
+
+ const api = getApi();
+ const params: Record = {};
+ if (targetRes.pagination) params.limit = 0;
+ const res = await api.get(targetRes.path, { params });
+
+ let items: any[];
+ if (targetRes.pagination) {
+ if (!res.data || typeof res.data !== "object" || !Array.isArray(res.data.items)) {
+ throw new Error(`Expected paginated response from ${targetRes.path}`);
+ }
+ items = res.data.items;
+ } else {
+ if (!Array.isArray(res.data)) {
+ throw new Error(`Expected array response from ${targetRes.path}`);
+ }
+ items = res.data;
+ }
+
+ const opts: FkOption[] = items.map((item: any) => ({
+ value: item[fk.value ?? targetRes.primaryKey],
+ label: applyFormat(item, targetRes.displayFormat),
+ }));
+ setFkOptions((prev) => ({ ...prev, [fieldName]: opts }));
+ } catch {
+ // leave options empty; the field renders without suggestions
+ } finally {
+ setFkLoading((prev) => ({ ...prev, [fieldName]: false }));
+ }
+ },
+ [resources]
+ );
+
+ useEffect(() => {
+ fields.forEach((f) => {
+ if (f.fk?.prefetch) loadFkOptions(f.name, f.fk);
+ });
+ }, [fields, loadFkOptions]);
+
+ const loadFkOnOpen = useCallback(
+ (fieldName: string) => {
+ const field = fields.find((f) => f.name === fieldName);
+ if (field?.fk && !field.fk.prefetch && !fkOptions[fieldName]) {
+ loadFkOptions(fieldName, field.fk);
+ }
+ },
+ [fields, fkOptions, loadFkOptions]
+ );
+
+ return { fkOptions, fkLoading, loadFkOnOpen };
+}
diff --git a/react-openapi/src/transformers/field-config.ts b/react-openapi/src/transformers/field-config.ts
index 87a0e14..cbdb793 100644
--- a/react-openapi/src/transformers/field-config.ts
+++ b/react-openapi/src/transformers/field-config.ts
@@ -136,7 +136,10 @@ export function extractFields(schemaName: string, schema: any, schemas: Record void;
}
-const ALL_GRANULARITIES = ["weekly", "monthly", "quarterly"];
-
export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) {
- const { schemas, resources } = useAppContext();
- const { create } = useResource("reports");
- const { list: listAccounts } = useResource("accounts");
+ const { schemas } = useAppContext();
+ const { create, loading: submitting } = useResource("reports");
const { showToast } = useToast();
- const [name, setName] = useState("");
- const [granularities, setGranularities] = useState(ALL_GRANULARITIES);
- const [groupDims, setGroupDims] = useState(["payee", "tag"]);
- const [flow, setFlow] = useState("both");
- const [accounts, setAccounts] = useState([]);
- const [ignoreSelf, setIgnoreSelf] = useState(true);
- const [startDate, setStartDate] = useState("");
- const [endDate, setEndDate] = useState("");
- const [minAmount, setMinAmount] = useState("");
- const [maxAmount, setMaxAmount] = useState("");
- const [submitting, setSubmitting] = useState(false);
+ const [formData, setFormData] = useState>({});
const [error, setError] = useState(null);
- const [accountOptions, setAccountOptions] = useState([]);
- const [dateErrors, setDateErrors] = useState<{ start?: string; end?: string }>({});
- const granularityChoices = useMemo(() => {
- const enums = granularityOptions(schemas);
- return enums.length ? enums : ALL_GRANULARITIES;
+ const fields: FieldConfig[] = useMemo(() => {
+ const schema = schemas?.ReportQuery;
+ if (!schema) return [];
+ return extractFields("ReportQuery", schema, schemas).sort(
+ (a, b) => a.order - b.order || a.name.localeCompare(b.name)
+ );
}, [schemas]);
- const dimChoices = useMemo(() => groupDimOptions(schemas), [schemas]);
- const accountsRes = resources.find((r) => r.name === "accounts");
- const accountsFormat = accountsRes?.displayFormat ?? "{name}";
+
+ const { fkOptions, fkLoading, loadFkOnOpen } = useFkFieldOptions(fields);
useEffect(() => {
- let mounted = true;
- listAccounts({ limit: 0 }).then((res) => {
- if (!mounted) return;
- const names = (res.items ?? [])
- .map((it: any) => applyDisplayFormat(it, accountsFormat))
- .filter((n: string) => n);
- setAccountOptions([...new Set(names)].sort((a, b) => a.localeCompare(b)));
+ setFormData((prev) => {
+ const next = { ...prev };
+ let changed = false;
+ for (const f of fields) {
+ if (next[f.name] === undefined && f.defaultValue !== undefined) {
+ next[f.name] = Array.isArray(f.defaultValue) ? [...f.defaultValue] : f.defaultValue;
+ changed = true;
+ }
+ }
+ return changed ? next : prev;
});
- return () => {
- mounted = false;
- };
- }, [listAccounts, accountsFormat]);
+ }, [fields]);
- const toggle = (list: string[], value: string, setter: (v: string[]) => void) =>
- setter(list.includes(value) ? list.filter((v) => v !== value) : [...list, value]);
-
- const validateDates = (): boolean => {
- const errs: { start?: string; end?: string } = {};
- if (startDate && !isDdmmyyyy(startDate)) errs.start = "Use DD-MM-YYYY";
- if (endDate && !isDdmmyyyy(endDate)) errs.end = "Use DD-MM-YYYY";
- setDateErrors(errs);
- return Object.keys(errs).length === 0;
- };
-
- const parseAmount = (value: string): number | null => {
- const trimmed = value.trim();
- if (!trimmed) return null;
- const n = Number(trimmed);
- return Number.isFinite(n) ? n : null;
- };
-
- const handleSubmit = async () => {
- if (!validateDates()) return;
+ const handleChange = useCallback((name: string, value: any) => {
+ setFormData((prev) => ({ ...prev, [name]: value }));
setError(null);
- const min = parseAmount(minAmount);
- const max = parseAmount(maxAmount);
- if (minAmount.trim() && min === null) {
- setError("Min amount must be a number");
- return;
+ }, []);
+
+ const buildPayload = (): Record => {
+ const payload: Record = {};
+ for (const f of fields) {
+ const v = formData[f.name];
+ if (v === undefined || v === null || v === "") continue;
+ if (Array.isArray(v) && v.length === 0) continue;
+ payload[f.name] = v;
}
- if (maxAmount.trim() && max === null) {
- setError("Max amount must be a number");
- return;
- }
- if (min !== null && max !== null && min > max) {
- setError("Min amount cannot exceed max amount");
- return;
- }
- const payload: Record = {
- name: name.trim(),
- granularities,
- group_dims: groupDims,
- flow,
- ignore_self: ignoreSelf,
- };
- if (accounts.length) payload.accounts = accounts;
- if (startDate.trim()) payload.start_date = startDate.trim();
- if (endDate.trim()) payload.end_date = endDate.trim();
- if (min !== null) payload.min_amount = min;
- if (max !== null) payload.max_amount = max;
- setSubmitting(true);
+ return payload;
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
try {
- const created = await create(payload);
+ const created = await create(buildPayload());
showToast(`Generated snapshot ${created?.name ? `“${created.name}”` : ""}`.trim() || "Generated snapshot");
onGenerated(created);
} catch (e: any) {
setError(apiErrorMessage(e));
- } finally {
- setSubmitting(false);
}
};
@@ -145,131 +99,28 @@ export function GenerateReportPanel({ onGenerated }: GenerateReportPanelProps) {
)}
-
- setName(e.target.value)}
- sx={{ maxWidth: 420 }}
- />
-
-
-
- Granularities
-
-
- {granularityChoices.map((g) => (
- toggle(granularities, g, setGranularities)}
+
+
+ {fields.map((field) => (
+
+ handleChange(field.name, val)}
+ fkOptions={fkOptions[field.name]}
+ fkLoading={fkLoading[field.name]}
+ onFkOpen={loadFkOnOpen}
/>
- ))}
-
+
+ ))}
+
+
+
+ } disabled={submitting}>
+ {submitting ? "Generating…" : "Generate"}
+
-
-
-
- Group dimensions
-
-
- {dimChoices.map((d) => (
- toggle(groupDims, d, setGroupDims)}
- />
- ))}
-
-
-
-
-
- Flow
-
-
- setIgnoreSelf(e.target.checked)} />}
- label="Ignore self-transfers"
- sx={{ mt: 0.25 }}
- />
-
-
- setAccounts(newVal)}
- renderInput={(params) => (
-
- )}
- sx={{ maxWidth: 420 }}
- />
-
-
- setStartDate(e.target.value)}
- error={Boolean(dateErrors.start)}
- helperText={dateErrors.start ?? "Inclusive start (empty = unbounded)"}
- sx={{ width: 200 }}
- />
- setEndDate(e.target.value)}
- error={Boolean(dateErrors.end)}
- helperText={dateErrors.end ?? "Inclusive end (empty = unbounded)"}
- sx={{ width: 200 }}
- />
-
-
-
- setMinAmount(e.target.value)}
- sx={{ width: 200 }}
- />
- setMaxAmount(e.target.value)}
- sx={{ width: 200 }}
- />
-
-
-
-
- } onClick={handleSubmit} disabled={submitting}>
- {submitting ? "Generating…" : "Generate"}
-
);
-}
\ No newline at end of file
+}
diff --git a/src/Reports/types.ts b/src/Reports/types.ts
index 9696dbd..c9a990d 100644
--- a/src/Reports/types.ts
+++ b/src/Reports/types.ts
@@ -51,14 +51,6 @@ export function apiErrorMessage(e: any): string {
return e?.message ?? "Request failed";
}
-export function granularityOptions(schemas: Record): string[] {
- return schemas?.ReportQuery?.properties?.granularities?.items?.enum ?? [];
-}
-
-export function groupDimOptions(schemas: Record): string[] {
- return schemas?.ReportQuery?.properties?.group_dims?.items?.enum ?? ["payee", "tag"];
-}
-
export const FLOW_OPTIONS = ["both", "inflows", "outflows"];
export function metricLabels(schemas: Record): MetricLabel[] {