Staff report: StaffReportView client component

This commit is contained in:
Joel Brock
2026-06-05 16:35:15 -07:00
parent d83077ba09
commit f889212296
+407
View File
@@ -0,0 +1,407 @@
"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 {
FieldHistoryRow,
formatShortDate,
formatLongDate,
computeDateRange,
} from "./report/FieldHistory";
import { LoadingState, EmptyState, ErrorState } from "./report/ReportStates";
interface StaffReportViewProps {
org: number;
authKey: string;
}
type LoadState =
| { kind: "loading" }
| { kind: "error"; message: string }
| { kind: "ready"; data: StaffReportPayload };
export function StaffReportView({ org, authKey }: 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 />;
// 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 dateRange = computeDateRange(data.activities.map((a) => a.date));
return (
<article className="space-y-12">
<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>
{membersField && membersField.history.length > 0 ? (
<MembershipChart
membersHistory={historyOnly(membersField)}
goalHistory={goalField ? historyOnly(goalField) : undefined}
/>
) : 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} />
))}
<ActivityTable activities={data.activities} options={data.options} />
</article>
);
}
function historyOnly(f: StaffReportField): FieldHistoryEntry[] {
return f.history;
}
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>
);
}
function StaffSection({
section,
options,
authKey,
}: {
section: StaffReportSection;
options: Record<number, SelectOption[]>;
authKey: string;
}) {
const filled = section.fields.filter((f) => f.history.length > 0);
const empty = section.fields.filter((f) => f.history.length === 0);
return (
<section className="space-y-4">
<h2 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"
>
<span>{f.descriptor.label}</span>
<span></span>
</li>
))}
</ul>
</section>
);
}
/**
* Adapt a StaffReportField to the FieldConfig shape FieldHistoryRow / FormattedValue
* expect. The renderer only reads `name`, `label`, `type`, `optionGroupId`.
*/
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":
return "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";
}
}
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>
);
}
function FilePreviewItem({
entry,
authKey,
}: {
entry: FieldHistoryEntry;
authKey: string;
}) {
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();
const isImage = ["png", "jpg", "jpeg", "gif", "webp", "svg"].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}
</>
);
}
function ActivityTable({
activities,
options,
}: {
activities: StaffReportPayload["activities"];
options: Record<number, SelectOption[]>;
}) {
if (activities.length === 0) {
return (
<section className="space-y-3">
<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 stageLabel = (v: string | null | undefined) =>
v ? stageOptions.find((o) => o.value === v)?.label ?? v : "—";
return (
<section className="space-y-3">
<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>
);
}
/**
* 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[] = [];
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;
// 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,
},
];
return <DateTimeline sections={sections} fieldHistory={fieldHistory} />;
}