App side: - Per-route CSP: /staff/report now sets frame-ancestors 'self' <CIVI_BASE_URL origin> and drops X-Frame-Options so the CiviCRM extension can iframe it. All other routes keep frame-ancestors 'none' + X-Frame-Options: DENY via a path-negation source. - Staff page recognises ?frame=1 and renders without SiteHeader/ SiteFooter so it fills the iframe cleanly. - StaffReportView posts its scrollHeight to the parent window via postMessage when framed; the Civi tab listens and auto-resizes the iframe (no nested scrollbar). Anchor strip drops its sticky positioning in frame mode since there's no internal scroll. CiviCRM extension (civi-extension/webform-mw/, key webform-mw): - info.xml + main hook file (webform_mw.php) implementing hook_civicrm_tabset to add an 'Engagement Report' tab to Organization contact-view pages. - CRM/WebformMw/Page/Tab.php + Smarty template render an iframe pointing at <WEBFORM_MW_APP_URL>/staff/report?org=<cid>&key=&frame=1, with a postMessage listener that validates event.origin against the configured app URL before resizing. - Config via PHP constants in civicrm.settings.php (WEBFORM_MW_APP_URL, WEBFORM_MW_STAFF_KEY) or matching env vars. Help banner shown when unconfigured. - README documents install, config, behaviour, security caveats.
621 lines
20 KiB
TypeScript
621 lines
20 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.
|
||
useEffect(() => {
|
||
if (!framed || typeof window === "undefined") return;
|
||
if (window.parent === window) return;
|
||
const post = () => {
|
||
window.parent.postMessage(
|
||
{ type: "webform-mw-height", height: document.documentElement.scrollHeight },
|
||
"*",
|
||
);
|
||
};
|
||
post();
|
||
const ro = new ResizeObserver(post);
|
||
ro.observe(document.documentElement);
|
||
window.addEventListener("load", post);
|
||
return () => {
|
||
ro.disconnect();
|
||
window.removeEventListener("load", post);
|
||
};
|
||
}, [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);
|
||
|
||
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>
|
||
<div className="h-px bg-rule" />
|
||
</header>
|
||
|
||
<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 matrix: pull Labor / Margin / Member_Sales quarterly fields
|
||
// out into a single tabular display matching the form's matrix layout.
|
||
const isStage5 = section.groupName === "Stage_5";
|
||
const matrixFields = isStage5 ? collectY1MatrixFields(filled) : null;
|
||
const filledOutsideMatrix =
|
||
matrixFields
|
||
? filled.filter((f) => !matrixFields.usedNames.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>
|
||
|
||
{matrixFields ? (
|
||
<Y1MatrixTable
|
||
rows={matrixFields.rows}
|
||
quarters={matrixFields.quarters}
|
||
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-2">
|
||
<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} />
|
||
{latest.date ? (
|
||
<span className="ml-2 text-[11px] text-ink-soft">
|
||
· {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"
|
||
>
|
||
{open ? "Hide" : `${earlier.length} earlier ${earlier.length === 1 ? "entry" : "entries"}`}
|
||
</button>
|
||
{open ? (
|
||
<ul className="mt-1 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"
|
||
>
|
||
<FieldValue field={field} entry={e} options={options} civiBaseUrl={civiBaseUrl} />
|
||
<span className="text-[11px] text-ink-soft">{formatShortDate(e.date)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
) : 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} />
|
||
);
|
||
}
|
||
|
||
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_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.
|
||
*/
|
||
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>;
|
||
}
|
||
interface Y1MatrixData {
|
||
rows: Y1MatrixRow[];
|
||
quarters: number[];
|
||
usedNames: Set<string>;
|
||
}
|
||
function collectY1MatrixFields(filled: StaffReportField[]): Y1MatrixData | null {
|
||
const re = /^Y1_Q(\d+)_(.+)$/;
|
||
const used = new Set<string>();
|
||
const byMetric = new Map<string, Map<number, StaffReportField>>();
|
||
const quartersSet = new Set<number>();
|
||
const metricLabel = new Map<string, string>();
|
||
|
||
for (const f of filled) {
|
||
const m = re.exec(f.descriptor.name);
|
||
if (!m) continue;
|
||
const quarter = Number(m[1]);
|
||
const metric = m[2];
|
||
used.add(f.descriptor.name);
|
||
quartersSet.add(quarter);
|
||
if (!byMetric.has(metric)) byMetric.set(metric, new Map());
|
||
byMetric.get(metric)!.set(quarter, f);
|
||
if (!metricLabel.has(metric)) {
|
||
// Strip "Y1 Q<n> " prefix variants from the label if present.
|
||
const cleaned = f.descriptor.label
|
||
.replace(/^Y1\s*Q\d+\s*/i, "")
|
||
.replace(/_/g, " ")
|
||
.trim();
|
||
metricLabel.set(metric, cleaned || metric.replace(/_/g, " "));
|
||
}
|
||
}
|
||
|
||
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]) => ({
|
||
metric,
|
||
label: metricLabel.get(metric) ?? metric,
|
||
byQuarter,
|
||
}));
|
||
return { rows, quarters, usedNames: used };
|
||
}
|
||
|
||
function Y1MatrixTable({
|
||
rows,
|
||
quarters,
|
||
options,
|
||
}: {
|
||
rows: Y1MatrixRow[];
|
||
quarters: number[];
|
||
options: Record<number, SelectOption[]>;
|
||
}) {
|
||
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
|
||
</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>
|
||
))}
|
||
</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>
|
||
{quarters.map((q) => {
|
||
const f = row.byQuarter.get(q);
|
||
const latest = f?.history[0];
|
||
return (
|
||
<td key={q} 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;
|