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.
716 lines
24 KiB
TypeScript
716 lines
24 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useState } from "react";
|
||
import type {
|
||
FieldConfig,
|
||
FieldHistoryEntry,
|
||
SelectOption,
|
||
StageSectionConfig,
|
||
StaffReportField,
|
||
StaffReportPayload,
|
||
StaffReportSection,
|
||
} from "@/types/form";
|
||
import { MembershipChart, MEMBERS_ACTUAL_NAME, MEMBERS_GOAL_NAME } from "./report/MembershipChart";
|
||
import { DateTimeline } from "./report/DateTimeline";
|
||
import {
|
||
FormattedValue,
|
||
formatShortDate,
|
||
formatLongDate,
|
||
computeDateRange,
|
||
} from "./report/FieldHistory";
|
||
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
|
||
|
||
interface StaffReportViewProps {
|
||
org: number;
|
||
authKey: string;
|
||
/** CIVI_BASE_URL, used to build outbound file links. */
|
||
civiBaseUrl: string;
|
||
/** True when the page is being embedded in a CiviCRM tab via iframe. */
|
||
framed?: boolean;
|
||
}
|
||
|
||
type LoadState =
|
||
| { kind: "loading" }
|
||
| { kind: "error"; message: string }
|
||
| { kind: "ready"; data: StaffReportPayload };
|
||
|
||
const STAGE_OPTION_GROUP_ID = 75;
|
||
|
||
export function StaffReportView({
|
||
org,
|
||
authKey,
|
||
civiBaseUrl,
|
||
framed = false,
|
||
}: StaffReportViewProps) {
|
||
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
||
|
||
useEffect(() => {
|
||
let alive = true;
|
||
(async () => {
|
||
try {
|
||
const res = await fetch(
|
||
`/api/staff/report?org=${encodeURIComponent(String(org))}&key=${encodeURIComponent(authKey)}`,
|
||
{ cache: "no-store" },
|
||
);
|
||
if (!res.ok) {
|
||
const body = (await res.json().catch(() => ({}))) as { error?: string };
|
||
if (alive)
|
||
setLoad({
|
||
kind: "error",
|
||
message: body.error ?? `Request failed (${res.status})`,
|
||
});
|
||
return;
|
||
}
|
||
const data = (await res.json()) as StaffReportPayload;
|
||
if (alive) setLoad({ kind: "ready", data });
|
||
} catch (e) {
|
||
const msg = e instanceof Error ? e.message : String(e);
|
||
if (alive) setLoad({ kind: "error", message: msg });
|
||
}
|
||
})();
|
||
return () => {
|
||
alive = false;
|
||
};
|
||
}, [org, authKey]);
|
||
|
||
// When embedded, post our content height to the parent so the Civi tab's
|
||
// iframe can resize to fit (no nested scrollbars). The receiving script
|
||
// lives in the WebForm-mw Civi extension's tab template.
|
||
//
|
||
// The root layout sets `html.h-full` and `body.min-h-full`, which tie
|
||
// document height to the iframe's viewport height. Combined with the
|
||
// parent setting `iframe.height = postedHeight + 24` on every message,
|
||
// that creates an unbounded feedback loop (viewport grows -> measured
|
||
// height grows -> parent grows the iframe -> repeat). Inside the iframe
|
||
// we decouple html/body from the viewport, measure `body.scrollHeight`
|
||
// (the actual content), observe the body, and skip duplicate posts.
|
||
useEffect(() => {
|
||
if (!framed || typeof window === "undefined") return;
|
||
if (window.parent === window) return;
|
||
const html = document.documentElement;
|
||
const body = document.body;
|
||
const prevHtmlHeight = html.style.height;
|
||
const prevBodyMinHeight = body.style.minHeight;
|
||
html.style.height = "auto";
|
||
body.style.minHeight = "0";
|
||
let lastHeight = -1;
|
||
const post = () => {
|
||
const h = body.scrollHeight;
|
||
if (h === lastHeight) return;
|
||
lastHeight = h;
|
||
window.parent.postMessage({ type: "webform-mw-height", height: h }, "*");
|
||
};
|
||
post();
|
||
const ro = new ResizeObserver(post);
|
||
ro.observe(body);
|
||
window.addEventListener("load", post);
|
||
return () => {
|
||
ro.disconnect();
|
||
window.removeEventListener("load", post);
|
||
html.style.height = prevHtmlHeight;
|
||
body.style.minHeight = prevBodyMinHeight;
|
||
};
|
||
}, [framed, load]);
|
||
|
||
if (load.kind === "loading") return <LoadingState />;
|
||
if (load.kind === "error") return <ErrorState message={load.message} />;
|
||
const { data } = load;
|
||
if (data.sections.length === 0 && data.activities.length === 0) return <EmptyState />;
|
||
|
||
const checkInSection = data.sections.find((s) => s.groupName === "Check_in_data__organizing_");
|
||
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
|
||
: "—";
|
||
const dateRange = computeDateRange(data.activities.map((a) => a.date));
|
||
|
||
return (
|
||
<article className="space-y-10">
|
||
<header className="space-y-4">
|
||
<p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700">
|
||
Staff report · Internal use only
|
||
</p>
|
||
<h1 className="font-display text-[40px] font-normal leading-[1.05] tracking-tight text-ink sm:text-[52px]">
|
||
{data.orgName}
|
||
</h1>
|
||
<dl className="grid grid-cols-2 gap-6 text-sm sm:grid-cols-4">
|
||
<Stat label="Current stage" value={stageLabel} />
|
||
<Stat label="Submissions" value={String(data.activities.length)} />
|
||
<Stat
|
||
label="Date range"
|
||
value={
|
||
dateRange
|
||
? `${formatShortDate(dateRange.from)} – ${formatShortDate(dateRange.to)}`
|
||
: "—"
|
||
}
|
||
/>
|
||
<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>
|
||
|
||
{/* Hide the anchor strip when framed in CiviCRM: the iframe has no
|
||
internal scroll context (it auto-sizes to content), so anchor
|
||
clicks change the hash but don't move the parent page. */}
|
||
{!framed && (
|
||
<SectionAnchorNav
|
||
sections={data.sections}
|
||
hasActivities={data.activities.length > 0}
|
||
framed={framed}
|
||
/>
|
||
)}
|
||
|
||
{membersField && membersField.history.length > 0 ? (
|
||
<MembershipChart
|
||
membersHistory={membersField.history}
|
||
goalHistory={goalField?.history}
|
||
/>
|
||
) : null}
|
||
|
||
<StaffDateTimeline data={data} />
|
||
|
||
{data.sections.map((section) => (
|
||
<StaffSection
|
||
key={section.groupName}
|
||
section={section}
|
||
options={data.options}
|
||
civiBaseUrl={civiBaseUrl}
|
||
/>
|
||
))}
|
||
|
||
<ActivityTable activities={data.activities} options={data.options} />
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
|
||
return (
|
||
<div>
|
||
<dt className="text-[11px] uppercase tracking-[0.16em] text-ink-soft">{label}</dt>
|
||
<dd className="mt-1 text-[15px] text-ink">{value}</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Horizontal anchor strip — one chip per section + Submissions.
|
||
*
|
||
* Sticky in standalone mode; non-sticky when embedded in a CiviCRM tab
|
||
* (the iframe auto-resizes to fit content so there's no internal scroll
|
||
* for `sticky` to engage against).
|
||
*/
|
||
function SectionAnchorNav({
|
||
sections,
|
||
hasActivities,
|
||
framed,
|
||
}: {
|
||
sections: StaffReportSection[];
|
||
hasActivities: boolean;
|
||
framed: boolean;
|
||
}) {
|
||
const items = sections.map((s) => ({
|
||
href: `#section-${s.groupName}`,
|
||
label: s.groupKind === "org" ? "Org profile" : s.groupTitle,
|
||
}));
|
||
if (hasActivities) items.push({ href: "#section-submissions", label: "Submissions" });
|
||
const stickyCls = framed ? "" : "sticky top-0 z-30 backdrop-blur";
|
||
return (
|
||
<nav
|
||
aria-label="Section navigation"
|
||
className={`${stickyCls} -mx-4 border-y border-rule bg-paper/95 px-4 py-2 sm:-mx-6 sm:px-6`}
|
||
>
|
||
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px]">
|
||
{items.map((it) => (
|
||
<li key={it.href}>
|
||
<a
|
||
href={it.href}
|
||
className="inline-block rounded-full border border-rule bg-paper px-2.5 py-0.5 text-ink-soft hover:border-leaf-700 hover:text-leaf-800"
|
||
>
|
||
{it.label}
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
function StaffSection({
|
||
section,
|
||
options,
|
||
civiBaseUrl,
|
||
}: {
|
||
section: StaffReportSection;
|
||
options: Record<number, SelectOption[]>;
|
||
civiBaseUrl: string;
|
||
}) {
|
||
const filled = section.fields.filter((f) => f.history.length > 0);
|
||
const empty = section.fields.filter((f) => f.history.length === 0);
|
||
const [showEmpty, setShowEmpty] = useState(false);
|
||
|
||
// 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 quarterlyMatrix = isStage5 ? collectY1MatrixByPeriod(filled, "Q") : null;
|
||
const monthlyMatrix = isStage5 ? collectY1MatrixByPeriod(filled, "M") : null;
|
||
const matrixUsedNames = new Set<string>([
|
||
...(quarterlyMatrix?.usedNames ?? []),
|
||
...(monthlyMatrix?.usedNames ?? []),
|
||
]);
|
||
const filledOutsideMatrix =
|
||
matrixUsedNames.size > 0
|
||
? filled.filter((f) => !matrixUsedNames.has(f.descriptor.name))
|
||
: filled;
|
||
|
||
return (
|
||
<section
|
||
id={`section-${section.groupName}`}
|
||
aria-labelledby={`heading-${section.groupName}`}
|
||
className="space-y-3 scroll-mt-16"
|
||
>
|
||
<h2
|
||
id={`heading-${section.groupName}`}
|
||
className="font-display text-2xl font-medium text-ink"
|
||
>
|
||
{section.groupTitle}
|
||
</h2>
|
||
<p className="text-[12px] uppercase tracking-[0.16em] text-ink-soft">
|
||
{section.fields.length} field{section.fields.length === 1 ? "" : "s"} ·{" "}
|
||
{filled.length} with data
|
||
</p>
|
||
|
||
{quarterlyMatrix ? (
|
||
<Y1MatrixTable data={quarterlyMatrix} options={options} />
|
||
) : null}
|
||
{monthlyMatrix ? (
|
||
<Y1MatrixTable data={monthlyMatrix} options={options} />
|
||
) : null}
|
||
|
||
{filledOutsideMatrix.length > 0 ? (
|
||
<ul className="divide-y divide-rule rounded-md border border-rule bg-paper">
|
||
{filledOutsideMatrix.map((f) => (
|
||
<CompactFieldRow
|
||
key={f.descriptor.name}
|
||
field={f}
|
||
options={options}
|
||
civiBaseUrl={civiBaseUrl}
|
||
/>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
|
||
{empty.length > 0 ? (
|
||
<div className="rounded-md border border-rule bg-paper">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowEmpty((v) => !v)}
|
||
className="flex w-full items-center justify-between px-3 py-1.5 text-[12px] uppercase tracking-[0.14em] text-ink-soft hover:text-ink"
|
||
>
|
||
<span>{empty.length} empty field{empty.length === 1 ? "" : "s"}</span>
|
||
<span aria-hidden>{showEmpty ? "▾" : "▸"}</span>
|
||
</button>
|
||
{showEmpty ? (
|
||
<ul className="divide-y divide-rule border-t border-rule">
|
||
{empty.map((f) => (
|
||
<li
|
||
key={f.descriptor.name}
|
||
className="flex items-baseline justify-between px-3 py-1 text-[13px] text-ink-soft"
|
||
>
|
||
<span>{f.descriptor.label}</span>
|
||
<span>—</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Compact, latest-only row. If the field has multiple history entries, a
|
||
* muted "N earlier entries" toggle reveals the rest inline.
|
||
*
|
||
* File-typed values render as an outbound link to CiviCRM rather than a
|
||
* proxied download — staff are already logged into Civi when they arrive
|
||
* here, and the server doesn't need to broker bytes.
|
||
*/
|
||
function CompactFieldRow({
|
||
field,
|
||
options,
|
||
civiBaseUrl,
|
||
}: {
|
||
field: StaffReportField;
|
||
options: Record<number, SelectOption[]>;
|
||
civiBaseUrl: string;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const latest = field.history[0];
|
||
const earlier = field.history.slice(1);
|
||
|
||
return (
|
||
<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="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="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||
as of {formatShortDate(latest.date)}
|
||
</span>
|
||
) : null}
|
||
{earlier.length > 0 ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
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 entries"
|
||
: `${earlier.length} earlier ${earlier.length === 1 ? "entry" : "entries"}`}
|
||
</button>
|
||
) : 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"
|
||
>
|
||
<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>
|
||
))}
|
||
</ol>
|
||
) : null}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function FieldValue({
|
||
field,
|
||
entry,
|
||
options,
|
||
civiBaseUrl,
|
||
}: {
|
||
field: StaffReportField;
|
||
entry: FieldHistoryEntry;
|
||
options: Record<number, SelectOption[]>;
|
||
civiBaseUrl: string;
|
||
}) {
|
||
if (field.descriptor.render === "file") {
|
||
const v = entry.value as { id?: number | string; file_name?: string } | null;
|
||
if (!v || v.id === undefined) return <span>—</span>;
|
||
const id = String(v.id);
|
||
const name = v.file_name ?? `file-${id}`;
|
||
// Civi serves uploaded files at /civicrm/file?reset=1&id=<id>.
|
||
// The staff member is already authenticated to Civi (they came from
|
||
// there); the browser sends their session cookie automatically.
|
||
const href = civiBaseUrl
|
||
? `${civiBaseUrl}/civicrm/file?reset=1&id=${encodeURIComponent(id)}`
|
||
: "#";
|
||
return (
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
|
||
>
|
||
{name}
|
||
</a>
|
||
);
|
||
}
|
||
return (
|
||
<FormattedValue value={entry.value} field={fieldConfigFor(field)} options={options} />
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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,
|
||
label: f.descriptor.label,
|
||
type: renderToFieldType(f.descriptor.render),
|
||
optionGroupId: f.descriptor.optionGroupId,
|
||
};
|
||
}
|
||
|
||
function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldConfig["type"] {
|
||
switch (r) {
|
||
case "currency":
|
||
return "currency";
|
||
case "date":
|
||
case "datetime":
|
||
return "date";
|
||
case "select":
|
||
return "select";
|
||
case "multiselect":
|
||
return "multiselect";
|
||
case "file":
|
||
return "file";
|
||
case "longtext":
|
||
return "textarea";
|
||
case "boolean":
|
||
return "boolean";
|
||
case "number":
|
||
return "number";
|
||
default:
|
||
return "text";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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_<P><n>_ prefix)
|
||
byPeriod: Map<number, StaffReportField>;
|
||
}
|
||
interface Y1MatrixData {
|
||
rows: Y1MatrixRow[];
|
||
periods: number[];
|
||
periodLetter: "Q" | "M";
|
||
usedNames: Set<string>;
|
||
}
|
||
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 periodsSet = new Set<number>();
|
||
const metricLabel = new Map<string, string>();
|
||
|
||
for (const f of filled) {
|
||
const m = nameRe.exec(f.descriptor.name);
|
||
if (!m) continue;
|
||
const period = Number(m[1]);
|
||
const metric = m[2];
|
||
used.add(f.descriptor.name);
|
||
periodsSet.add(period);
|
||
if (!byMetric.has(metric)) byMetric.set(metric, new Map());
|
||
byMetric.get(metric)!.set(period, f);
|
||
if (!metricLabel.has(metric)) {
|
||
// Strip the "Y1 Q<n> " / "Y1 M<n> " prefix from the label if present.
|
||
const cleaned = f.descriptor.label
|
||
.replace(labelStripRe, "")
|
||
.replace(/_/g, " ")
|
||
.trim();
|
||
metricLabel.set(metric, cleaned || metric.replace(/_/g, " "));
|
||
}
|
||
}
|
||
|
||
if (byMetric.size === 0) return null;
|
||
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,
|
||
byPeriod,
|
||
}));
|
||
return { rows, periods, periodLetter, usedNames: used };
|
||
}
|
||
|
||
function Y1MatrixTable({
|
||
data,
|
||
options,
|
||
}: {
|
||
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 {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>
|
||
{periods.map((p) => (
|
||
<th key={p} className="px-3 py-2 font-medium">{periodLetter}{p}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-rule">
|
||
{rows.map((row) => (
|
||
<tr key={row.metric}>
|
||
<td className="px-3 py-2 text-[13px] text-ink">{row.label}</td>
|
||
{periods.map((p) => {
|
||
const f = row.byPeriod.get(p);
|
||
const latest = f?.history[0];
|
||
return (
|
||
<td key={p} className="px-3 py-2 text-[13px] text-ink-soft tabular-nums">
|
||
{f && latest ? (
|
||
<FormattedValue
|
||
value={latest.value}
|
||
field={fieldConfigFor(f)}
|
||
options={options}
|
||
/>
|
||
) : (
|
||
"—"
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ActivityTable({
|
||
activities,
|
||
options,
|
||
}: {
|
||
activities: StaffReportPayload["activities"];
|
||
options: Record<number, SelectOption[]>;
|
||
}) {
|
||
if (activities.length === 0) {
|
||
return (
|
||
<section id="section-submissions" className="space-y-3 scroll-mt-16">
|
||
<h2 className="font-display text-2xl font-medium text-ink">All submissions</h2>
|
||
<p className="text-sm text-ink-soft">No submissions recorded for this organization yet.</p>
|
||
</section>
|
||
);
|
||
}
|
||
const stageOptions = options[STAGE_OPTION_GROUP_ID] ?? [];
|
||
const stageLabel = (v: string | null | undefined) =>
|
||
v ? stageOptions.find((o) => o.value === v)?.label ?? v : "—";
|
||
|
||
return (
|
||
<section id="section-submissions" className="space-y-3 scroll-mt-16">
|
||
<h2 className="font-display text-2xl font-medium text-ink">All submissions</h2>
|
||
<div className="overflow-x-auto rounded-md border border-rule">
|
||
<table className="min-w-full text-sm">
|
||
<thead className="bg-paper text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
|
||
<tr>
|
||
<th className="px-3 py-2">Date</th>
|
||
<th className="px-3 py-2">Stage snapshot</th>
|
||
<th className="px-3 py-2">Subject</th>
|
||
<th className="px-3 py-2">Submitted by</th>
|
||
<th className="px-3 py-2">Activity id</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-rule">
|
||
{activities.map((a) => (
|
||
<tr key={a.id}>
|
||
<td className="px-3 py-2 text-ink">{formatShortDate(a.date)}</td>
|
||
<td className="px-3 py-2 text-ink">{stageLabel(a.stage ?? null)}</td>
|
||
<td className="px-3 py-2 text-ink">{a.subject ?? "—"}</td>
|
||
<td className="px-3 py-2 text-ink">{a.submittedBy ?? "—"}</td>
|
||
<td className="px-3 py-2 font-mono text-xs text-ink-soft">{a.id}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
|
||
const fieldHistory: Record<string, FieldHistoryEntry[]> = {};
|
||
const fieldConfigs: FieldConfig[] = [];
|
||
|
||
for (const section of data.sections) {
|
||
if (section.groupKind !== "activity") continue;
|
||
for (const f of section.fields) {
|
||
if (f.descriptor.render !== "date" && f.descriptor.render !== "datetime") continue;
|
||
if (f.history.length === 0) continue;
|
||
fieldHistory[f.descriptor.name] = f.history;
|
||
fieldConfigs.push(fieldConfigFor(f));
|
||
}
|
||
}
|
||
|
||
if (fieldConfigs.length === 0) return null;
|
||
|
||
const sections: StageSectionConfig[] = [
|
||
{ rank: 0, id: "all", label: "All dated events", fields: fieldConfigs },
|
||
];
|
||
|
||
return <DateTimeline sections={sections} fieldHistory={fieldHistory} />;
|
||
}
|
||
|
||
// formatLongDate retained for symmetry with other report views; unused here
|
||
// now that file rows are compact links.
|
||
void formatLongDate;
|