Files
WebForm-mw/components/StaffReportView.tsx
T

408 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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} />;
}