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:
+325
-156
@@ -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}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
{empty.map((f) => (
|
||||
<li
|
||||
key={f.descriptor.name}
|
||||
className="flex items-center justify-between px-4 py-3 text-sm text-ink-soft"
|
||||
{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>{f.descriptor.label}</span>
|
||||
<span>—</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user