# MR: Reports frontend — spec-driven generate form, sliceable viewer, dimension bars
**Branch:** `cached-reporting` → **Target:** `main`
## Summary
Reports UI rebuilt to treat the backend OpenAPI spec as the source of truth: the
generate-report form renders generically from `ReportQuery`, and the viewer displays
API-computed metrics verbatim instead of recomputing them client-side.
## Changes
### react-openapi (library)
- `MultiEnumField` renderer for array-of-enum fields; dispatch branch in
`FormFieldRenderer`
- `useFkFieldOptions` hook + `FKFieldConfig.value` (FK options keyed by a non-PK
field — accounts resolve by `name`)
- `FieldConfig.defaultValue` seeding in `extractFields`; enum lift for
`items.enum` arrays; sanitize-payload bypasses FK resolution when `fk.value` is set
### Reports page
- **GenerateReportPanel**: rewritten (~275 → ~110 lines) — fields extracted from the
served `ReportQuery` schema (labels, order, defaults, multi-enum, account picker);
server-side validation only
- **ReportViewer**:
- Period groups built from cube buckets keyed by canonical `period_id`; buckets
merged per period (disjoint slices), txn ids deduped
- Cards/bars/metric strips read `metrics` verbatim (outflows/inflows/sum/count/
avg/min/max/cadence/frequency); Net = one subtraction at render
- Dimension-switchable bar strip (Period / Payees / Tags): segmented pill control
with sliding indicator; pills disabled when the report has ≤1 distinct value;
top-10 bars in a fixed view paged by chevrons (hidden scrollbar)
- Active dim requests the full distribution via `payee=*` / `tags=*` when its own
filter is unselected; explicit selections always slice normally
- **TransactionList**: shared component with optional `groups` prop (API periods +
metrics) used by the viewer; Expense page keeps the client-side fallback path;
group strip shows Sum · Avg · Min · Max · Cadence (or Frequency /day when
cadence < 1)
### Fixes
- Strict DD-MM-YYYY parsing (`parseOccurredAt`) in period merges and date ordering —
`new Date()` mis-read dates as MM-DD-YYYY and corrupted merged cadence
(Aug Zomato: 12.89 days displayed vs 0.76 actual)
- Bar widths magnitude-based so all-outflow slices render at proper width
Reviewed-on: #16
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
200 lines
4.4 KiB
TypeScript
200 lines
4.4 KiB
TypeScript
import React from "react";
|
|
import { TextField, Chip, Box } from "@mui/material";
|
|
import type { FieldConfig } from "../../types";
|
|
import { StringField } from "./renderers/StringField";
|
|
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";
|
|
import { ImageField } from "./renderers/ImageField";
|
|
import { JsonField } from "./renderers/JsonField";
|
|
import { DiscriminatorField } from "./renderers/DiscriminatorField";
|
|
|
|
interface FormFieldProps {
|
|
field: FieldConfig;
|
|
value: any;
|
|
onChange: (value: any) => void;
|
|
error?: string;
|
|
fkOptions?: { value: any; label: string }[];
|
|
fkLoading?: boolean;
|
|
recordId?: string | number;
|
|
onFkOpen?: (fieldName: string) => void;
|
|
}
|
|
|
|
export function FormFieldRenderer({ field, value, onChange, error, fkOptions, fkLoading, recordId, onFkOpen }: FormFieldProps) {
|
|
if (field.hidden?.form) return null;
|
|
|
|
if (field.upload) {
|
|
return (
|
|
<FileUploadField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.readOnly && field.uiType !== "image") {
|
|
return (
|
|
<StringField field={field} value={value} onChange={onChange} error={error} />
|
|
);
|
|
}
|
|
|
|
if (field.uiType === "image") {
|
|
return (
|
|
<ImageField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
id={recordId}
|
|
uploadUrl={field.uploadUrl}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.fk) {
|
|
console.log(`[FormFieldRenderer] FK field="${field.name}" fkOptions=${fkOptions ? `${fkOptions.length} items` : "undefined"} fkLoading=${fkLoading} isArray=${field.isArray}`);
|
|
if (field.isArray) {
|
|
return (
|
|
<FkMultiSelectField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
fkOptions={fkOptions}
|
|
fkLoading={fkLoading}
|
|
onOpen={() => onFkOpen?.(field.name)}
|
|
/>
|
|
);
|
|
}
|
|
return (
|
|
<FkSelectField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
error={error}
|
|
fkOptions={fkOptions}
|
|
onOpen={() => onFkOpen?.(field.name)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.isArray && field.enumValues) {
|
|
return (
|
|
<MultiEnumField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
error={error}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.enumValues) {
|
|
return (
|
|
<EnumField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
error={error}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.type === "boolean") {
|
|
return <BooleanField field={field} value={value} onChange={onChange} />;
|
|
}
|
|
|
|
if (field.uiType === "currency") {
|
|
return (
|
|
<NumberField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
error={error}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.type === "integer" || field.type === "number") {
|
|
return (
|
|
<NumberField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
error={error}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.format === "date" || field.format === "date-time") {
|
|
return (
|
|
<DateField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
error={error}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.oneOfOptions) {
|
|
return (
|
|
<DiscriminatorField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
error={error}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.refSchema && !field.fk) {
|
|
return (
|
|
<JsonField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
/>
|
|
);
|
|
}
|
|
|
|
if (field.type === "array" && !field.fk) {
|
|
return (
|
|
<Box>
|
|
<Chip
|
|
label={`${field.label} (${Array.isArray(value) ? value.length : 0})`}
|
|
size="small"
|
|
color="default"
|
|
variant="outlined"
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
if (field.type === "object" && !field.fk) {
|
|
return (
|
|
<Box>
|
|
<Chip
|
|
label={field.label}
|
|
size="small"
|
|
color="default"
|
|
variant="outlined"
|
|
/>
|
|
</Box>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<StringField
|
|
field={field}
|
|
value={value}
|
|
onChange={onChange}
|
|
error={error}
|
|
/>
|
|
);
|
|
}
|