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 orgId = Number(org);
const orgValid = !!org && Number.isFinite(orgId) && orgId > 0; 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 ( 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"> <div className="mx-auto max-w-5xl px-4 py-10 sm:px-6 sm:py-14">
{orgValid ? ( {orgValid ? (
<Suspense fallback={null}> <Suspense fallback={null}>
<StaffReportView org={orgId} authKey={key!} /> <StaffReportView org={orgId} authKey={key!} civiBaseUrl={civiBaseUrl} />
</Suspense> </Suspense>
) : ( ) : (
<MissingOrg /> <MissingOrg />
+325 -156
View File
@@ -13,7 +13,7 @@ import type {
import { MembershipChart, MEMBERS_ACTUAL_NAME, MEMBERS_GOAL_NAME } from "./report/MembershipChart"; import { MembershipChart, MEMBERS_ACTUAL_NAME, MEMBERS_GOAL_NAME } from "./report/MembershipChart";
import { DateTimeline } from "./report/DateTimeline"; import { DateTimeline } from "./report/DateTimeline";
import { import {
FieldHistoryRow, FormattedValue,
formatShortDate, formatShortDate,
formatLongDate, formatLongDate,
computeDateRange, computeDateRange,
@@ -23,6 +23,8 @@ import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
interface StaffReportViewProps { interface StaffReportViewProps {
org: number; org: number;
authKey: string; authKey: string;
/** CIVI_BASE_URL, used to build outbound file links. */
civiBaseUrl: string;
} }
type LoadState = type LoadState =
@@ -30,7 +32,9 @@ type LoadState =
| { kind: "error"; message: string } | { kind: "error"; message: string }
| { kind: "ready"; data: StaffReportPayload }; | { 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" }); const [load, setLoad] = useState<LoadState>({ kind: "loading" });
useEffect(() => { useEffect(() => {
@@ -67,19 +71,18 @@ export function StaffReportView({ org, authKey }: StaffReportViewProps) {
const { data } = load; const { data } = load;
if (data.sections.length === 0 && data.activities.length === 0) return <EmptyState />; 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 checkInSection = data.sections.find((s) => s.groupName === "Check_in_data__organizing_");
const membersField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_ACTUAL_NAME); 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 goalField = checkInSection?.fields.find((f) => f.descriptor.name === MEMBERS_GOAL_NAME);
const stageLabel = const stageLabel = data.currentStage
data.currentStage ? data.options[STAGE_OPTION_GROUP_ID]?.find((o) => o.value === data.currentStage)?.label
? data.options[75]?.find((o) => o.value === data.currentStage)?.label ?? data.currentStage ?? data.currentStage
: "—"; : "—";
const dateRange = computeDateRange(data.activities.map((a) => a.date)); const dateRange = computeDateRange(data.activities.map((a) => a.date));
return ( return (
<article className="space-y-12"> <article className="space-y-10">
<header className="space-y-4"> <header className="space-y-4">
<p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700"> <p className="text-[11px] uppercase tracking-[0.18em] text-leaf-700">
Staff report · Internal use only Staff report · Internal use only
@@ -103,18 +106,24 @@ export function StaffReportView({ org, authKey }: StaffReportViewProps) {
<div className="h-px bg-rule" /> <div className="h-px bg-rule" />
</header> </header>
<SectionAnchorNav sections={data.sections} hasActivities={data.activities.length > 0} />
{membersField && membersField.history.length > 0 ? ( {membersField && membersField.history.length > 0 ? (
<MembershipChart <MembershipChart
membersHistory={historyOnly(membersField)} membersHistory={membersField.history}
goalHistory={goalField ? historyOnly(goalField) : undefined} goalHistory={goalField?.history}
/> />
) : null} ) : null}
{/* Build a synthetic "sections + fieldHistory" view the DateTimeline understands. */}
<StaffDateTimeline data={data} /> <StaffDateTimeline data={data} />
{data.sections.map((section) => ( {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} /> <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 }) { function Stat({ label, value }: { label: string; value: React.ReactNode }) {
return ( return (
<div> <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({ function StaffSection({
section, section,
options, options,
authKey, civiBaseUrl,
}: { }: {
section: StaffReportSection; section: StaffReportSection;
options: Record<number, SelectOption[]>; options: Record<number, SelectOption[]>;
authKey: string; civiBaseUrl: string;
}) { }) {
const filled = section.fields.filter((f) => f.history.length > 0); const filled = section.fields.filter((f) => f.history.length > 0);
const empty = 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 ( return (
<section className="space-y-4"> <section
<h2 className="font-display text-2xl font-medium text-ink">{section.groupTitle}</h2> 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"> <p className="text-[12px] uppercase tracking-[0.16em] text-ink-soft">
{section.fields.length} field{section.fields.length === 1 ? "" : "s"} ·{" "} {section.fields.length} field{section.fields.length === 1 ? "" : "s"} ·{" "}
{filled.length} with data {filled.length} with data
</p> </p>
<ul className="divide-y divide-rule rounded-md border border-rule bg-paper"> {matrixFields ? (
{filled.map((f) => ( <Y1MatrixTable
<li key={f.descriptor.name} className="px-4 py-4"> rows={matrixFields.rows}
{f.descriptor.render === "file" ? ( quarters={matrixFields.quarters}
<FileFieldRow field={f} authKey={authKey} /> options={options}
) : ( />
<FieldHistoryRow ) : null}
field={fieldConfigFor(f)}
entries={f.history} {filledOutsideMatrix.length > 0 ? (
options={options} <ul className="divide-y divide-rule rounded-md border border-rule bg-paper">
/> {filledOutsideMatrix.map((f) => (
)} <CompactFieldRow
</li> key={f.descriptor.name}
))} field={f}
{empty.map((f) => ( options={options}
<li civiBaseUrl={civiBaseUrl}
key={f.descriptor.name} />
className="flex items-center justify-between px-4 py-3 text-sm text-ink-soft" ))}
</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>{f.descriptor.label}</span> <span>{empty.length} empty field{empty.length === 1 ? "" : "s"}</span>
<span></span> <span aria-hidden>{showEmpty ? "▾" : "▸"}</span>
</li> </button>
))} {showEmpty ? (
</ul> <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> </section>
); );
} }
/** /**
* Adapt a StaffReportField to the FieldConfig shape FieldHistoryRow / FormattedValue * Compact, latest-only row. If the field has multiple history entries, a
* expect. The renderer only reads `name`, `label`, `type`, `optionGroupId`. * 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 { function fieldConfigFor(f: StaffReportField): FieldConfig {
return { return {
name: f.descriptor.name, name: f.descriptor.name,
@@ -201,7 +377,6 @@ function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldCo
case "currency": case "currency":
return "currency"; return "currency";
case "date": case "date":
return "date";
case "datetime": case "datetime":
return "date"; return "date";
case "select": case "select":
@@ -221,101 +396,107 @@ function renderToFieldType(r: StaffReportField["descriptor"]["render"]): FieldCo
} }
} }
function FileFieldRow({ field, authKey }: { field: StaffReportField; authKey: string }) { /**
return ( * Stage 5 Y1 matrix: detect fields whose names match Y1_Q<n>_<metric> and
<div> * group them into a read-only table mirroring the form's matrix layout.
<p className="font-medium text-ink">{field.descriptor.label}</p> * The metric set is whatever's actually present in the data so a Civi
<ul className="mt-2 space-y-2"> * schema addition (e.g. Y1_Q*_Labor_Hours) appears automatically.
{field.history.map((entry) => ( */
<FilePreviewItem key={`${entry.activityId}-${entry.date}`} entry={entry} authKey={authKey} /> interface Y1MatrixRow {
))} metric: string; // e.g. "Labor" | "Margin" | "Member_Sales_"
</ul> label: string; // human label from the first field's descriptor (sans Y1_Q<n>_ prefix)
</div> 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({ function Y1MatrixTable({
entry, rows,
authKey, quarters,
options,
}: { }: {
entry: FieldHistoryEntry; rows: Y1MatrixRow[];
authKey: string; 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 ( return (
<li className="flex items-start gap-3"> <div className="overflow-x-auto rounded-md border border-rule bg-paper">
{isImage ? ( <table className="min-w-full text-sm">
<a href={href} target="_blank" rel="noopener noreferrer"> <caption className="px-3 pt-2 text-left text-[11px] uppercase tracking-[0.16em] text-ink-soft">
{/* eslint-disable-next-line @next/next/no-img-element */} Year 1 quarterly · latest values
<img </caption>
src={href} <thead className="text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
alt={name} <tr>
className="h-20 w-20 rounded border border-rule object-cover" <th className="px-3 py-2 font-medium">Metric</th>
/> {quarters.map((q) => (
</a> <th key={q} className="px-3 py-2 font-medium">Q{q}</th>
) : null} ))}
<div className="flex-1"> </tr>
<a </thead>
href={href} <tbody className="divide-y divide-rule">
download={name} {rows.map((row) => (
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink" <tr key={row.metric}>
> <td className="px-3 py-2 text-[13px] text-ink">{row.label}</td>
{name} {quarters.map((q) => {
</a> const f = row.byQuarter.get(q);
{entry.date ? ( const latest = f?.history[0];
<p className="text-xs text-ink-soft">submitted {formatLongDate(entry.date)}</p> return (
) : null} <td key={q} className="px-3 py-2 text-[13px] text-ink-soft tabular-nums">
{isPdf ? <PdfPreviewButton href={href} name={name} /> : null} {f && latest ? (
</div> <FormattedValue
</li> value={latest.value}
); field={fieldConfigFor(f)}
} options={options}
/>
function PdfPreviewButton({ href, name }: { href: string; name: string }) { ) : (
const [open, setOpen] = useState(false); "—"
return ( )}
<> </td>
<button );
type="button" })}
onClick={() => setOpen(true)} </tr>
className="mt-1 text-xs uppercase tracking-[0.12em] text-leaf-700 underline-offset-4 hover:underline" ))}
> </tbody>
Preview </table>
</button> </div>
{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" />
</div>
</div>
) : null}
</>
); );
} }
@@ -328,18 +509,18 @@ function ActivityTable({
}) { }) {
if (activities.length === 0) { if (activities.length === 0) {
return ( 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> <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> <p className="text-sm text-ink-soft">No submissions recorded for this organization yet.</p>
</section> </section>
); );
} }
const stageOptions = options[75] ?? []; const stageOptions = options[STAGE_OPTION_GROUP_ID] ?? [];
const stageLabel = (v: string | null | undefined) => const stageLabel = (v: string | null | undefined) =>
v ? stageOptions.find((o) => o.value === v)?.label ?? v : "—"; v ? stageOptions.find((o) => o.value === v)?.label ?? v : "—";
return ( 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> <h2 className="font-display text-2xl font-medium text-ink">All submissions</h2>
<div className="overflow-x-auto rounded-md border border-rule"> <div className="overflow-x-auto rounded-md border border-rule">
<table className="min-w-full text-sm"> <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 }) { function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
const fieldHistory: Record<string, FieldHistoryEntry[]> = {}; const fieldHistory: Record<string, FieldHistoryEntry[]> = {};
const fieldConfigs: FieldConfig[] = []; const fieldConfigs: FieldConfig[] = [];
@@ -394,16 +566,13 @@ function StaffDateTimeline({ data }: { data: StaffReportPayload }) {
if (fieldConfigs.length === 0) return null; 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[] = [ 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} />; 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;