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.
579 lines
19 KiB
TypeScript
579 lines
19 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;
|
||
}
|
||
|
||
type LoadState =
|
||
| { kind: "loading" }
|
||
| { kind: "error"; message: string }
|
||
| { kind: "ready"; data: StaffReportPayload };
|
||
|
||
const STAGE_OPTION_GROUP_ID = 75;
|
||
|
||
export function StaffReportView({ org, authKey, civiBaseUrl }: 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]);
|
||
|
||
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} />
|
||
|
||
{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>
|
||
);
|
||
}
|
||
|
||
/** 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,
|
||
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;
|