Each stage-transition activity now becomes a horizontal range on its lane: start = the activity's date, end = the next activity with a higher stage rank, or extending to today if still in effect. Milestone date-field dots overlay on top of the ranges. Activity dots at each range start carry a tooltip with the activity subject. Added computeStageRanges in lib/stageRank.ts and switched DateTimeline to take activities directly (deriving both the rank resolver and the ranges internally). Wide year-spanning timelines now show their full extent even when no milestone dates have been entered.
772 lines
26 KiB
TypeScript
772 lines
26 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 {
|
||
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<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]);
|
||
|
||
// 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 <LoadingState />;
|
||
if (load.kind === "error") return <ErrorState message={load.message} />;
|
||
const { data } = load;
|
||
if (data.sections.length === 0 && data.activities.length === 0) return <EmptyState />;
|
||
|
||
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 (
|
||
<article className="space-y-10">
|
||
<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>
|
||
{(submitterName || submitterEmail) && (
|
||
<p className="text-sm text-ink-soft">
|
||
<span className="text-[11px] uppercase tracking-[0.16em] text-ink-mute">
|
||
Most recent submitter
|
||
</span>{" "}
|
||
<span className="text-ink">{submitterName ?? "—"}</span>
|
||
{submitterEmail && (
|
||
<>
|
||
{" · "}
|
||
<a
|
||
href={`mailto:${submitterEmail}`}
|
||
className="text-leaf-700 underline decoration-rule underline-offset-4 hover:decoration-leaf-700"
|
||
>
|
||
{submitterEmail}
|
||
</a>
|
||
</>
|
||
)}
|
||
</p>
|
||
)}
|
||
<div className="h-px bg-rule" />
|
||
</header>
|
||
|
||
{/* 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 && (
|
||
<SectionAnchorNav
|
||
sections={data.sections}
|
||
hasActivities={data.activities.length > 0}
|
||
framed={framed}
|
||
/>
|
||
)}
|
||
|
||
{membersField && membersField.history.length > 0 ? (
|
||
<MembershipChart
|
||
membersHistory={membersField.history}
|
||
goalHistory={goalField?.history}
|
||
/>
|
||
) : null}
|
||
|
||
<StaffDateTimeline data={data} />
|
||
|
||
{data.sections.map((section) => (
|
||
<StaffSection
|
||
key={section.groupName}
|
||
section={section}
|
||
options={data.options}
|
||
civiBaseUrl={civiBaseUrl}
|
||
/>
|
||
))}
|
||
|
||
<ActivityTable activities={data.activities} options={data.options} />
|
||
</article>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<nav
|
||
aria-label="Section navigation"
|
||
className={`${stickyCls} -mx-4 border-y border-rule bg-paper/95 px-4 py-2 sm:-mx-6 sm:px-6`}
|
||
>
|
||
<ul className="flex flex-wrap items-center gap-x-3 gap-y-1 text-[12px]">
|
||
{items.map((it) => (
|
||
<li key={it.href}>
|
||
<a
|
||
href={it.href}
|
||
className="inline-block rounded-full border border-rule bg-paper px-2.5 py-0.5 text-ink-soft hover:border-leaf-700 hover:text-leaf-800"
|
||
>
|
||
{it.label}
|
||
</a>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</nav>
|
||
);
|
||
}
|
||
|
||
function StaffSection({
|
||
section,
|
||
options,
|
||
civiBaseUrl,
|
||
}: {
|
||
section: StaffReportSection;
|
||
options: Record<number, SelectOption[]>;
|
||
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<string>([
|
||
...(quarterlyMatrix?.usedNames ?? []),
|
||
...(monthlyMatrix?.usedNames ?? []),
|
||
]);
|
||
const filledOutsideMatrix =
|
||
matrixUsedNames.size > 0
|
||
? filled.filter((f) => !matrixUsedNames.has(f.descriptor.name))
|
||
: filled;
|
||
|
||
return (
|
||
<section
|
||
id={`section-${section.groupName}`}
|
||
aria-labelledby={`heading-${section.groupName}`}
|
||
className="space-y-3 scroll-mt-16"
|
||
>
|
||
<h2
|
||
id={`heading-${section.groupName}`}
|
||
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>
|
||
|
||
{quarterlyMatrix ? (
|
||
<Y1MatrixTable data={quarterlyMatrix} options={options} />
|
||
) : null}
|
||
{monthlyMatrix ? (
|
||
<Y1MatrixTable data={monthlyMatrix} options={options} />
|
||
) : null}
|
||
|
||
{filledOutsideMatrix.length > 0 ? (
|
||
<ul className="divide-y divide-rule rounded-md border border-rule bg-paper">
|
||
{filledOutsideMatrix.map((f) => (
|
||
<CompactFieldRow
|
||
key={f.descriptor.name}
|
||
field={f}
|
||
options={options}
|
||
civiBaseUrl={civiBaseUrl}
|
||
/>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
|
||
{empty.length > 0 ? (
|
||
<div className="rounded-md border border-rule bg-paper">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowEmpty((v) => !v)}
|
||
className="flex w-full items-center justify-between px-3 py-1.5 text-[12px] uppercase tracking-[0.14em] text-ink-soft hover:text-ink"
|
||
>
|
||
<span>{empty.length} empty field{empty.length === 1 ? "" : "s"}</span>
|
||
<span aria-hidden>{showEmpty ? "▾" : "▸"}</span>
|
||
</button>
|
||
{showEmpty ? (
|
||
<ul className="divide-y divide-rule border-t border-rule">
|
||
{empty.map((f) => (
|
||
<li
|
||
key={f.descriptor.name}
|
||
className="flex items-baseline justify-between px-3 py-1 text-[13px] text-ink-soft"
|
||
>
|
||
<span>{f.descriptor.label}</span>
|
||
<span>—</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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<number, SelectOption[]>;
|
||
civiBaseUrl: string;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const latest = field.history[0];
|
||
const earlier = field.history.slice(1);
|
||
|
||
return (
|
||
<li className="px-3 py-3">
|
||
<div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
|
||
<span className="text-[13px] font-medium text-ink">{field.descriptor.label}</span>
|
||
<div className="flex flex-col items-end gap-0.5 text-right">
|
||
<span className="font-display text-xl font-medium leading-snug text-leaf-800 tabular-nums">
|
||
<FieldValue
|
||
field={field}
|
||
entry={latest}
|
||
options={options}
|
||
civiBaseUrl={civiBaseUrl}
|
||
/>
|
||
</span>
|
||
{latest.date ? (
|
||
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||
as of {formatShortDate(latest.date)}
|
||
</span>
|
||
) : null}
|
||
{earlier.length > 0 ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
aria-expanded={open}
|
||
className="text-[10px] font-medium text-leaf-700 hover:text-leaf-800 hover:underline focus:outline-none focus-visible:underline"
|
||
>
|
||
{open
|
||
? "Hide earlier entries"
|
||
: `${earlier.length} earlier ${earlier.length === 1 ? "entry" : "entries"}`}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
{open && earlier.length > 0 ? (
|
||
<ol className="mt-2 ml-auto max-w-[24rem] space-y-1 border-l border-rule pl-3">
|
||
{earlier.map((e, i) => (
|
||
<li
|
||
key={`${e.activityId}-${e.date}-${i}`}
|
||
className="flex items-baseline justify-between gap-x-4"
|
||
>
|
||
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||
{formatShortDate(e.date)}
|
||
</span>
|
||
<span className="font-display text-base font-medium text-ink-soft tabular-nums">
|
||
<FieldValue
|
||
field={field}
|
||
entry={e}
|
||
options={options}
|
||
civiBaseUrl={civiBaseUrl}
|
||
/>
|
||
</span>
|
||
</li>
|
||
))}
|
||
</ol>
|
||
) : null}
|
||
</li>
|
||
);
|
||
}
|
||
|
||
function FieldValue({
|
||
field,
|
||
entry,
|
||
options,
|
||
civiBaseUrl,
|
||
}: {
|
||
field: StaffReportField;
|
||
entry: FieldHistoryEntry;
|
||
options: Record<number, SelectOption[]>;
|
||
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 <span>—</span>;
|
||
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 (
|
||
<a
|
||
href={href}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-ink underline decoration-rule underline-offset-4 hover:decoration-ink"
|
||
>
|
||
{name}
|
||
</a>
|
||
);
|
||
}
|
||
return (
|
||
<FormattedValue value={entry.value} field={fieldConfigFor(field)} options={options} />
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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_<P><n>_<metric> 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_<P><n>_ prefix)
|
||
byPeriod: Map<number, StaffReportField>;
|
||
}
|
||
interface Y1MatrixData {
|
||
rows: Y1MatrixRow[];
|
||
periods: number[];
|
||
periodLetter: "Q" | "M";
|
||
usedNames: Set<string>;
|
||
}
|
||
/**
|
||
* 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<n>". Hardcoded here so the staff
|
||
* report can fold these into the monthly Y1 matrix.
|
||
*/
|
||
const Y1_MONTHLY_SALES_TARGET_FIELDS: Record<number, string> = {
|
||
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<string>();
|
||
const byMetric = new Map<string, Map<number, StaffReportField>>();
|
||
const periodsSet = new Set<number>();
|
||
const metricLabel = new Map<string, string>();
|
||
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<n> " / "Y1 M<n> " 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<n>_<metric> regex above. Only present in the monthly matrix.
|
||
if (periodLetter === "M") {
|
||
const byMonth = new Map<number, StaffReportField>();
|
||
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<number, SelectOption[]>;
|
||
}) {
|
||
const { rows, periods, periodLetter } = data;
|
||
const cadence = periodLetter === "Q" ? "quarterly" : "monthly";
|
||
return (
|
||
<div className="overflow-x-auto rounded-md border border-rule bg-paper">
|
||
<table className="min-w-full text-sm">
|
||
<caption className="px-3 pt-2 text-left text-[11px] uppercase tracking-[0.16em] text-ink-soft">
|
||
Year 1 {cadence} · latest values
|
||
</caption>
|
||
<thead className="text-left text-[11px] uppercase tracking-[0.14em] text-ink-soft">
|
||
<tr>
|
||
<th className="px-3 py-2 font-medium">Metric</th>
|
||
{periods.map((p) => (
|
||
<th key={p} className="px-3 py-2 font-medium">{periodLetter}{p}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-rule">
|
||
{rows.map((row) => (
|
||
<tr key={row.metric}>
|
||
<td className="px-3 py-2 text-[13px] text-ink">{row.label}</td>
|
||
{periods.map((p) => {
|
||
const f = row.byPeriod.get(p);
|
||
const latest = f?.history[0];
|
||
return (
|
||
<td key={p} className="px-3 py-2 text-[13px] text-ink-soft tabular-nums">
|
||
{f && latest ? (
|
||
<FormattedValue
|
||
value={latest.value}
|
||
field={fieldConfigFor(f)}
|
||
options={options}
|
||
/>
|
||
) : (
|
||
"—"
|
||
)}
|
||
</td>
|
||
);
|
||
})}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ActivityTable({
|
||
activities,
|
||
options,
|
||
}: {
|
||
activities: StaffReportPayload["activities"];
|
||
options: Record<number, SelectOption[]>;
|
||
}) {
|
||
if (activities.length === 0) {
|
||
return (
|
||
<section id="section-submissions" className="space-y-3 scroll-mt-16">
|
||
<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[STAGE_OPTION_GROUP_ID] ?? [];
|
||
const stageLabel = (v: string | null | undefined) =>
|
||
v ? stageOptions.find((o) => o.value === v)?.label ?? v : "—";
|
||
|
||
return (
|
||
<section id="section-submissions" className="space-y-3 scroll-mt-16">
|
||
<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>
|
||
);
|
||
}
|
||
|
||
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;
|
||
|
||
const sections: StageSectionConfig[] = [
|
||
{ rank: 0, id: "all", label: "All dated events", fields: fieldConfigs },
|
||
];
|
||
|
||
return (
|
||
<DateTimeline
|
||
sections={sections}
|
||
fieldHistory={fieldHistory}
|
||
activities={data.activities}
|
||
/>
|
||
);
|
||
}
|
||
|
||
// formatLongDate retained for symmetry with other report views; unused here
|
||
// now that file rows are compact links.
|
||
void formatLongDate;
|