408 lines
13 KiB
TypeScript
408 lines
13 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 {
|
||
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} />;
|
||
}
|