Files
khata-ui/react-openapi/src/components/fields/FormFieldRenderer.tsx
Vishesh 'ironeagle' Bangotra 2add23efe7 Reports frontend — spec-driven generate form, sliceable viewer, dimension bars (#16)
# 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>
2026-08-21 14:16:35 +00:00

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}
/>
);
}