diff --git a/components/StaffReportView.tsx b/components/StaffReportView.tsx new file mode 100644 index 0000000..e87efd4 --- /dev/null +++ b/components/StaffReportView.tsx @@ -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({ 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 ; + if (load.kind === "error") return ; + const { data } = load; + if (data.sections.length === 0 && data.activities.length === 0) return ; + + // 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 ( +
+
+

+ Staff report · Internal use only +

+

+ {data.orgName} +

+
+ + + + {data.orgId}} /> +
+
+
+ + {membersField && membersField.history.length > 0 ? ( + + ) : null} + + {/* Build a synthetic "sections + fieldHistory" view the DateTimeline understands. */} + + + {data.sections.map((section) => ( + + ))} + + +
+ ); +} + +function historyOnly(f: StaffReportField): FieldHistoryEntry[] { + return f.history; +} + +function Stat({ label, value }: { label: string; value: React.ReactNode }) { + return ( +
+
{label}
+
{value}
+
+ ); +} + +function StaffSection({ + section, + options, + authKey, +}: { + section: StaffReportSection; + options: Record; + authKey: string; +}) { + const filled = section.fields.filter((f) => f.history.length > 0); + const empty = section.fields.filter((f) => f.history.length === 0); + + return ( +
+

{section.groupTitle}

+

+ {section.fields.length} field{section.fields.length === 1 ? "" : "s"} ·{" "} + {filled.length} with data +

+ +
    + {filled.map((f) => ( +
  • + {f.descriptor.render === "file" ? ( + + ) : ( + + )} +
  • + ))} + {empty.map((f) => ( +
  • + {f.descriptor.label} + +
  • + ))} +
+
+ ); +} + +/** + * 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 ( +
+

{field.descriptor.label}

+
    + {field.history.map((entry) => ( + + ))} +
+
+ ); +} + +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 ( +
  • + {isImage ? ( + + {/* eslint-disable-next-line @next/next/no-img-element */} + {name} + + ) : null} +
    + + {name} + + {entry.date ? ( +

    submitted {formatLongDate(entry.date)}

    + ) : null} + {isPdf ? : null} +
    +
  • + ); +} + +function PdfPreviewButton({ href, name }: { href: string; name: string }) { + const [open, setOpen] = useState(false); + return ( + <> + + {open ? ( +
    setOpen(false)} + > +
    e.stopPropagation()} + > + +