Files
Joel Brock 6850ff9dee Staff report: inline lightbox for image/PDF attachments
Adds a /api/staff/file proxy that re-streams Civi attachments with
Content-Disposition: inline so a native <dialog> lightbox can preview
images and PDFs in place. Office docs keep their plain download link
and gain a "View in Google Docs" secondary link (uses the Civi-signed
URL so Google can fetch without our staff key).

Also threads mime through /api/staff/report (Attachment.get mime_type)
so the dispatcher picks the right affordance without relying solely on
filename inference.
2026-06-15 11:56:45 -07:00

781 lines
26 KiB
TypeScript
Raw Permalink 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";
import { FileLink } from "./report/FileLink";
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}
org={org}
authKey={authKey}
/>
))}
<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,
org,
authKey,
}: {
section: StaffReportSection;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: 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}
org={org}
authKey={authKey}
/>
))}
</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,
org,
authKey,
}: {
field: StaffReportField;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: 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}
org={org}
authKey={authKey}
/>
</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}
org={org}
authKey={authKey}
/>
</span>
</li>
))}
</ol>
) : null}
</li>
);
}
function FieldValue({
field,
entry,
options,
civiBaseUrl,
org,
authKey,
}: {
field: StaffReportField;
entry: FieldHistoryEntry;
options: Record<number, SelectOption[]>;
civiBaseUrl: string;
org: number;
authKey: string;
}) {
if (field.descriptor.render === "file") {
const v = entry.value as
| { id?: number | string; file_name?: string; url?: string; mime?: string }
| null;
if (!v || v.id === undefined) return <span></span>;
const id = String(v.id);
const name = v.file_name ?? `file-${id}`;
return (
<FileLink
fileId={id}
fileName={name}
civiSignedUrl={v.url}
mime={v.mime}
org={org}
authKey={authKey}
civiBaseUrl={civiBaseUrl}
/>
);
}
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}
activities={data.activities}
/>
);
}
// formatLongDate retained for symmetry with other report views; unused here
// now that file rows are compact links.
void formatLongDate;