Compare commits

..
2 Commits
Author SHA1 Message Date
Joel Brock 229ef51537 Staff report: monthly Y1 matrix, value emphasis, submitter header
Five related refinements to the staff report:

1. Surface latest submitter. Pull the most recent non-empty
   Survey_completed_by / Survey_completed_by_email values from the
   Check_in_data__organizing_ history and render them just below the org
   stats in the report header. Email is a mailto: link. Hidden when both
   values are empty.

2. Y1 monthly matrix. Generalize the Y1 matrix collector to detect either
   Y1_Q<n>_<metric> or Y1_M<n>_<metric> field-name patterns. Stage 5 now
   renders the quarterly table (when present) followed by the monthly
   table (when present); each table auto-labels its columns Q1..Qn or
   M1..Mn from the data, and the caption reflects the cadence. Adding a
   new Y1_M<n>_<metric> field in Civi extends the columns automatically.

3. Larger field value. The latest value in each CompactFieldRow is now
   font-display text-xl text-leaf-800 (previously text-[13px] text-ink-soft).
   Makes the current number the dominant element in each row.

4. Smaller right-aligned earlier-entries toggle. The "N earlier entries"
   button moves out of the inline date line onto its own row beneath the
   "as of <date>" caption, right-aligned, in a 10px link style.

5. Right-aligned expanded entries. When earlier entries are unhidden,
   each row now shows date on the left and the value on the right, mirroring
   the active value's right alignment. Values render in font-display text-base
   text-ink-soft so they visually echo the latest value while being clearly
   demoted in size and color. The list is constrained to max-w-[24rem] with
   ml-auto so it sits under the active value column rather than spanning the
   full row.
2026-06-10 09:03:19 -07:00
Joel Brock bd859ce906 Form: required submitter info section above the stage pathway
Captures the form-filler's name and email on every check-in. Both fields
are required; values write back to Check_in_data__organizing_.Survey_completed_by
and Survey_completed_by_email on the activity, giving us a per-submission
record of who filled out which check-in.

Implementation:
- config/form.ts: new submitterInfo section (rank -1) at the head of the
  sections array. rank -1 keeps it out of the past/current/future stage
  pathway computation.
- components/EngagementForm.tsx: filter the submitter section out of
  sectionsToRender and render it directly with FieldRenderer inside a
  bordered card above the stage list. The fields still flow through RHF
  registration, onInvalid scroll-to-error, and the onSubmit visibility
  filter the same as any other field.

The staff report auto-discovers these fields via CustomField.get since
they live in Check_in_data__organizing_, so the field history shows up
in the report with no extra wiring.
2026-06-10 09:03:19 -07:00
3 changed files with 228 additions and 72 deletions
+55 -2
View File
@@ -19,6 +19,7 @@ function sectionForField(
import { evaluate } from "@/lib/conditional"; import { evaluate } from "@/lib/conditional";
import { STAGE_OPTION_GROUP_ID } from "@/config/form"; import { STAGE_OPTION_GROUP_ID } from "@/config/form";
import { StageSection } from "./StageSection"; import { StageSection } from "./StageSection";
import { FieldRenderer } from "./fields/FieldRenderer";
import { loadDraft, saveDraft, clearDraft } from "@/lib/draft"; import { loadDraft, saveDraft, clearDraft } from "@/lib/draft";
interface EngagementFormProps { interface EngagementFormProps {
@@ -241,6 +242,26 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0; const currentRank = currentStageValue ? STAGE_RANK[currentStageValue] ?? 0 : 0;
// Section pathway state — past / current / future is determined entirely
// by stage rank vs the org's current rank. Sections never hide; future
// stages render in a locked treatment so users can preview what's ahead.
//
// Section-level visibleWhen is still consulted on submit to strip
// locked-stage values from the payload (see onSubmit below), so a future
// stage's data never gets accidentally written.
// The submitter-info section (rank: -1) lives outside the stage pathway —
// it renders directly above the section list (see JSX below), not inside
// the rail/marker accordion model used for Stage 0..5. Filter it out here
// so its rank doesn't pollute the past/current/future calculation.
const submitterSection = useMemo(
() => config.sections.find((s) => s.id === "submitter_info"),
[config.sections],
);
const stageSections = useMemo(
() => config.sections.filter((s) => s.id !== "submitter_info"),
[config.sections],
);
// Section pathway state — past / current / future is determined entirely // Section pathway state — past / current / future is determined entirely
// by stage rank vs the org's current rank. Sections never hide; future // by stage rank vs the org's current rank. Sections never hide; future
// stages render in a locked treatment so users can preview what's ahead. // stages render in a locked treatment so users can preview what's ahead.
@@ -250,12 +271,12 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
// stage's data never gets accidentally written. // stage's data never gets accidentally written.
const sectionsToRender = useMemo( const sectionsToRender = useMemo(
() => () =>
config.sections.map((s) => { stageSections.map((s) => {
const pathwayState: PathwayState = const pathwayState: PathwayState =
s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future"; s.rank < currentRank ? "past" : s.rank === currentRank ? "current" : "future";
return { section: s, pathwayState, locked: pathwayState === "future" }; return { section: s, pathwayState, locked: pathwayState === "future" };
}), }),
[config.sections, currentRank], [stageSections, currentRank],
); );
// Track which sections the user has manually opened/closed so we can // Track which sections the user has manually opened/closed so we can
@@ -398,6 +419,38 @@ export function EngagementForm({ config, cid, cs }: EngagementFormProps) {
}} /> }} />
)} )}
{submitterSection && (
<section
aria-labelledby="submitter-info-heading"
className="rounded-2xl border border-rule bg-paper p-4 sm:p-5"
>
<h2
id="submitter-info-heading"
className="font-display text-lg font-medium text-ink"
>
{submitterSection.label}
</h2>
{submitterSection.intro && (
<p className="mt-1 text-sm text-ink-soft">{submitterSection.intro}</p>
)}
<div className="mt-3 grid grid-cols-1 gap-4 sm:grid-cols-2">
{submitterSection.fields.map((f) => (
<FieldRenderer
key={f.name}
field={f}
register={register}
setValue={setValue}
control={control}
errors={errors}
cid={cid}
cs={cs}
onUploadStateChange={handleUploadStateChange}
/>
))}
</div>
</section>
)}
<ol className="relative space-y-5 md:pl-12"> <ol className="relative space-y-5 md:pl-12">
{sectionsToRender.map((entry, i) => { {sectionsToRender.map((entry, i) => {
const { section, pathwayState, locked } = entry; const { section, pathwayState, locked } = entry;
+132 -59
View File
@@ -121,6 +121,16 @@ export function StaffReportView({
const membersField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_ACTUAL_NAME); const membersField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_ACTUAL_NAME);
const goalField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_GOAL_NAME); const goalField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_GOAL_NAME);
// Most recent Survey_completed_by / _email values from the activity history.
// Activities are returned newest-first by /api/staff/report, so the first
// non-empty history entry on each field is "most recent."
const submitterName = pickLatestText(
checkInSection?.fields.find((f) => f.descriptor.name === "Survey_completed_by"),
);
const submitterEmail = pickLatestText(
checkInSection?.fields.find((f) => f.descriptor.name === "Survey_completed_by_email"),
);
const stageLabel = data.currentStage const stageLabel = data.currentStage
? data.options[STAGE_OPTION_GROUP_ID]?.find((o) => o.value === data.currentStage)?.label ? data.options[STAGE_OPTION_GROUP_ID]?.find((o) => o.value === data.currentStage)?.label
?? data.currentStage ?? data.currentStage
@@ -149,6 +159,25 @@ export function StaffReportView({
/> />
<Stat label="Org id" value={<code className="font-mono">{data.orgId}</code>} /> <Stat label="Org id" value={<code className="font-mono">{data.orgId}</code>} />
</dl> </dl>
{(submitterName || submitterEmail) && (
<p className="text-sm text-ink-soft">
<span className="text-[11px] uppercase tracking-[0.16em] text-ink-mute">
Most recent submitter
</span>{" "}
<span className="text-ink">{submitterName ?? "—"}</span>
{submitterEmail && (
<>
{" · "}
<a
href={`mailto:${submitterEmail}`}
className="text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-leaf-700"
>
{submitterEmail}
</a>
</>
)}
</p>
)}
<div className="h-px bg-rule" /> <div className="h-px bg-rule" />
</header> </header>
@@ -251,13 +280,20 @@ function StaffSection({
const empty = section.fields.filter((f) => f.history.length === 0); const empty = section.fields.filter((f) => f.history.length === 0);
const [showEmpty, setShowEmpty] = useState(false); const [showEmpty, setShowEmpty] = useState(false);
// Stage 5 Y1 matrix: pull Labor / Margin / Member_Sales quarterly fields // Stage 5 Y1 matrices: pull quarterly (Y1_Q*) and monthly (Y1_M*) field
// out into a single tabular display matching the form's matrix layout. // series out into compact tabular displays that mirror the form's matrix
// layout. Anything not consumed by a matrix falls through to the regular
// per-field list below.
const isStage5 = section.groupName === "Stage_5"; const isStage5 = section.groupName === "Stage_5";
const matrixFields = isStage5 ? collectY1MatrixFields(filled) : null; const quarterlyMatrix = isStage5 ? collectY1MatrixByPeriod(filled, "Q") : null;
const monthlyMatrix = isStage5 ? collectY1MatrixByPeriod(filled, "M") : null;
const matrixUsedNames = new Set<string>([
...(quarterlyMatrix?.usedNames ?? []),
...(monthlyMatrix?.usedNames ?? []),
]);
const filledOutsideMatrix = const filledOutsideMatrix =
matrixFields matrixUsedNames.size > 0
? filled.filter((f) => !matrixFields.usedNames.has(f.descriptor.name)) ? filled.filter((f) => !matrixUsedNames.has(f.descriptor.name))
: filled; : filled;
return ( return (
@@ -277,12 +313,11 @@ function StaffSection({
{filled.length} with data {filled.length} with data
</p> </p>
{matrixFields ? ( {quarterlyMatrix ? (
<Y1MatrixTable <Y1MatrixTable data={quarterlyMatrix} options={options} />
rows={matrixFields.rows} ) : null}
quarters={matrixFields.quarters} {monthlyMatrix ? (
options={options} <Y1MatrixTable data={monthlyMatrix} options={options} />
/>
) : null} ) : null}
{filledOutsideMatrix.length > 0 ? ( {filledOutsideMatrix.length > 0 ? (
@@ -349,41 +384,58 @@ function CompactFieldRow({
const earlier = field.history.slice(1); const earlier = field.history.slice(1);
return ( return (
<li className="px-3 py-2"> <li className="px-3 py-3">
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1"> <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
<span className="text-[13px] font-medium text-ink">{field.descriptor.label}</span> <span className="text-[13px] font-medium text-ink">{field.descriptor.label}</span>
<div className="text-[13px] text-ink-soft"> <div className="flex flex-col items-end gap-0.5 text-right">
<FieldValue field={field} entry={latest} options={options} civiBaseUrl={civiBaseUrl} /> <span className="font-display text-xl font-medium leading-snug text-leaf-800 tabular-nums">
<FieldValue
field={field}
entry={latest}
options={options}
civiBaseUrl={civiBaseUrl}
/>
</span>
{latest.date ? ( {latest.date ? (
<span className="ml-2 text-[11px] text-ink-soft"> <span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
· {formatShortDate(latest.date)} as of {formatShortDate(latest.date)}
</span> </span>
) : null} ) : null}
</div>
</div>
{earlier.length > 0 ? ( {earlier.length > 0 ? (
<div className="mt-1">
<button <button
type="button" type="button"
onClick={() => setOpen((v) => !v)} onClick={() => setOpen((v) => !v)}
className="text-[11px] uppercase tracking-[0.14em] text-leaf-700 hover:underline" aria-expanded={open}
className="text-[10px] font-medium text-leaf-700 hover:text-leaf-800 hover:underline focus:outline-none focus-visible:underline"
> >
{open ? "Hide" : `${earlier.length} earlier ${earlier.length === 1 ? "entry" : "entries"}`} {open
? "Hide earlier entries"
: `${earlier.length} earlier ${earlier.length === 1 ? "entry" : "entries"}`}
</button> </button>
{open ? ( ) : null}
<ul className="mt-1 space-y-1 border-l border-rule pl-3"> </div>
</div>
{open && earlier.length > 0 ? (
<ol className="mt-2 ml-auto max-w-[24rem] space-y-1 border-l border-rule pl-3">
{earlier.map((e, i) => ( {earlier.map((e, i) => (
<li <li
key={`${e.activityId}-${e.date}-${i}`} key={`${e.activityId}-${e.date}-${i}`}
className="flex items-baseline justify-between gap-x-4 text-[12px] text-ink-soft" className="flex items-baseline justify-between gap-x-4"
> >
<FieldValue field={field} entry={e} options={options} civiBaseUrl={civiBaseUrl} /> <span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
<span className="text-[11px] text-ink-soft">{formatShortDate(e.date)}</span> {formatShortDate(e.date)}
</span>
<span className="font-display text-base font-medium text-ink-soft tabular-nums">
<FieldValue
field={field}
entry={e}
options={options}
civiBaseUrl={civiBaseUrl}
/>
</span>
</li> </li>
))} ))}
</ul> </ol>
) : null}
</div>
) : null} ) : null}
</li> </li>
); );
@@ -427,6 +479,21 @@ function FieldValue({
); );
} }
/**
* Return the most recent non-empty string value from a field's history, or
* undefined if the field is missing or every entry is empty. Used to surface
* the latest submitter name / email at the top of the report.
*/
function pickLatestText(field: StaffReportField | undefined): string | undefined {
if (!field) return undefined;
for (const e of field.history) {
if (e.value === null || e.value === undefined) continue;
const s = String(e.value).trim();
if (s) return s;
}
return undefined;
}
function fieldConfigFor(f: StaffReportField): FieldConfig { function fieldConfigFor(f: StaffReportField): FieldConfig {
return { return {
name: f.descriptor.name, name: f.descriptor.name,
@@ -461,41 +528,47 @@ function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldCo
} }
/** /**
* Stage 5 Y1 matrix: detect fields whose names match Y1_Q<n>_<metric> and * Stage 5 Y1 matrix: detect fields whose names match Y1_<P><n>_<metric> for a
* group them into a read-only table mirroring the form's matrix layout. * given period letter (Q for quarterly, M for monthly) and group them into a
* The metric set is whatever's actually present in the data so a Civi * read-only table mirroring the form's matrix layout. The metric set is
* schema addition (e.g. Y1_Q*_Labor_Hours) appears automatically. * whatever's actually present in the data, so a Civi schema addition (e.g.
* Y1_M*_Labor_Hours) appears automatically.
*/ */
interface Y1MatrixRow { interface Y1MatrixRow {
metric: string; // e.g. "Labor" | "Margin" | "Member_Sales_" metric: string; // e.g. "Labor" | "Margin" | "Member_Sales_"
label: string; // human label from the first field's descriptor (sans Y1_Q<n>_ prefix) label: string; // human label from the first field's descriptor (sans Y1_<P><n>_ prefix)
byQuarter: Map<number, StaffReportField>; byPeriod: Map<number, StaffReportField>;
} }
interface Y1MatrixData { interface Y1MatrixData {
rows: Y1MatrixRow[]; rows: Y1MatrixRow[];
quarters: number[]; periods: number[];
periodLetter: "Q" | "M";
usedNames: Set<string>; usedNames: Set<string>;
} }
function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null { function collectY1MatrixByPeriod(
const re = /^Y1_Q(\d+)_(.+)$/; filled: StaffReportField[],
periodLetter: "Q" | "M",
): Y1MatrixData | null {
const nameRe = new RegExp(`^Y1_${periodLetter}(\\d+)_(.+)$`);
const labelStripRe = new RegExp(`^Y1\\s*${periodLetter}\\d+\\s*`, "i");
const used = new Set<string>(); const used = new Set<string>();
const byMetric = new Map<string, Map<number, StaffReportField>>(); const byMetric = new Map<string, Map<number, StaffReportField>>();
const quartersSet = new Set<number>(); const periodsSet = new Set<number>();
const metricLabel = new Map<string, string>(); const metricLabel = new Map<string, string>();
for (const f of filled) { for (const f of filled) {
const m = re.exec(f.descriptor.name); const m = nameRe.exec(f.descriptor.name);
if (!m) continue; if (!m) continue;
const quarter = Number(m[1]); const period = Number(m[1]);
const metric = m[2]; const metric = m[2];
used.add(f.descriptor.name); used.add(f.descriptor.name);
quartersSet.add(quarter); periodsSet.add(period);
if (!byMetric.has(metric)) byMetric.set(metric, new Map()); if (!byMetric.has(metric)) byMetric.set(metric, new Map());
byMetric.get(metric)!.set(quarter, f); byMetric.get(metric)!.set(period, f);
if (!metricLabel.has(metric)) { if (!metricLabel.has(metric)) {
// Strip "Y1 Q<n> " prefix variants from the label if present. // Strip the "Y1 Q<n> " / "Y1 M<n> " prefix from the label if present.
const cleaned = f.descriptor.label const cleaned = f.descriptor.label
.replace(/^Y1\s*Q\d+\s*/i, "") .replace(labelStripRe, "")
.replace(/_/g, " ") .replace(/_/g, " ")
.trim(); .trim();
metricLabel.set(metric, cleaned || metric.replace(/_/g, " ")); metricLabel.set(metric, cleaned || metric.replace(/_/g, " "));
@@ -503,35 +576,35 @@ function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null
} }
if (byMetric.size === 0) return null; if (byMetric.size === 0) return null;
const quarters = Array.from(quartersSet).sort((a, b) => a - b); const periods = Array.from(periodsSet).sort((a, b) => a - b);
const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byQuarter]) => ({ const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byPeriod]) => ({
metric, metric,
label: metricLabel.get(metric) ?? metric, label: metricLabel.get(metric) ?? metric,
byQuarter, byPeriod,
})); }));
return { rows, quarters, usedNames: used }; return { rows, periods, periodLetter, usedNames: used };
} }
function Y1MatrixTable({ function Y1MatrixTable({
rows, data,
quarters,
options, options,
}: { }: {
rows: Y1MatrixRow[]; data: Y1MatrixData;
quarters: number[];
options: Record<number, SelectOption[]>; options: Record<number, SelectOption[]>;
}) { }) {
const { rows, periods, periodLetter } = data;
const cadence = periodLetter === "Q" ? "quarterly" : "monthly";
return ( return (
<div className="overflow-x-auto rounded-md border border-rule bg-paper"> <div className="overflow-x-auto rounded-md border border-rule bg-paper">
<table className="min-w-full text-sm"> <table className="min-w-full text-sm">
<caption className="px-3 pt-2 text-left text-[11px] uppercase tracking-[0.16em] text-ink-soft"> <caption className="px-3 pt-2 text-left text-[11px] uppercase tracking-[0.16em] text-ink-soft">
Year 1 quarterly · latest values Year 1 {cadence} · latest values
</caption> </caption>
<thead className="text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft"> <thead className="text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
<tr> <tr>
<th className="px-3 py-2 font-medium">Metric</th> <th className="px-3 py-2 font-medium">Metric</th>
{quarters.map((q) => ( {periods.map((p) => (
<th key={q} className="px-3 py-2 font-medium">Q{q}</th> <th key={p} className="px-3 py-2 font-medium">{periodLetter}{p}</th>
))} ))}
</tr> </tr>
</thead> </thead>
@@ -539,11 +612,11 @@ function Y1MatrixTable({
{rows.map((row) => ( {rows.map((row) => (
<tr key={row.metric}> <tr key={row.metric}>
<td className="px-3 py-2 text-[13px] text-ink">{row.label}</td> <td className="px-3 py-2 text-[13px] text-ink">{row.label}</td>
{quarters.map((q) => { {periods.map((p) => {
const f = row.byQuarter.get(q); const f = row.byPeriod.get(p);
const latest = f?.history[0]; const latest = f?.history[0];
return ( return (
<td key={q} className="px-3 py-2 text-[13px] text-ink-soft tabular-nums"> <td key={p} className="px-3 py-2 text-[13px] text-ink-soft tabular-nums">
{f && latest ? ( {f && latest ? (
<FormattedValue <FormattedValue
value={latest.value} value={latest.value}
+31 -1
View File
@@ -54,6 +54,36 @@ const G5 = "Stage_5";
// route through Contact.update on submit and Contact.get at form load. // route through Contact.update on submit and Contact.get at form load.
const G_ORG = "Food_Co_op_Organizing"; const G_ORG = "Food_Co_op_Organizing";
// Submitter information — captured on every check-in. Rendered above the
// stage pathway (see EngagementForm) so it's not threaded into the past /
// current / future stage rail. Both fields are required on every submission.
// rank: -1 keeps the section out of the rank-based pathway computation;
// EngagementForm filters this section out of `sectionsToRender` and renders
// it directly via FieldRenderer above the stage list.
const submitterInfo: StageSectionConfig = {
rank: -1,
id: "submitter_info",
label: "Survey submitter",
intro:
"Who's filling this out? We log this with each submission so we can follow up if needed.",
fields: [
{
name: "Survey_completed_by",
label: "Survey completed by",
type: "text",
required: true,
civiField: `${G0}.Survey_completed_by`,
},
{
name: "Survey_completed_by_email",
label: "Survey completed by — email",
type: "email",
required: true,
civiField: `${G0}.Survey_completed_by_email`,
},
],
};
// Stage 0 — Survey (always visible) // Stage 0 — Survey (always visible)
const stage0: StageSectionConfig = { const stage0: StageSectionConfig = {
rank: 0, rank: 0,
@@ -753,7 +783,7 @@ export const formConfig: FormConfig = {
subtitle: subtitle:
"Thank you for updating your co-op information. The questions you'll see depend on where you are in the Framework.", "Thank you for updating your co-op information. The questions you'll see depend on where you are in the Framework.",
stageField: "current_stage", stageField: "current_stage",
sections: [stage0, stage1, stage2, stage3, stage4, stage5], sections: [submitterInfo, stage0, stage1, stage2, stage3, stage4, stage5],
}; };
/** Convenience: flatten all field configs across all sections for lookup by name. */ /** Convenience: flatten all field configs across all sections for lookup by name. */