Files
WebForm-mw/components/StaffReportView.tsx
T
Joel Brock b65bc6d0e0 File redirect route: mint fcs JWT in extension, link from staff report
Path 1 (APIv4 Attachment.get + select url) shipped but didn't fix the
Firebase\JWT decode crash — the deployed Civi version either omits `url`
from Attachment.get or returns it without the fcs param. Falling back to
the bare /civicrm/file?id=X URL hits the same JWT null crash.

Path 2: route file clicks through a tiny redirect endpoint in the Civi
extension instead. The extension runs PHP on Civi, has access to the
crypto.jwt service, and mints the same shape of token Civi's own file
URL builder uses ({exp, "civi.file": <id>}) before 302-redirecting to
the canonical /civicrm/file URL.

Civi extension changes:
- New CRM/WebformMw/Page/File.php — resolves eid from civicrm_entity_file
  if not supplied, signs a 7-day JWT via Civi::service('crypto.jwt'),
  redirects.
- xml/Menu/webform_mw.xml — registers civicrm/webform-mw/file. Requires
  `access CiviCRM` (the user is already authenticated in the parent Civi
  tab when they click the link).

Frontend (StaffReportView.tsx, FieldValue):
- When Attachment.get's url is missing, fall back to the new extension
  route instead of bare /civicrm/file. Attachment.get's url remains the
  fast path when present.

Deploy: admin needs to push the updated extension files to the Civi
server, then Disable/Enable webform-mw (or cv flush) so the new menu
route registers in civicrm_menu.
2026-06-10 10:47:34 -07:00

766 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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; url?: string }
| null;
if (!v || v.id === undefined) return <span></span>;
const id = String(v.id);
const name = v.file_name ?? `file-${id}`;
// Prefer the Civi-signed URL (carries the fcs JWT) returned by
// Attachment.get. If absent, fall back to the WebForm-mw Civi extension's
// file-redirect route — it mints the fcs server-side and 302s to the
// real /civicrm/file URL. (Hitting /civicrm/file?id=X bare crashes Civi
// on a null fcs JWT decode.)
let href = "#";
if (v.url) {
href = v.url.startsWith("http")
? v.url
: `${civiBaseUrl}${v.url.startsWith("/") ? "" : "/"}${v.url}`;
} else if (civiBaseUrl) {
href = `${civiBaseUrl}/civicrm/webform-mw/file?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>;
}
/**
* Y1 Monthly Sales Target Civi machine names — irregular. M1 dropped the
* trailing period from "Y1_Monthly_Sales_Targets", M3 lives in a field named
* "_M2" (Civi schema error captured in the original form mapping), and the
* rest follow "Y1_Monthly_Sales_Target_M<n>". Hardcoded here so the staff
* report can fold these into the monthly Y1 matrix.
*/
const Y1_MONTHLY_SALES_TARGET_FIELDS: Record<number, string> = {
1: "Y1_Monthly_Sales_Targets",
2: "Y1_Monthly_Sales_Targets_M2",
3: "Y1_Monthly_Sales_Target_M2", // intentional: Civi name says M2, value is M3.
4: "Y1_Monthly_Sales_Target_M4",
5: "Y1_Monthly_Sales_Target_M5",
6: "Y1_Monthly_Sales_Target_M6",
7: "Y1_Monthly_Sales_Target_M7",
8: "Y1_Monthly_Sales_Target_M8",
9: "Y1_Monthly_Sales_Target_M9",
10: "Y1_Monthly_Sales_Target_M10",
11: "Y1_Monthly_Sales_Target_M11",
12: "Y1_Monthly_Sales_Target_M12",
};
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>();
const byName = new Map(filled.map((f) => [f.descriptor.name, f]));
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, " "));
}
}
// Y1 Monthly Sales Target — fold in the irregular fields that don't fit
// the Y1_M<n>_<metric> regex above. Only present in the monthly matrix.
if (periodLetter === "M") {
const byMonth = new Map<number, StaffReportField>();
for (const [periodStr, fieldName] of Object.entries(Y1_MONTHLY_SALES_TARGET_FIELDS)) {
const f = byName.get(fieldName);
if (!f) continue;
const period = Number(periodStr);
byMonth.set(period, f);
used.add(fieldName);
periodsSet.add(period);
}
if (byMonth.size > 0) {
byMetric.set("Sales_Target", byMonth);
metricLabel.set("Sales_Target", "Sales Target");
}
}
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;