Staff report: compact rows, anchor nav, Civi file links, Y1 matrix

UX iteration after first live look:
- Sticky anchor strip below the header with a chip per section (incl.
  Submissions) so staff can jump around a long page.
- Compact one-line rows that show only the latest value; multi-history
  fields get a muted 'N earlier entries' toggle that reveals the rest
  inline. Same affordance for file fields.
- Empty fields collapse under a single 'N empty fields' toggle per
  section instead of taking a row each.
- Stage 5: Y1_Q<n>_<metric> fields render as a read-only matrix table
  (rows: metrics; columns: Q1..Q4) matching the form's matrix layout.

File proxy (/api/staff/file) deleted. APIv4 Attachment isn't exposed
on this Civi instance (per the June upload spike), which is why the
previous proxy returned broken images. Staff are already authenticated
to Civi when they arrive here, so file fields now render as outbound
links to CIVI_BASE_URL/civicrm/file?reset=1&id=<id> and the browser
uses the staff session. No more proxy auth, no more SSRF surface to
harden, no broken images.

CIVI_BASE_URL flows from the staff page (server component) into the
client as a prop. No secret material crosses the boundary.
This commit is contained in:
Joel Brock
2026-06-05 17:28:17 -07:00
parent 586cf14e75
commit b548b6425b
3 changed files with 329 additions and 292 deletions
-135
View File
@@ -1,135 +0,0 @@
/**
* GET /api/staff/file?id=<civi_file_id>&key=<secret>
*
* Streams an attachment from CiviCRM to the caller. The Civi API user's
* credentials never leave the server. Auth is the same shared
* STAFF_REPORT_KEY used by /api/staff/report.
*
* In stub mode, returns a tiny placeholder PNG so the UI's preview path
* is exercisable in dev.
*/
import { NextRequest, NextResponse } from "next/server";
import { isStaffKeyValid } from "@/lib/staff-auth";
import { civi } from "@/lib/civicrm";
function isCiviStubMode(): boolean {
return !(
process.env.CIVI_BASE_URL &&
process.env.CIVI_API_KEY &&
process.env.CIVI_SITE_KEY
);
}
// 1x1 transparent PNG, base64-encoded — used as a stub attachment so the
// UI's image preview path renders something in dev.
const STUB_PNG_B64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
export async function GET(req: NextRequest) {
const url = new URL(req.url);
const key = url.searchParams.get("key");
const idStr = url.searchParams.get("id");
if (!isStaffKeyValid(key)) {
return new NextResponse("Not found", { status: 404 });
}
const id = Number(idStr);
if (!idStr || !Number.isFinite(id) || id <= 0) {
return new NextResponse("Bad request", { status: 400 });
}
if (isCiviStubMode()) {
const bytes = Buffer.from(STUB_PNG_B64, "base64");
return new NextResponse(bytes, {
status: 200,
headers: {
"content-type": "image/png",
"content-disposition": `inline; filename="stub-${id}.png"`,
"cache-control": "private, max-age=60",
},
});
}
try {
// Look up the attachment URL and metadata.
const meta = await civi<{ id: number; url: string; mime_type: string; name: string }>(
"Attachment",
"get",
{
select: ["id", "url", "mime_type", "name"],
where: [["id", "=", id]],
},
);
const row = meta.values?.[0];
if (!row?.url) {
return new NextResponse("Not found", { status: 404 });
}
// SSRF guard: only follow URLs whose origin matches CIVI_BASE_URL. Civi
// returns absolute URLs for attachments; if a compromised Civi (or DB row
// tamper) ever set this to an attacker-controlled host, the basic-auth
// creds attached below would leak. Validating the origin closes that.
const civiOrigin = new URL(process.env.CIVI_BASE_URL!).origin;
let upstreamUrl: URL;
try {
upstreamUrl = new URL(row.url);
} catch {
console.error(`[staff/file] malformed civi url id=${id}`);
return new NextResponse("Upstream error", { status: 502 });
}
if (upstreamUrl.origin !== civiOrigin) {
console.error(
`[staff/file] refused cross-origin upstream id=${id} origin=${upstreamUrl.origin}`,
);
return new NextResponse("Upstream error", { status: 502 });
}
const headers: Record<string, string> = {};
if (process.env.CIVI_HTTP_AUTH_USER && process.env.CIVI_HTTP_AUTH_PASS) {
const creds = Buffer.from(
`${process.env.CIVI_HTTP_AUTH_USER}:${process.env.CIVI_HTTP_AUTH_PASS}`,
).toString("base64");
headers["Authorization"] = `Basic ${creds}`;
}
const upstream = await fetch(upstreamUrl, { headers, redirect: "manual" });
if (!upstream.ok || !upstream.body) {
console.error(
`[staff/file] upstream fetch failed: id=${id} status=${upstream.status}`,
);
return new NextResponse("Upstream error", { status: 502 });
}
// XSS guard: only allow a fixed allowlist of MIME types to render inline
// (browsers execute scripts inside SVGs and HTML, and will sniff some
// ambiguous types). Everything else is forced to attachment with a
// neutralised content-type. nosniff blocks MIME sniffing entirely.
const SAFE_INLINE = new Set([
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"application/pdf",
]);
const safeName = (row.name || `file-${id}`).replace(/[\r\n"]/g, "");
const declaredMime = row.mime_type || upstream.headers.get("content-type") || "application/octet-stream";
const isInline = SAFE_INLINE.has(declaredMime);
const servedMime = isInline ? declaredMime : "application/octet-stream";
return new NextResponse(upstream.body, {
status: 200,
headers: {
"content-type": servedMime,
"content-disposition": `${isInline ? "inline" : "attachment"}; filename="${safeName}"`,
"cache-control": "private, max-age=60",
"x-content-type-options": "nosniff",
"content-security-policy": "default-src 'none'; sandbox; style-src 'unsafe-inline'",
},
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
console.error(`[staff/file] fetch threw: ${msg}`);
return new NextResponse("Upstream error", { status: 502 });
}
}
+4 -1
View File
@@ -23,6 +23,9 @@ export default async function StaffReportPage({ searchParams }: PageProps) {
const orgId = Number(org);
const orgValid = !!org && Number.isFinite(orgId) && orgId > 0;
// CIVI_BASE_URL flows from server config to client only as a base for
// outbound file links. No secret material is exposed.
const civiBaseUrl = process.env.CIVI_BASE_URL ?? "";
return (
<>
@@ -37,7 +40,7 @@ export default async function StaffReportPage({ searchParams }: PageProps) {
<div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14">
{orgValid ? (
<Suspense fallback={null}>
<StaffReportView org={orgId} authKey={key!} />
<StaffReportView org={orgId} authKey={key!} civiBaseUrl={civiBaseUrl} />
</Suspense>
) : (
<MissingOrg />
+310 -141
View File
@@ -13,7 +13,7 @@ import type {
import { MembershipChart, MEMBERS_ACTUAL_NAME, MEMBERS_GOAL_NAME } from "./report/MembershipChart";
import { DateTimeline } from "./report/DateTimeline";
import {
FieldHistoryRow,
FormattedValue,
formatShortDate,
formatLongDate,
computeDateRange,
@@ -23,6 +23,8 @@ import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
interface StaffReportViewProps {
org: number;
authKey: string;
/** CIVI_BASE_URL, used to build outbound file links. */
civiBaseUrl: string;
}
type LoadState =
@@ -30,7 +32,9 @@ type LoadState =
| { kind: "error"; message: string }
| { kind: "ready"; data: StaffReportPayload };
export function StaffReportView({ org, authKey }: StaffReportViewProps) {
const STAGE_OPTION_GROUP_ID = 75;
export function StaffReportView({ org, authKey, civiBaseUrl }: StaffReportViewProps) {
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
useEffect(() => {
@@ -67,19 +71,18 @@ export function StaffReportView({ org, authKey }: StaffReportViewProps) {
const { data } = load;
if (data.sections.length === 0 && data.activities.length === 0) return <EmptyState />;
// Pull the membership + goal series out wherever they live (Check_in_data__organizing_).
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[75]?.find((o) => o.value === data.currentStage)?.label ?? data.currentStage
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-12">
<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
@@ -103,18 +106,24 @@ export function StaffReportView({ org, authKey }: StaffReportViewProps) {
<div className="h-px bg-rule" />
</header>
<SectionAnchorNav sections={data.sections} hasActivities={data.activities.length > 0} />
{membersField && membersField.history.length > 0 ? (
<MembershipChart
membersHistory={historyOnly(membersField)}
goalHistory={goalField ? historyOnly(goalField) : undefined}
membersHistory={membersField.history}
goalHistory={goalField?.history}
/>
) : null}
{/* Build a synthetic "sections + fieldHistory" view the DateTimeline understands. */}
<StaffDateTimeline data={data} />
{data.sections.map((section) => (
<StaffSection key={section.groupName} section={section} options={data.options} authKey={authKey} />
<StaffSection
key={section.groupName}
section={section}
options={data.options}
civiBaseUrl={civiBaseUrl}
/>
))}
<ActivityTable activities={data.activities} options={data.options} />
@@ -122,10 +131,6 @@ export function StaffReportView({ org, authKey }: StaffReportViewProps) {
);
}
function historyOnly(f: StaffReportField): FieldHistoryEntry[] {
return f.history;
}
function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div>
@@ -135,58 +140,229 @@ function Stat({ label, value }: { label: string; value: React.ReactNode }) {
);
}
/** Sticky horizontal anchor strip — one chip per section + Submissions. */
function SectionAnchorNav({
sections,
hasActivities,
}: {
sections: StaffReportSection[];
hasActivities: 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" });
return (
<nav
aria-label="Section navigation"
className="sticky top-0 z-30 -mx-4 border-y border-rule bg-paper/95 px-4 py-2 backdrop-blur 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,
authKey,
civiBaseUrl,
}: {
section: StaffReportSection;
options: Record<number, SelectOption[]>;
authKey: string;
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 className="space-y-4">
<h2 className="font-display text-2xl font-medium text-ink">{section.groupTitle}</h2>
<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>
<ul className="divide-y divide-rule rounded-md border border-rule bg-paper">
{filled.map((f) => (
<li key={f.descriptor.name} className="px-4 py-4">
{f.descriptor.render === "file" ? (
<FileFieldRow field={f} authKey={authKey} />
) : (
<FieldHistoryRow
field={fieldConfigFor(f)}
entries={f.history}
{matrixFields ? (
<Y1MatrixTable
rows={matrixFields.rows}
quarters={matrixFields.quarters}
options={options}
/>
)}
</li>
) : 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-center justify-between px-4 py-3 text-sm text-ink-soft"
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>
);
}
/**
* Adapt a StaffReportField to the FieldConfig shape FieldHistoryRow / FormattedValue
* expect. The renderer only reads `name`, `label`, `type`, `optionGroupId`.
* 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,
@@ -201,7 +377,6 @@ function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldCo
case "currency":
return "currency";
case "date":
return "date";
case "datetime":
return "date";
case "select":
@@ -221,101 +396,107 @@ function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldCo
}
}
function FileFieldRow({ field, authKey }: { field: StaffReportField; authKey: string }) {
return (
<div>
<p className="font-medium text-ink">{field.descriptor.label}</p>
<ul className="mt-2 space-y-2">
{field.history.map((entry) => (
<FilePreviewItem key={`${entry.activityId}-${entry.date}`} entry={entry} authKey={authKey} />
))}
</ul>
</div>
);
/**
* 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 FilePreviewItem({
entry,
authKey,
function Y1MatrixTable({
rows,
quarters,
options,
}: {
entry: FieldHistoryEntry;
authKey: string;
rows: Y1MatrixRow[];
quarters: number[];
options: Record<number, SelectOption[]>;
}) {
const v = entry.value as { id?: number | string; file_name?: string } | null;
if (!v || v.id === undefined) return null;
const id = String(v.id);
const name = v.file_name ?? `file-${id}`;
const href = `/api/staff/file?id=${encodeURIComponent(id)}&key=${encodeURIComponent(authKey)}`;
const ext = (name.split(".").pop() ?? "").toLowerCase();
// SVG omitted on purpose — the proxy forces SVG to download (XSS hardening),
// so an inline <img> here would just show a broken thumbnail.
const isImage = ["png", "jpg", "jpeg", "gif", "webp"].includes(ext);
const isPdf = ext === "pdf";
return (
<li className="flex items-start gap-3">
{isImage ? (
<a href={href} target="_blank" rel="noopener noreferrer">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={href}
alt={name}
className="h-20 w-20 rounded border border-rule object-cover"
<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}
/>
</a>
) : null}
<div className="flex-1">
<a
href={href}
download={name}
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
>
{name}
</a>
{entry.date ? (
<p className="text-xs text-ink-soft">submitted {formatLongDate(entry.date)}</p>
) : null}
{isPdf ? <PdfPreviewButton href={href} name={name} /> : null}
</div>
</li>
) : (
"—"
)}
</td>
);
}
function PdfPreviewButton({ href, name }: { href: string; name: string }) {
const [open, setOpen] = useState(false);
return (
<>
<button
type="button"
onClick={() => setOpen(true)}
className="mt-1 text-xs uppercase tracking-[0.12em] text-leaf-700 underline-offset-4 hover:underline"
>
Preview
</button>
{open ? (
<div
role="dialog"
aria-modal="true"
aria-label={`Preview of ${name}`}
className="fixed inset-0 z-50 flex items-center justify-center bg-ink/60 p-4"
onClick={() => setOpen(false)}
>
<div
className="relative max-h-[90vh] w-full max-w-4xl overflow-hidden rounded-lg bg-paper shadow-2xl"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setOpen(false)}
className="absolute right-3 top-3 z-10 rounded bg-paper px-2 py-1 text-xs uppercase tracking-[0.12em] text-ink shadow"
>
Close
</button>
<iframe src={href} title={name} className="h-[85vh] w-full" />
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
) : null}
</>
);
}
@@ -328,18 +509,18 @@ function ActivityTable({
}) {
if (activities.length === 0) {
return (
<section className="space-y-3">
<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[75] ?? [];
const stageOptions = options[STAGE_OPTION_GROUP_ID] ?? [];
const stageLabel = (v: string | null | undefined) =>
v ? stageOptions.find((o) => o.value === v)?.label ?? v : "—";
return (
<section className="space-y-3">
<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">
@@ -369,15 +550,6 @@ function ActivityTable({
);
}
/**
* The shared DateTimeline expects `sections: StageSectionConfig[]` and
* `fieldHistory: Record<string, FieldHistoryEntry[]>`. Build that shape
* from the staff payload so the timeline strip works untouched.
*
* We synthesise a single section per CiviCRM custom group with its
* dated fields, and key the fieldHistory by field name (DateTimeline only
* looks at the date values, not the field meta).
*/
function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
const fieldHistory: Record<string, FieldHistoryEntry[]> = {};
const fieldConfigs: FieldConfig[] = [];
@@ -394,16 +566,13 @@ function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
if (fieldConfigs.length === 0) return null;
// DateTimeline groups its dots by `section.rank`. Use rank 0 for
// everything since the staff view doesn't surface stage progression.
const sections: StageSectionConfig[] = [
{
rank: 0,
id: "all",
label: "All dated events",
fields: fieldConfigs,
},
{ 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;