Add matrix-group display for monthly/quarterly time-series fields in Stage 5
This commit is contained in:
139
components/MatrixGroup.tsx
Normal file
139
components/MatrixGroup.tsx
Normal file
@@ -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<FieldValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<section
|
||||||
|
aria-labelledby={`${group.id}-heading`}
|
||||||
|
className="rounded-xl border border-stone-200 bg-white p-4 shadow-sm sm:p-5"
|
||||||
|
>
|
||||||
|
<header className="mb-3">
|
||||||
|
<h3 id={`${group.id}-heading`} className="text-base font-semibold text-stone-900">
|
||||||
|
{group.label}
|
||||||
|
</h3>
|
||||||
|
{group.intro && (
|
||||||
|
<p className="mt-1 text-sm text-stone-600 leading-relaxed">{group.intro}</p>
|
||||||
|
)}
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto -mx-4 sm:mx-0">
|
||||||
|
<table className="min-w-full border-separate border-spacing-0 text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className="sticky left-0 z-10 bg-stone-50 border-b border-stone-200 px-3 py-2 text-left font-medium text-stone-700"
|
||||||
|
>
|
||||||
|
Metric
|
||||||
|
</th>
|
||||||
|
{group.cols.map((c) => (
|
||||||
|
<th
|
||||||
|
key={c.key}
|
||||||
|
scope="col"
|
||||||
|
className="border-b border-stone-200 bg-stone-50 px-2 py-2 text-center font-medium text-stone-700 min-w-[5.5rem]"
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{group.rows.map((row, rowIdx) => (
|
||||||
|
<tr key={rowIdx} className={rowIdx % 2 === 0 ? "bg-white" : "bg-stone-50/40"}>
|
||||||
|
<th
|
||||||
|
scope="row"
|
||||||
|
className="sticky left-0 z-10 border-b border-stone-100 bg-inherit px-3 py-1.5 text-left font-medium text-stone-800 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{decoratedRowLabel(row.label, row.type)}
|
||||||
|
</th>
|
||||||
|
{row.fields.map((fieldName, colIdx) => (
|
||||||
|
<td
|
||||||
|
key={`${rowIdx}-${colIdx}`}
|
||||||
|
className="border-b border-stone-100 px-1 py-1"
|
||||||
|
>
|
||||||
|
<CellInput
|
||||||
|
fieldName={fieldName}
|
||||||
|
type={row.type}
|
||||||
|
label={`${row.label} ${group.cols[colIdx]?.label ?? ""}`.trim()}
|
||||||
|
register={register}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<FieldValues>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 (
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
aria-label={label}
|
||||||
|
step={type === "number" ? 1 : 0.01}
|
||||||
|
{...register(fieldName, { valueAsNumber: true })}
|
||||||
|
className={baseClass}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
aria-label={label}
|
||||||
|
{...register(fieldName)}
|
||||||
|
className={baseClass + " text-left"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useId, useState } from "react";
|
import { useId, useMemo, useState } from "react";
|
||||||
import type { StageSectionConfig, SelectOption } from "@/types/form";
|
import type { StageSectionConfig, SelectOption } from "@/types/form";
|
||||||
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
|
import type { UseFormRegister, FieldValues, FieldErrors } from "react-hook-form";
|
||||||
import { FieldRenderer } from "./fields/FieldRenderer";
|
import { FieldRenderer } from "./fields/FieldRenderer";
|
||||||
|
import { MatrixGroup } from "./MatrixGroup";
|
||||||
import { evaluate } from "@/lib/conditional";
|
import { evaluate } from "@/lib/conditional";
|
||||||
|
|
||||||
interface StageSectionProps {
|
interface StageSectionProps {
|
||||||
@@ -38,7 +39,19 @@ export function StageSection({
|
|||||||
const headingId = useId();
|
const headingId = useId();
|
||||||
const panelId = 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<string>();
|
||||||
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
@@ -88,9 +101,16 @@ export function StageSection({
|
|||||||
{section.intro && (
|
{section.intro && (
|
||||||
<p className="mb-5 text-sm text-stone-600 leading-relaxed">{section.intro}</p>
|
<p className="mb-5 text-sm text-stone-600 leading-relaxed">{section.intro}</p>
|
||||||
)}
|
)}
|
||||||
{visibleFields.length === 0 ? (
|
{(section.matrixGroups ?? []).length > 0 && (
|
||||||
|
<div className="mb-6 space-y-5">
|
||||||
|
{section.matrixGroups!.map((g) => (
|
||||||
|
<MatrixGroup key={g.id} group={g} register={register} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visibleFields.length === 0 && (section.matrixGroups ?? []).length === 0 ? (
|
||||||
<p className="text-sm italic text-stone-500">No fields are visible at this stage.</p>
|
<p className="text-sm italic text-stone-500">No fields are visible at this stage.</p>
|
||||||
) : (
|
) : visibleFields.length === 0 ? null : (
|
||||||
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2">
|
<div className="grid grid-cols-1 gap-x-6 gap-y-5 md:grid-cols-2">
|
||||||
{visibleFields.map((f) => {
|
{visibleFields.map((f) => {
|
||||||
const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined;
|
const resolvedOptions = f.optionGroupId ? options[f.optionGroupId] : undefined;
|
||||||
|
|||||||
@@ -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 = {
|
const stage5: StageSectionConfig = {
|
||||||
rank: 5,
|
rank: 5,
|
||||||
id: "stage_5",
|
id: "stage_5",
|
||||||
label: "Stage 5 — Fulfill and Stabilize",
|
label: "Stage 5 — Fulfill and Stabilize",
|
||||||
visibleWhen: visibleAtOrAfter(S.Stabilize),
|
visibleWhen: visibleAtOrAfter(S.Stabilize),
|
||||||
fields: STAGE_5_FIELDS,
|
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 = {
|
export const formConfig: FormConfig = {
|
||||||
|
|||||||
@@ -91,6 +91,39 @@ export interface FieldConfig {
|
|||||||
optionGroupId?: number;
|
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 {
|
export interface StageSectionConfig {
|
||||||
/** Stage rank, 0..5. Used by the conditional engine and the accordion. */
|
/** Stage rank, 0..5. Used by the conditional engine and the accordion. */
|
||||||
rank: number;
|
rank: number;
|
||||||
@@ -107,6 +140,12 @@ export interface StageSectionConfig {
|
|||||||
*/
|
*/
|
||||||
visibleWhen?: VisibilityRule;
|
visibleWhen?: VisibilityRule;
|
||||||
fields: FieldConfig[];
|
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 {
|
export interface FormConfig {
|
||||||
|
|||||||
Reference in New Issue
Block a user