"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; /** True when the page is being embedded in a CiviCRM tab via iframe. */ framed?: boolean; } type LoadState = | { kind: "loading" } | { kind: "error"; message: string } | { kind: "ready"; data: StaffReportPayload }; const STAGE_OPTION_GROUP_ID = 75; export function StaffReportView({ org, authKey, civiBaseUrl, framed = false, }: 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]); // When embedded, post our content height to the parent so the Civi tab's // iframe can resize to fit (no nested scrollbars). The receiving script // lives in the WebForm-mw Civi extension's tab template. // // The root layout sets `html.h-full` and `body.min-h-full`, which tie // document height to the iframe's viewport height. Combined with the // parent setting `iframe.height = postedHeight + 24` on every message, // that creates an unbounded feedback loop (viewport grows -> measured // height grows -> parent grows the iframe -> repeat). Inside the iframe // we decouple html/body from the viewport, measure `body.scrollHeight` // (the actual content), observe the body, and skip duplicate posts. useEffect(() => { if (!framed || typeof window === "undefined") return; if (window.parent === window) return; const html = document.documentElement; const body = document.body; const prevHtmlHeight = html.style.height; const prevBodyMinHeight = body.style.minHeight; html.style.height = "auto"; body.style.minHeight = "0"; let lastHeight = -1; const post = () => { const h = body.scrollHeight; if (h === lastHeight) return; lastHeight = h; window.parent.postMessage({ type: "webform-mw-height", height: h }, "*"); }; post(); const ro = new ResizeObserver(post); ro.observe(body); window.addEventListener("load", post); return () => { ro.disconnect(); window.removeEventListener("load", post); html.style.height = prevHtmlHeight; body.style.minHeight = prevBodyMinHeight; }; }, [framed, load]); if (load.kind === "loading") return ; if (load.kind === "error") return ; const { data } = load; if (data.sections.length === 0 && data.activities.length === 0) return ; 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); // Most recent Survey_completed_by / _email values from the activity history. // Activities are returned newest-first by /api/staff/report, so the first // non-empty history entry on each field is "most recent." const submitterName = pickLatestText( checkInSection?.fields.find((f) => f.descriptor.name === "Survey_completed_by"), ); const submitterEmail = pickLatestText( checkInSection?.fields.find((f) => f.descriptor.name === "Survey_completed_by_email"), ); 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 (

Staff report · Internal use only

{data.orgName}

{data.orgId}} />
{(submitterName || submitterEmail) && (

Most recent submitter {" "} {submitterName ?? "—"} {submitterEmail && ( <> {" · "} {submitterEmail} )}

)}
{/* Hide the anchor strip when framed in CiviCRM: the iframe has no internal scroll context (it auto-sizes to content), so anchor clicks change the hash but don't move the parent page. */} {!framed && ( 0} framed={framed} /> )} {membersField && membersField.history.length > 0 ? ( ) : null} {data.sections.map((section) => ( ))}
); } function Stat({ label, value }: { label: string; value: React.ReactNode }) { return (
{label}
{value}
); } /** * Horizontal anchor strip — one chip per section + Submissions. * * Sticky in standalone mode; non-sticky when embedded in a CiviCRM tab * (the iframe auto-resizes to fit content so there's no internal scroll * for `sticky` to engage against). */ function SectionAnchorNav({ sections, hasActivities, framed, }: { sections: StaffReportSection[]; hasActivities: boolean; framed: 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" }); const stickyCls = framed ? "" : "sticky top-0 z-30 backdrop-blur"; return ( ); } function StaffSection({ section, options, civiBaseUrl, }: { section: StaffReportSection; options: Record; 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 matrices: pull quarterly (Y1_Q*) and monthly (Y1_M*) field // series out into compact tabular displays that mirror the form's matrix // layout. Anything not consumed by a matrix falls through to the regular // per-field list below. const isStage5 = section.groupName === "Stage_5"; const quarterlyMatrix = isStage5 ? collectY1MatrixByPeriod(filled, "Q") : null; const monthlyMatrix = isStage5 ? collectY1MatrixByPeriod(filled, "M") : null; const matrixUsedNames = new Set([ ...(quarterlyMatrix?.usedNames ?? []), ...(monthlyMatrix?.usedNames ?? []), ]); const filledOutsideMatrix = matrixUsedNames.size > 0 ? filled.filter((f) => !matrixUsedNames.has(f.descriptor.name)) : filled; return (

{section.groupTitle}

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

{quarterlyMatrix ? ( ) : null} {monthlyMatrix ? ( ) : null} {filledOutsideMatrix.length > 0 ? (
    {filledOutsideMatrix.map((f) => ( ))}
) : null} {empty.length > 0 ? (
{showEmpty ? (
    {empty.map((f) => (
  • {f.descriptor.label}
  • ))}
) : null}
) : null}
); } /** * 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; civiBaseUrl: string; }) { const [open, setOpen] = useState(false); const latest = field.history[0]; const earlier = field.history.slice(1); return (
  • {field.descriptor.label}
    {latest.date ? ( as of {formatShortDate(latest.date)} ) : null} {earlier.length > 0 ? ( ) : null}
    {open && earlier.length > 0 ? (
      {earlier.map((e, i) => (
    1. {formatShortDate(e.date)}
    2. ))}
    ) : null}
  • ); } function FieldValue({ field, entry, options, civiBaseUrl, }: { field: StaffReportField; entry: FieldHistoryEntry; options: Record; civiBaseUrl: string; }) { if (field.descriptor.render === "file") { const v = entry.value as | { id?: number | string; file_name?: string; url?: string } | null; if (!v || v.id === undefined) return ; const id = String(v.id); const name = v.file_name ?? `file-${id}`; // Prefer the Civi-signed URL (carries the fcs JWT) returned by // Attachment.get. If absent, fall back to the WebForm-mw Civi extension's // file-redirect route — it mints the fcs server-side and 302s to the // real /civicrm/file URL. (Hitting /civicrm/file?id=X bare crashes Civi // on a null fcs JWT decode.) let href = "#"; if (v.url) { href = v.url.startsWith("http") ? v.url : `${civiBaseUrl}${v.url.startsWith("/") ? "" : "/"}${v.url}`; } else if (civiBaseUrl) { href = `${civiBaseUrl}/civicrm/webform-mw/file?id=${encodeURIComponent(id)}`; } return ( {name} ); } return ( ); } /** * Return the most recent non-empty string value from a field's history, or * undefined if the field is missing or every entry is empty. Used to surface * the latest submitter name / email at the top of the report. */ function pickLatestText(field: StaffReportField | undefined): string | undefined { if (!field) return undefined; for (const e of field.history) { if (e.value === null || e.value === undefined) continue; const s = String(e.value).trim(); if (s) return s; } return undefined; } 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_

    _ for a * given period letter (Q for quarterly, M for monthly) 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_M*_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_

    _ prefix) byPeriod: Map; } interface Y1MatrixData { rows: Y1MatrixRow[]; periods: number[]; periodLetter: "Q" | "M"; usedNames: Set; } /** * Y1 Monthly Sales Target Civi machine names — irregular. M1 dropped the * trailing period from "Y1_Monthly_Sales_Targets", M3 lives in a field named * "_M2" (Civi schema error captured in the original form mapping), and the * rest follow "Y1_Monthly_Sales_Target_M". Hardcoded here so the staff * report can fold these into the monthly Y1 matrix. */ const Y1_MONTHLY_SALES_TARGET_FIELDS: Record = { 1: "Y1_Monthly_Sales_Targets", 2: "Y1_Monthly_Sales_Targets_M2", 3: "Y1_Monthly_Sales_Target_M2", // intentional: Civi name says M2, value is M3. 4: "Y1_Monthly_Sales_Target_M4", 5: "Y1_Monthly_Sales_Target_M5", 6: "Y1_Monthly_Sales_Target_M6", 7: "Y1_Monthly_Sales_Target_M7", 8: "Y1_Monthly_Sales_Target_M8", 9: "Y1_Monthly_Sales_Target_M9", 10: "Y1_Monthly_Sales_Target_M10", 11: "Y1_Monthly_Sales_Target_M11", 12: "Y1_Monthly_Sales_Target_M12", }; function collectY1MatrixByPeriod( filled: StaffReportField[], periodLetter: "Q" | "M", ): Y1MatrixData | null { const nameRe = new RegExp(`^Y1_${periodLetter}(\\d+)_(.+)$`); const labelStripRe = new RegExp(`^Y1\\s*${periodLetter}\\d+\\s*`, "i"); const used = new Set(); const byMetric = new Map>(); const periodsSet = new Set(); const metricLabel = new Map(); const byName = new Map(filled.map((f) => [f.descriptor.name, f])); for (const f of filled) { const m = nameRe.exec(f.descriptor.name); if (!m) continue; const period = Number(m[1]); const metric = m[2]; used.add(f.descriptor.name); periodsSet.add(period); if (!byMetric.has(metric)) byMetric.set(metric, new Map()); byMetric.get(metric)!.set(period, f); if (!metricLabel.has(metric)) { // Strip the "Y1 Q " / "Y1 M " prefix from the label if present. const cleaned = f.descriptor.label .replace(labelStripRe, "") .replace(/_/g, " ") .trim(); metricLabel.set(metric, cleaned || metric.replace(/_/g, " ")); } } // Y1 Monthly Sales Target — fold in the irregular fields that don't fit // the Y1_M_ regex above. Only present in the monthly matrix. if (periodLetter === "M") { const byMonth = new Map(); for (const [periodStr, fieldName] of Object.entries(Y1_MONTHLY_SALES_TARGET_FIELDS)) { const f = byName.get(fieldName); if (!f) continue; const period = Number(periodStr); byMonth.set(period, f); used.add(fieldName); periodsSet.add(period); } if (byMonth.size > 0) { byMetric.set("Sales_Target", byMonth); metricLabel.set("Sales_Target", "Sales Target"); } } if (byMetric.size === 0) return null; const periods = Array.from(periodsSet).sort((a, b) => a - b); const rows: Y1MatrixRow[] = Array.from(byMetric.entries()).map(([metric, byPeriod]) => ({ metric, label: metricLabel.get(metric) ?? metric, byPeriod, })); return { rows, periods, periodLetter, usedNames: used }; } function Y1MatrixTable({ data, options, }: { data: Y1MatrixData; options: Record; }) { const { rows, periods, periodLetter } = data; const cadence = periodLetter === "Q" ? "quarterly" : "monthly"; return (

    {periods.map((p) => ( ))} {rows.map((row) => ( {periods.map((p) => { const f = row.byPeriod.get(p); const latest = f?.history[0]; return ( ); })} ))}
    Year 1 {cadence} · latest values
    Metric{periodLetter}{p}
    {row.label} {f && latest ? ( ) : ( "—" )}
    ); } function ActivityTable({ activities, options, }: { activities: StaffReportPayload["activities"]; options: Record; }) { if (activities.length === 0) { return (

    All submissions

    No submissions recorded for this organization yet.

    ); } const stageOptions = options[STAGE_OPTION_GROUP_ID] ?? []; const stageLabel = (v: string | null | undefined) => v ? stageOptions.find((o) => o.value === v)?.label ?? v : "—"; return (

    All submissions

    {activities.map((a) => ( ))}
    Date Stage snapshot Subject Submitted by Activity id
    {formatShortDate(a.date)} {stageLabel(a.stage ?? null)} {a.subject ?? "—"} {a.submittedBy ?? "—"} {a.id}
    ); } function StaffDateTimeline({ data }: { data: StaffReportPayload }) { const fieldHistory: Record = {}; 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 ( ); } // formatLongDate retained for symmetry with other report views; unused here // now that file rows are compact links. void formatLongDate;