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.
This commit is contained in:
+132
-59
@@ -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({
|
||||
/>
|
||||
<Stat label="Org id" value={<code className="font-mono">{data.orgId}</code>} />
|
||||
</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" />
|
||||
</header>
|
||||
|
||||
@@ -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<string>([
|
||||
...(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
|
||||
</p>
|
||||
|
||||
{matrixFields ? (
|
||||
<Y1MatrixTable
|
||||
rows={matrixFields.rows}
|
||||
quarters={matrixFields.quarters}
|
||||
options={options}
|
||||
/>
|
||||
{quarterlyMatrix ? (
|
||||
<Y1MatrixTable data={quarterlyMatrix} options={options} />
|
||||
) : null}
|
||||
{monthlyMatrix ? (
|
||||
<Y1MatrixTable data={monthlyMatrix} options={options} />
|
||||
) : null}
|
||||
|
||||
{filledOutsideMatrix.length > 0 ? (
|
||||
@@ -349,41 +384,58 @@ function CompactFieldRow({
|
||||
const earlier = field.history.slice(1);
|
||||
|
||||
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">
|
||||
<span className="text-[13px] font-medium text-ink">{field.descriptor.label}</span>
|
||||
<div className="text-[13px] text-ink-soft">
|
||||
<FieldValue field={field} entry={latest} options={options} civiBaseUrl={civiBaseUrl} />
|
||||
<div className="flex flex-col items-end gap-0.5 text-right">
|
||||
<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 ? (
|
||||
<span className="ml-2 text-[11px] text-ink-soft">
|
||||
· {formatShortDate(latest.date)}
|
||||
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||||
as of {formatShortDate(latest.date)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{earlier.length > 0 ? (
|
||||
<div className="mt-1">
|
||||
<button
|
||||
type="button"
|
||||
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>
|
||||
{open ? (
|
||||
<ul className="mt-1 space-y-1 border-l border-rule pl-3">
|
||||
) : null}
|
||||
</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) => (
|
||||
<li
|
||||
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] text-ink-soft">{formatShortDate(e.date)}</span>
|
||||
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||||
{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>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</div>
|
||||
</ol>
|
||||
) : null}
|
||||
</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 {
|
||||
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<n>_<metric> 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_<P><n>_<metric> 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<n>_ prefix)
|
||||
byQuarter: Map<number, StaffReportField>;
|
||||
label: string; // human label from the first field's descriptor (sans Y1_<P><n>_ prefix)
|
||||
byPeriod: Map<number, StaffReportField>;
|
||||
}
|
||||
interface Y1MatrixData {
|
||||
rows: Y1MatrixRow[];
|
||||
quarters: number[];
|
||||
periods: number[];
|
||||
periodLetter: "Q" | "M";
|
||||
usedNames: Set<string>;
|
||||
}
|
||||
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<string>();
|
||||
const byMetric = new Map<string, Map<number, StaffReportField>>();
|
||||
const quartersSet = new Set<number>();
|
||||
const periodsSet = new Set<number>();
|
||||
const metricLabel = new Map<string, string>();
|
||||
|
||||
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<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
|
||||
.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<number, SelectOption[]>;
|
||||
}) {
|
||||
const { rows, periods, periodLetter } = data;
|
||||
const cadence = periodLetter === "Q" ? "quarterly" : "monthly";
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-md border border-rule bg-paper">
|
||||
<table className="min-w-full text-sm">
|
||||
<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>
|
||||
<thead className="text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">Metric</th>
|
||||
{quarters.map((q) => (
|
||||
<th key={q} className="px-3 py-2 font-medium">Q{q}</th>
|
||||
{periods.map((p) => (
|
||||
<th key={p} className="px-3 py-2 font-medium">{periodLetter}{p}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -539,11 +612,11 @@ function Y1MatrixTable({
|
||||
{rows.map((row) => (
|
||||
<tr key={row.metric}>
|
||||
<td className="px-3 py-2 text-[13px] text-ink">{row.label}</td>
|
||||
{quarters.map((q) => {
|
||||
const f = row.byQuarter.get(q);
|
||||
{periods.map((p) => {
|
||||
const f = row.byPeriod.get(p);
|
||||
const latest = f?.history[0];
|
||||
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 ? (
|
||||
<FormattedValue
|
||||
value={latest.value}
|
||||
|
||||
Reference in New Issue
Block a user