From da3e48a8746befcb415dada598f8cb6fffb42bc0 Mon Sep 17 00:00:00 2001
From: Joel Brock
Date: Sat, 9 May 2026 21:29:21 -0700
Subject: [PATCH] Add matrix-group display for monthly/quarterly time-series
fields in Stage 5
---
components/MatrixGroup.tsx | 139 ++++++++++++++++++++++++++++++++++++
components/StageSection.tsx | 28 ++++++--
config/form.ts | 66 +++++++++++++++++
types/form.ts | 39 ++++++++++
4 files changed, 268 insertions(+), 4 deletions(-)
create mode 100644 components/MatrixGroup.tsx
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}
+ )}
+
+
+
+
+
+
+ |
+ Metric
+ |
+ {group.cols.map((c) => (
+
+ {c.label}
+ |
+ ))}
+
+
+
+ {group.rows.map((row, rowIdx) => (
+
+ |
+ {decoratedRowLabel(row.label, row.type)}
+ |
+ {row.fields.map((fieldName, colIdx) => (
+
+
+ |
+ ))}
+
+ ))}
+
+
+
+
+ );
+}
+
+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 (
)}
- {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 {