diff --git a/components/MatrixGroup.tsx b/components/MatrixGroup.tsx new file mode 100644 index 0000000..26f0f67 --- /dev/null +++ b/components/MatrixGroup.tsx @@ -0,0 +1,139 @@ +"use client"; + +import type { MatrixGroupConfig, FieldType } from "@/types/form"; +import type { UseFormRegister, FieldValues } from "react-hook-form"; + +interface MatrixGroupProps { + group: MatrixGroupConfig; + register: UseFormRegister; +} + +/** + * Renders a matrix of repeating fields as an HTML table. + * + * - Row labels live in the leftmost column (sticky on horizontal scroll). + * - Column headers carry the period labels (M1..M12, Q1..Q4, …). + * - Each row's `type` controls the cell input rendering: currency cells get + * a $ prefix on the row label rather than per-cell to save space; percent + * cells append %. + * - On narrow viewports the table scrolls horizontally; the metric column + * stays pinned via position:sticky. + * + * Cells are still individual react-hook-form fields, registered by + * `field.fields[colIndex]`. The matrix only changes layout — submission and + * validation behave exactly like the standalone-input version. + */ +export function MatrixGroup({ group, register }: MatrixGroupProps) { + return ( +
+
+

+ {group.label} +

+ {group.intro && ( +

{group.intro}

+ )} +
+ +
+ + + + + {group.cols.map((c) => ( + + ))} + + + + {group.rows.map((row, rowIdx) => ( + + + {row.fields.map((fieldName, colIdx) => ( + + ))} + + ))} + +
+ Metric + + {c.label} +
+ {decoratedRowLabel(row.label, row.type)} + + +
+
+
+ ); +} + +function decoratedRowLabel(label: string, type: FieldType): string { + if (type === "currency") return `${label} ($)`; + if (type === "percent") return `${label} (%)`; + return label; +} + +interface CellInputProps { + fieldName: string; + type: FieldType; + label: string; // for aria-label only + register: UseFormRegister; +} + +/** + * A compact, table-cell-sized input. No visible label (the row + column + * already provide context); a screen-reader label is set via aria-label. + * Supports currency / percent / number / text — uniform sizing across cells. + */ +function CellInput({ fieldName, type, label, register }: CellInputProps) { + const isNumeric = type === "currency" || type === "percent" || type === "number"; + const baseClass = + "w-full rounded border border-stone-200 bg-white px-2 py-1 text-right text-stone-900 " + + "tabular-nums shadow-sm transition " + + "focus:border-leaf-500 focus:outline-none focus:ring-2 focus:ring-leaf-500/30"; + + if (isNumeric) { + return ( + + ); + } + + return ( + + ); +} diff --git a/components/StageSection.tsx b/components/StageSection.tsx index 9ee72ee..1576e42 100644 --- a/components/StageSection.tsx +++ b/components/StageSection.tsx @@ -1,9 +1,10 @@ "use client"; -import { useId, useState } from "react"; +import { useId, useMemo, useState } from "react"; import type { StageSectionConfig, SelectOption } from "@/types/form"; import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form"; import { FieldRenderer } from "./fields/FieldRenderer"; +import { MatrixGroup } from "./MatrixGroup"; import { evaluate } from "@/lib/conditional"; interface StageSectionProps { @@ -38,7 +39,19 @@ export function StageSection({ const headingId = useId(); const panelId = useId(); - const visibleFields = section.fields.filter((f) => evaluate(f.visibleWhen, formValues)); + // Names of fields that appear inside any matrix group; excluded from the + // standalone per-field grid so they don't render twice. + const matrixFieldNames = useMemo(() => { + const names = new Set(); + for (const m of section.matrixGroups ?? []) { + for (const row of m.rows) for (const f of row.fields) names.add(f); + } + return names; + }, [section.matrixGroups]); + + const visibleFields = section.fields.filter( + (f) => evaluate(f.visibleWhen, formValues) && !matrixFieldNames.has(f.name), + ); return (
{section.intro}

)} - {visibleFields.length === 0 ? ( + {(section.matrixGroups ?? []).length > 0 && ( +
+ {section.matrixGroups!.map((g) => ( + + ))} +
+ )} + {visibleFields.length === 0 && (section.matrixGroups ?? []).length === 0 ? (

No fields are visible at this stage.

- ) : ( + ) : visibleFields.length === 0 ? null : (
{visibleFields.map((f) => { const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined; diff --git a/config/form.ts b/config/form.ts index 731e423..c4716a9 100644 --- a/config/form.ts +++ b/config/form.ts @@ -511,12 +511,78 @@ const STAGE_5_FIELDS: FieldConfig[] = [ })), ]; +// Helpers for building the field-name lists in the matrices below. +const months = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]; +const monthCols = months.map((m) => ({ key: `M${m}`, label: `M${m}` })); +const quarters = [1, 2, 3, 4]; +const quarterCols = quarters.map((q) => ({ key: `Q${q}`, label: `Q${q}` })); + +// Y1 Monthly Sales Target field names (irregular — built explicitly). +const Y1_TARGET_FIELDS = [ + "Y1_Monthly_Sales_Targets", // M1 + "Y1_Monthly_Sales_Targets_M2", // M2 + "Y1_Monthly_Sales_Target_M2", // M3 (Civi field name says M2; label says M3) + "Y1_Monthly_Sales_Target_M4", + "Y1_Monthly_Sales_Target_M5", + "Y1_Monthly_Sales_Target_M6", + "Y1_Monthly_Sales_Target_M7", + "Y1_Monthly_Sales_Target_M8", + "Y1_Monthly_Sales_Target_M9", + "Y1_Monthly_Sales_Target_M10", + "Y1_Monthly_Sales_Target_M11", + "Y1_Monthly_Sales_Target_M12", +]; + const stage5: StageSectionConfig = { rank: 5, id: "stage_5", label: "Stage 5 — Fulfill and Stabilize", visibleWhen: visibleAtOrAfter(S.Stabilize), fields: STAGE_5_FIELDS, + matrixGroups: [ + { + id: "y1_monthly_metrics", + label: "Year 1 — Monthly Tracking", + intro: "Targets and actuals by month. Leave blank for months you don't yet have data for.", + cols: monthCols, + rows: [ + { label: "Sales Target", type: "currency", fields: Y1_TARGET_FIELDS }, + { + label: "Actual Sales", + type: "currency", + fields: months.map((m) => `Y1_M${m}_Actual_Sales`), + }, + { + label: "Transactions", + type: "number", + fields: months.map((m) => `Y1_M${m}_Transactions`), + }, + ], + }, + { + id: "y1_quarterly_metrics", + label: "Year 1 — Quarterly Tracking", + intro: "Quarterly operating ratios.", + cols: quarterCols, + rows: [ + { + label: "Labor", + type: "percent", + fields: quarters.map((q) => `Y1_Q${q}_Labor`), + }, + { + label: "Margin", + type: "percent", + fields: quarters.map((q) => `Y1_Q${q}_Margin`), + }, + { + label: "Member Sales", + type: "percent", + fields: quarters.map((q) => `Y1_Q${q}_Member_Sales_`), + }, + ], + }, + ], }; export const formConfig: FormConfig = { diff --git a/types/form.ts b/types/form.ts index 61efd0b..4d6542e 100644 --- a/types/form.ts +++ b/types/form.ts @@ -91,6 +91,39 @@ export interface FieldConfig { optionGroupId?: number; } +/** + * A repeating group of fields that share period axes — e.g. monthly sales + * targets across M1..M12, or quarterly margin across Q1..Q4. Rendered as a + * compact HTML table: rows are metrics, columns are periods. + * + * Underlying data flow is unchanged: each cell is still a regular form + * field (registered with react-hook-form, mapped to its own Civi custom + * field). The matrix is purely a display reorganization. Fields referenced + * in a matrix are skipped from the section's main per-field grid so they + * don't render twice. + */ +export interface MatrixGroupConfig { + /** Unique id within the section. */ + id: string; + /** Heading shown above the table. */ + label: string; + /** Optional intro paragraph below the heading. */ + intro?: string; + /** Period columns, in left-to-right order. */ + cols: Array<{ key: string; label: string }>; + /** + * Metric rows. `fields` is an array of form-side field names (the same + * `name` used in FieldConfig), one per column, in `cols` order. + * `type` controls the input rendering for this row's cells (currency, + * percent, number, etc). + */ + rows: Array<{ + label: string; + type: FieldType; + fields: string[]; + }>; +} + export interface StageSectionConfig { /** Stage rank, 0..5. Used by the conditional engine and the accordion. */ rank: number; @@ -107,6 +140,12 @@ export interface StageSectionConfig { */ visibleWhen?: VisibilityRule; fields: FieldConfig[]; + /** + * Matrix groups rendered above the per-field grid. Fields referenced in + * any matrix are excluded from the per-field grid (so a Y1 monthly sales + * target doesn't appear both in the table and as a standalone field). + */ + matrixGroups?: MatrixGroupConfig[]; } export interface FormConfig {