From 229ef5153760c9ba271403ad5e7adc115559a259 Mon Sep 17 00:00:00 2001 From: Joel Brock Date: Wed, 10 Jun 2026 09:03:19 -0700 Subject: [PATCH] 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_ or Y1_M_ 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_ 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 " 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. --- components/StaffReportView.tsx | 211 ++++++++++++++++++++++----------- 1 file changed, 142 insertions(+), 69 deletions(-) diff --git a/components/StaffReportView.tsx b/components/StaffReportView.tsx index c53e7c6..efe2643 100644 --- a/components/StaffReportView.tsx +++ b/components/StaffReportView.tsx @@ -121,6 +121,16 @@ export function StaffReportView({ const membersField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_ACTUAL_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 ? data.options[STAGE_OPTION_GROUP_ID]?.find((o) => o.value === data.currentStage)?.label ?? data.currentStage @@ -149,6 +159,25 @@ export function StaffReportView({ /> {data.orgId}} /> + {(submitterName || submitterEmail) && ( +

+ + Most recent submitter + {" "} + {submitterName ?? "—"} + {submitterEmail && ( + <> + {" · "} + + {submitterEmail} + + + )} +

+ )}
@@ -251,13 +280,20 @@ function StaffSection({ const empty = section.fields.filter((f) => f.history.length === 0); const [showEmpty, setShowEmpty] = useState(false); - // Stage 5 Y1 matrix: pull Labor / Margin / Member_Sales quarterly fields - // out into a single tabular display matching the form's matrix layout. + // Stage 5 Y1 matrices: pull quarterly (Y1_Q*) and monthly (Y1_M*) field + // 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 matrixFields = isStage5 ? collectY1MatrixFields(filled) : null; + const quarterlyMatrix = isStage5 ? collectY1MatrixByPeriod(filled, "Q") : null; + const monthlyMatrix = isStage5 ? collectY1MatrixByPeriod(filled, "M") : null; + const matrixUsedNames = new Set([ + ...(quarterlyMatrix?.usedNames ?? []), + ...(monthlyMatrix?.usedNames ?? []), + ]); const filledOutsideMatrix = - matrixFields - ? filled.filter((f) => !matrixFields.usedNames.has(f.descriptor.name)) + matrixUsedNames.size > 0 + ? filled.filter((f) => !matrixUsedNames.has(f.descriptor.name)) : filled; return ( @@ -277,12 +313,11 @@ function StaffSection({ {filled.length} with data

- {matrixFields ? ( - + {quarterlyMatrix ? ( + + ) : null} + {monthlyMatrix ? ( + ) : null} {filledOutsideMatrix.length > 0 ? ( @@ -349,41 +384,58 @@ function CompactFieldRow({ const earlier = field.history.slice(1); return ( -
  • +
  • {field.descriptor.label} -
    - +
    + + + {latest.date ? ( - - · {formatShortDate(latest.date)} + + as of {formatShortDate(latest.date)} ) : null} + {earlier.length > 0 ? ( + + ) : null}
    - {earlier.length > 0 ? ( -
    - - {open ? ( -
      - {earlier.map((e, i) => ( -
    • - - {formatShortDate(e.date)} -
    • - ))} -
    - ) : null} -
    + {open && earlier.length > 0 ? ( +
      + {earlier.map((e, i) => ( +
    1. + + {formatShortDate(e.date)} + + + + +
    2. + ))} +
    ) : null}
  • ); @@ -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 { return { 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_ and - * group them into a read-only table mirroring the form's matrix layout. - * The metric set is whatever's actually present in the data so a Civi - * schema addition (e.g. Y1_Q*_Labor_Hours) appears automatically. + * Stage 5 Y1 matrix: detect fields whose names match Y1_

    _ for a + * given period letter (Q for quarterly, M for monthly) and group them into a + * read-only table mirroring the form's matrix layout. The metric set is + * whatever's actually present in the data, so a Civi schema addition (e.g. + * Y1_M*_Labor_Hours) appears automatically. */ interface Y1MatrixRow { metric: string; // e.g. "Labor" | "Margin" | "Member_Sales_" - label: string; // human label from the first field's descriptor (sans Y1_Q_ prefix) - byQuarter: Map; + label: string; // human label from the first field's descriptor (sans Y1_

    _ prefix) + byPeriod: Map; } interface Y1MatrixData { rows: Y1MatrixRow[]; - quarters: number[]; + periods: number[]; + periodLetter: "Q" | "M"; usedNames: Set; } -function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null { - const re = /^Y1_Q(\d+)_(.+)$/; +function collectY1MatrixByPeriod( + 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(); const byMetric = new Map>(); - const quartersSet = new Set(); + const periodsSet = new Set(); const metricLabel = new Map(); for (const f of filled) { - const m = re.exec(f.descriptor.name); + const m = nameRe.exec(f.descriptor.name); if (!m) continue; - const quarter = Number(m[1]); + const period = Number(m[1]); const metric = m[2]; used.add(f.descriptor.name); - quartersSet.add(quarter); + periodsSet.add(period); 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)) { - // Strip "Y1 Q " prefix variants from the label if present. + // Strip the "Y1 Q " / "Y1 M " prefix from the label if present. const cleaned = f.descriptor.label - .replace(/^Y1\s*Q\d+\s*/i, "") + .replace(labelStripRe, "") .replace(/_/g, " ") .trim(); metricLabel.set(metric, cleaned || metric.replace(/_/g, " ")); @@ -503,35 +576,35 @@ function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null } if (byMetric.size === 0) return null; - const quarters = Array.from(quartersSet).sort((a, b) => a - b); - const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byQuarter]) => ({ + const periods = Array.from(periodsSet).sort((a, b) => a - b); + const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byPeriod]) => ({ metric, label: metricLabel.get(metric) ?? metric, - byQuarter, + byPeriod, })); - return { rows, quarters, usedNames: used }; + return { rows, periods, periodLetter, usedNames: used }; } function Y1MatrixTable({ - rows, - quarters, + data, options, }: { - rows: Y1MatrixRow[]; - quarters: number[]; + data: Y1MatrixData; options: Record; }) { + const { rows, periods, periodLetter } = data; + const cadence = periodLetter === "Q" ? "quarterly" : "monthly"; return (

    - {quarters.map((q) => ( - + {periods.map((p) => ( + ))} @@ -539,11 +612,11 @@ function Y1MatrixTable({ {rows.map((row) => ( - {quarters.map((q) => { - const f = row.byQuarter.get(q); + {periods.map((p) => { + const f = row.byPeriod.get(p); const latest = f?.history[0]; return ( -
    - Year 1 quarterly · latest values + Year 1 {cadence} · latest values
    MetricQ{q}{periodLetter}{p}
    {row.label} + {f && latest ? (