1225 lines
42 KiB
TypeScript
1225 lines
42 KiB
TypeScript
"use client";
|
||
|
||
import { useEffect, useId, useMemo, useState } from "react";
|
||
import type {
|
||
FieldConfig,
|
||
FieldHistoryEntry,
|
||
FormConfig,
|
||
ReportPayload,
|
||
SelectOption,
|
||
StageSectionConfig,
|
||
} from "@/types/form";
|
||
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
||
import { StageIcon } from "./StageIcon";
|
||
import {
|
||
FieldHistoryGroup,
|
||
FieldHistoryRow,
|
||
formatShortDate,
|
||
computeDateRange,
|
||
Chevron,
|
||
numberFmt,
|
||
} from "./report/FieldHistory";
|
||
|
||
interface ReportViewProps {
|
||
config: FormConfig;
|
||
cid: string;
|
||
cs: string;
|
||
}
|
||
|
||
type LoadState =
|
||
| { kind: "loading" }
|
||
| { kind: "error"; message: string }
|
||
| { kind: "ready"; data: ReportPayload };
|
||
|
||
type PathwayState = "past" | "current" | "future";
|
||
|
||
const STAGE_RANK: Record<string, number> = {
|
||
Inquiry: 0,
|
||
Organizing: 1,
|
||
Feasibility: 2,
|
||
"Business feasibility": 3,
|
||
"Store Implementation": 4,
|
||
"Stabilize newly opened co-op": 5,
|
||
};
|
||
|
||
export function ReportView({ config, cid, cs }: ReportViewProps) {
|
||
const [load, setLoad] = useState<LoadState>({ kind: "loading" });
|
||
|
||
useEffect(() => {
|
||
let cancelled = false;
|
||
async function go() {
|
||
try {
|
||
const url = new URL("/api/report", window.location.origin);
|
||
url.searchParams.set("cid", cid);
|
||
url.searchParams.set("cs", cs);
|
||
const res = await fetch(url.toString(), { cache: "no-store" });
|
||
if (!res.ok) {
|
||
const text = await res.text();
|
||
let message = `Could not load report (HTTP ${res.status}).`;
|
||
try {
|
||
const j = JSON.parse(text) as { error?: string };
|
||
if (j.error) message = j.error;
|
||
} catch { /* default */ }
|
||
if (!cancelled) setLoad({ kind: "error", message });
|
||
return;
|
||
}
|
||
const data: ReportPayload = await res.json();
|
||
if (!cancelled) setLoad({ kind: "ready", data });
|
||
} catch (e) {
|
||
if (!cancelled) {
|
||
setLoad({
|
||
kind: "error",
|
||
message: e instanceof Error ? e.message : "Unexpected error loading report.",
|
||
});
|
||
}
|
||
}
|
||
}
|
||
void go();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, [cid, cs]);
|
||
|
||
const sectionsToRender = useMemo(() => {
|
||
if (load.kind !== "ready") return [];
|
||
const { currentStage, fieldHistory } = load.data;
|
||
const currentRank = STAGE_RANK[currentStage] ?? 0;
|
||
return config.sections
|
||
.map((section) => {
|
||
const fieldsWithHistory = section.fields.filter(
|
||
(f) => fieldHistory[f.name] && fieldHistory[f.name].length > 0,
|
||
);
|
||
const pathwayState: PathwayState =
|
||
section.rank < currentRank ? "past" : section.rank === currentRank ? "current" : "future";
|
||
return { section, fieldsWithHistory, pathwayState };
|
||
})
|
||
.filter((e) => e.fieldsWithHistory.length > 0);
|
||
}, [load, config.sections]);
|
||
|
||
if (load.kind === "loading") return <LoadingState />;
|
||
if (load.kind === "error") return <ErrorState message={load.message} />;
|
||
|
||
const { data } = load;
|
||
const stageOpts = data.options?.[STAGE_OPTION_GROUP_ID] ?? [];
|
||
const currentStageLabel =
|
||
stageOpts.find((o) => o.value === data.currentStage)?.label ?? data.currentStage;
|
||
const currentRank = STAGE_RANK[data.currentStage] ?? 0;
|
||
|
||
const dateRange = computeDateRange(data.activities.map((a) => a.date));
|
||
const totalActivities = data.activities.length;
|
||
const totalFieldsTracked = Object.keys(data.fieldHistory).length;
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
<ReportContextHeader
|
||
orgName={data.orgName}
|
||
currentStageLabel={currentStageLabel}
|
||
currentRank={currentRank}
|
||
totalActivities={totalActivities}
|
||
totalFieldsTracked={totalFieldsTracked}
|
||
dateRange={dateRange}
|
||
/>
|
||
|
||
<DateTimeline sections={config.sections} fieldHistory={data.fieldHistory} />
|
||
|
||
{sectionsToRender.length === 0 ? (
|
||
<EmptyState />
|
||
) : (
|
||
<ol className="relative space-y-5 md:pl-12">
|
||
{sectionsToRender.map((entry, i) => {
|
||
const { section, pathwayState, fieldsWithHistory } = entry;
|
||
const nextState =
|
||
i < sectionsToRender.length - 1 ? sectionsToRender[i + 1].pathwayState : undefined;
|
||
return (
|
||
<li key={section.id} className="relative list-none">
|
||
<MobileStem show={i > 0} state={pathwayState} />
|
||
<RailMarker rank={section.rank} state={pathwayState} nextState={nextState} />
|
||
<ReportSection
|
||
section={section}
|
||
fields={fieldsWithHistory}
|
||
history={data.fieldHistory}
|
||
options={data.options ?? {}}
|
||
isCurrent={pathwayState === "current"}
|
||
defaultOpen={pathwayState === "current" || section.rank === 0}
|
||
/>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReportContextHeader({
|
||
orgName,
|
||
currentStageLabel,
|
||
currentRank,
|
||
totalActivities,
|
||
totalFieldsTracked,
|
||
dateRange,
|
||
}: {
|
||
orgName: string;
|
||
currentStageLabel: string;
|
||
currentRank: number;
|
||
totalActivities: number;
|
||
totalFieldsTracked: number;
|
||
dateRange: { from: string; to: string } | null;
|
||
}) {
|
||
return (
|
||
<header className="rounded-lg border border-rule bg-paper-2/40 px-6 py-5 sm:px-7 sm:py-6">
|
||
<p className="text-[11px] uppercase tracking-[0.18em] text-ink-mute">Report for</p>
|
||
<h1 className="mt-1 font-display text-3xl font-medium leading-tight tracking-tight text-ink sm:text-[34px]">
|
||
{orgName}
|
||
</h1>
|
||
<div className="mt-4 flex items-center gap-3">
|
||
<StageProgress currentRank={currentRank} />
|
||
<span className="text-[10px] uppercase tracking-[0.16em] font-medium text-ink-mute">
|
||
Current stage
|
||
</span>
|
||
</div>
|
||
<p className="mt-1.5 font-display text-xl sm:text-2xl font-medium leading-tight tracking-tight text-leaf-800">
|
||
{currentStageLabel || "—"}
|
||
</p>
|
||
<dl className="mt-5 grid grid-cols-3 gap-3 border-t border-rule-soft pt-4 text-sm sm:gap-6">
|
||
<Stat label="Surveys" value={String(totalActivities)} />
|
||
<Stat label="Fields tracked" value={String(totalFieldsTracked)} />
|
||
<Stat
|
||
label="Span"
|
||
value={dateRange ? `${formatShortDate(dateRange.from)} - ${formatShortDate(dateRange.to)}` : "—"}
|
||
/>
|
||
</dl>
|
||
</header>
|
||
);
|
||
}
|
||
|
||
function Stat({ label, value }: { label: string; value: string }) {
|
||
return (
|
||
<div>
|
||
<dt className="text-[10px] uppercase tracking-[0.14em] font-medium text-ink-mute">
|
||
{label}
|
||
</dt>
|
||
<dd className="mt-0.5 font-display text-base font-medium text-ink tabular-nums sm:text-lg">
|
||
{value}
|
||
</dd>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function StageProgress({ currentRank }: { currentRank: number }) {
|
||
return (
|
||
<div className="flex items-center gap-1.5" role="img" aria-label={`Stage ${currentRank} of 5`}>
|
||
{[0, 1, 2, 3, 4, 5].map((r) => (
|
||
<span
|
||
key={r}
|
||
className={
|
||
"h-1.5 rounded-full transition-all " +
|
||
(r < currentRank
|
||
? "w-2.5 bg-leaf-300"
|
||
: r === currentRank
|
||
? "w-6 bg-leaf-700"
|
||
: "w-2.5 bg-rule-soft")
|
||
}
|
||
/>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Date-grouped timeline strip at the top of the report. Walks every
|
||
* date-type field across all stage sections (0–5), pulls each field's
|
||
* most-recent entered date, and plots events into six horizontal swim
|
||
* lanes — one per stage. Stage 5's `Date_Opened` gets visual emphasis
|
||
* as the journey's anchor at the right end of the time axis. A faint
|
||
* "Today" tick anchors the reader if today falls within the range, and
|
||
* an adaptive month/year axis runs beneath the lanes. Each dot exposes
|
||
* a tooltip on hover or keyboard focus.
|
||
*/
|
||
function DateTimeline({
|
||
sections,
|
||
fieldHistory,
|
||
}: {
|
||
sections: StageSectionConfig[];
|
||
fieldHistory: Record<string, FieldHistoryEntry[]>;
|
||
}) {
|
||
type Event = {
|
||
rank: number;
|
||
fieldLabel: string;
|
||
date: string; // YYYY-MM-DD or full ISO
|
||
isOpened: boolean;
|
||
};
|
||
const events: Event[] = [];
|
||
for (const section of sections) {
|
||
if (section.rank < 0 || section.rank > 5) continue;
|
||
for (const f of section.fields) {
|
||
if (f.type !== "date") continue;
|
||
const history = fieldHistory[f.name];
|
||
if (!history || history.length === 0) continue;
|
||
const v = history[0].value;
|
||
if (typeof v !== "string" || v.length === 0) continue;
|
||
events.push({
|
||
rank: section.rank,
|
||
fieldLabel: f.label,
|
||
date: v,
|
||
isOpened: f.name === "Date_Opened",
|
||
});
|
||
}
|
||
}
|
||
if (events.length === 0) return null;
|
||
|
||
const times = events.map((e) => new Date(e.date).getTime()).filter(Number.isFinite);
|
||
if (times.length === 0) return null;
|
||
|
||
const openedEvent = events.find((e) => e.isOpened);
|
||
const openedTime = openedEvent ? new Date(openedEvent.date).getTime() : undefined;
|
||
const minT = Math.min(...times);
|
||
// If Date_Opened is set, anchor the right edge there; if any other event
|
||
// is later, extend so nothing falls off the chart.
|
||
const maxT = Math.max(...times, openedTime ?? -Infinity);
|
||
const tRange = maxT - minT || 1;
|
||
const todayT = Date.now();
|
||
const todayPct =
|
||
todayT >= minT && todayT <= maxT ? ((todayT - minT) / tRange) * 100 : null;
|
||
|
||
const ticks = generateAxisTicks(minT, maxT);
|
||
|
||
return (
|
||
<section
|
||
aria-labelledby="timeline-heading"
|
||
className="rounded-lg border border-rule bg-paper-2/30 px-6 py-5 sm:px-7 sm:py-6"
|
||
>
|
||
<div className="flex items-baseline justify-between gap-4">
|
||
<h2
|
||
id="timeline-heading"
|
||
className="font-display text-base font-medium leading-tight text-ink"
|
||
>
|
||
Timeline
|
||
</h2>
|
||
<p className="text-[11px] uppercase tracking-[0.12em] text-ink-mute tabular-nums">
|
||
{formatShortDate(new Date(minT).toISOString())}
|
||
{" → "}
|
||
{formatShortDate(new Date(maxT).toISOString())}
|
||
</p>
|
||
</div>
|
||
<ol className="mt-4 space-y-2">
|
||
{[0, 1, 2, 3, 4, 5].map((rank) => {
|
||
const rowEvents = events.filter((e) => e.rank === rank);
|
||
const isEmpty = rowEvents.length === 0;
|
||
return (
|
||
<li
|
||
key={rank}
|
||
className="grid grid-cols-[3.5rem_1fr] items-center gap-3 sm:grid-cols-[4.5rem_1fr]"
|
||
>
|
||
<span className="text-[10px] uppercase tracking-[0.14em] font-medium text-ink-mute">
|
||
Stage {rank}
|
||
</span>
|
||
<div className="relative h-5">
|
||
<span
|
||
aria-hidden
|
||
className={
|
||
"absolute inset-x-0 top-1/2 -translate-y-1/2 h-px " +
|
||
(isEmpty ? "bg-rule-soft/60" : "bg-rule-soft")
|
||
}
|
||
/>
|
||
{todayPct !== null && (
|
||
<span
|
||
aria-hidden
|
||
style={{ left: `${todayPct}%` }}
|
||
className="absolute top-0 bottom-0 w-px -translate-x-1/2 border-l border-dashed border-clay-300/70"
|
||
/>
|
||
)}
|
||
{rowEvents.map((e, i) => {
|
||
const t = new Date(e.date).getTime();
|
||
if (!Number.isFinite(t)) return null;
|
||
const x = ((t - minT) / tRange) * 100;
|
||
return (
|
||
<TimelineDot
|
||
key={i}
|
||
rank={rank}
|
||
event={e}
|
||
x={x}
|
||
/>
|
||
);
|
||
})}
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ol>
|
||
|
||
{/* Adaptive month/year axis. Render only when we have ≥2 ticks so a
|
||
single tick doesn't dangle. Today gets its own labeled mark when
|
||
it falls within range. */}
|
||
{ticks.length >= 2 && (
|
||
<div className="mt-3 grid grid-cols-[3.5rem_1fr] items-start gap-3 sm:grid-cols-[4.5rem_1fr]">
|
||
<span aria-hidden />
|
||
<div className="relative h-7">
|
||
<span
|
||
aria-hidden
|
||
className="absolute inset-x-0 top-0 h-px bg-rule-soft"
|
||
/>
|
||
{ticks.map((tk, i) => {
|
||
const x = ((tk.t - minT) / tRange) * 100;
|
||
// Edge labels shift so they don't overflow the row.
|
||
const alignClass =
|
||
x < 6
|
||
? "left-0 origin-top-left"
|
||
: x > 94
|
||
? "right-0 origin-top-right text-right"
|
||
: "left-1/2 -translate-x-1/2 text-center";
|
||
return (
|
||
<span
|
||
key={i}
|
||
style={{ left: `${x}%` }}
|
||
className="absolute top-0 -translate-x-1/2"
|
||
>
|
||
<span
|
||
aria-hidden
|
||
className="block h-1.5 w-px bg-rule mx-auto"
|
||
/>
|
||
<span
|
||
className={
|
||
"absolute top-2 block whitespace-nowrap text-[9px] uppercase tracking-[0.08em] tabular-nums text-ink-mute " +
|
||
alignClass
|
||
}
|
||
>
|
||
{tk.label}
|
||
</span>
|
||
</span>
|
||
);
|
||
})}
|
||
{todayPct !== null && (
|
||
<span
|
||
style={{ left: `${todayPct}%` }}
|
||
className="absolute top-0 -translate-x-1/2"
|
||
>
|
||
<span
|
||
aria-hidden
|
||
className="block h-1.5 w-px bg-clay-500 mx-auto"
|
||
/>
|
||
<span className="absolute top-2 left-1/2 -translate-x-1/2 block whitespace-nowrap text-[9px] font-medium uppercase tracking-[0.08em] text-clay-700">
|
||
Today
|
||
</span>
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)}
|
||
{/* Accessible event list — invisible to sighted users but readable by SR */}
|
||
<ul className="sr-only">
|
||
{events.map((e, i) => (
|
||
<li key={i}>
|
||
Stage {e.rank}: {e.fieldLabel} — {formatShortDate(e.date)}
|
||
{e.isOpened ? " (opened)" : ""}
|
||
</li>
|
||
))}
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function stageDotBg(rank: number): string {
|
||
switch (rank) {
|
||
case 0:
|
||
return "bg-leaf-200";
|
||
case 1:
|
||
return "bg-leaf-300";
|
||
case 2:
|
||
return "bg-leaf-500";
|
||
case 3:
|
||
return "bg-leaf-600";
|
||
case 4:
|
||
return "bg-leaf-700";
|
||
case 5:
|
||
return "bg-clay-700";
|
||
default:
|
||
return "bg-leaf-600";
|
||
}
|
||
}
|
||
|
||
/**
|
||
* A single dot on the timeline plus its hover/focus tooltip. Keyboard
|
||
* users can Tab to each dot and the tooltip will appear via
|
||
* `group-focus-within`. The tooltip is anchored above the dot; for dots
|
||
* near the left or right edge of the row, anchor flips so the card
|
||
* doesn't overflow.
|
||
*/
|
||
function TimelineDot({
|
||
rank,
|
||
event,
|
||
x,
|
||
}: {
|
||
rank: number;
|
||
event: { fieldLabel: string; date: string; isOpened: boolean };
|
||
x: number;
|
||
}) {
|
||
const isOpened = event.isOpened;
|
||
const bg = isOpened ? "bg-clay-700" : stageDotBg(rank);
|
||
const size = isOpened ? "h-3.5 w-3.5" : "h-2.5 w-2.5";
|
||
const ring = isOpened ? "ring-2" : "ring-1";
|
||
const tipAlign =
|
||
x < 18
|
||
? "left-0"
|
||
: x > 82
|
||
? "right-0"
|
||
: "left-1/2 -translate-x-1/2";
|
||
return (
|
||
<span
|
||
role="img"
|
||
tabIndex={0}
|
||
aria-label={`${event.fieldLabel}, ${formatShortDate(event.date)}${isOpened ? " (opened)" : ""}, stage ${rank}`}
|
||
style={{ left: `${x}%` }}
|
||
className={
|
||
"group/dot absolute top-1/2 -translate-x-1/2 -translate-y-1/2 rounded-full shadow-sm focus:outline-none focus-visible:ring-2 focus-visible:ring-leaf-500/40 " +
|
||
`${ring} ring-paper ${size} ${bg}`
|
||
}
|
||
>
|
||
<span
|
||
role="tooltip"
|
||
className={
|
||
"pointer-events-none absolute bottom-full z-30 mb-2 hidden whitespace-nowrap rounded-md bg-ink/95 px-2.5 py-1.5 text-[11px] leading-snug text-paper shadow-lg group-hover/dot:block group-focus-within/dot:block " +
|
||
tipAlign
|
||
}
|
||
>
|
||
<span className="block font-medium">{event.fieldLabel}</span>
|
||
<span className="mt-0.5 block tabular-nums text-paper-2/80">
|
||
{formatShortDate(event.date)}
|
||
<span className="ml-2 uppercase tracking-[0.08em] text-paper-2/60">
|
||
Stage {rank}
|
||
</span>
|
||
</span>
|
||
{isOpened && (
|
||
<span className="mt-0.5 block text-clay-200 uppercase tracking-[0.08em]">
|
||
Opened
|
||
</span>
|
||
)}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Pick a "nice" tick interval for the time axis based on the visible
|
||
* span, then walk that interval from min to max producing labeled ticks.
|
||
* Uses months for shorter spans, years for longer ones; January-bordered
|
||
* month ticks include the year so the reader has an anchor.
|
||
*/
|
||
function generateAxisTicks(minT: number, maxT: number): Array<{ t: number; label: string }> {
|
||
const range = maxT - minT;
|
||
const monthMs = 30.4375 * 24 * 3600 * 1000;
|
||
const months = range / monthMs;
|
||
|
||
type Step = { unit: "month" | "year"; step: number };
|
||
let plan: Step;
|
||
if (months < 4) plan = { unit: "month", step: 1 };
|
||
else if (months < 12) plan = { unit: "month", step: 2 };
|
||
else if (months < 24) plan = { unit: "month", step: 3 };
|
||
else if (months < 48) plan = { unit: "month", step: 6 };
|
||
else if (months / 12 < 12) plan = { unit: "year", step: 1 };
|
||
else plan = { unit: "year", step: 2 };
|
||
|
||
const ticks: Array<{ t: number; label: string }> = [];
|
||
const start = new Date(minT);
|
||
let y = start.getFullYear();
|
||
let m =
|
||
plan.unit === "year"
|
||
? 0
|
||
: Math.floor(start.getMonth() / plan.step) * plan.step;
|
||
// Don't skip a tick that sits right at minT — start from minT-aligned step.
|
||
while (true) {
|
||
const t = new Date(y, m, 1).getTime();
|
||
if (t > maxT) break;
|
||
if (t >= minT) {
|
||
const d = new Date(y, m, 1);
|
||
const label =
|
||
plan.unit === "year"
|
||
? String(y)
|
||
: m === 0
|
||
? d.toLocaleDateString("en-US", { month: "short", year: "numeric" })
|
||
: d.toLocaleDateString("en-US", { month: "short" });
|
||
ticks.push({ t, label });
|
||
}
|
||
if (plan.unit === "year") {
|
||
y += plan.step;
|
||
} else {
|
||
m += plan.step;
|
||
while (m > 11) {
|
||
m -= 12;
|
||
y += 1;
|
||
}
|
||
}
|
||
}
|
||
return ticks;
|
||
}
|
||
|
||
const MEMBERS_ACTUAL_NAME = "Members__current_";
|
||
const MEMBERS_GOAL_NAME = "Member_Goal_for_current_Stage";
|
||
|
||
/**
|
||
* Dedicated comparison chart for the org's actual member-owner count vs the
|
||
* goal it set for the current stage. Renders when at least one of the
|
||
* two fields has historical data. Both series are drawn as step lines
|
||
* (carry-forward semantics — a measurement holds until the next one
|
||
* updates it, then extends to the right edge). Header carries a
|
||
* current/goal summary with the gap; legend sits below the chart.
|
||
*/
|
||
function MembershipChart({
|
||
membersField,
|
||
goalField,
|
||
membersHistory,
|
||
goalHistory,
|
||
}: {
|
||
membersField?: FieldConfig;
|
||
goalField?: FieldConfig;
|
||
membersHistory?: FieldHistoryEntry[];
|
||
goalHistory?: FieldHistoryEntry[];
|
||
}) {
|
||
const toPoints = (entries: FieldHistoryEntry[] | undefined) =>
|
||
(entries ?? [])
|
||
.slice()
|
||
.reverse()
|
||
.map((e) => ({
|
||
t: new Date(e.date).getTime(),
|
||
v: typeof e.value === "number" ? e.value : Number(e.value),
|
||
}))
|
||
.filter((p) => Number.isFinite(p.t) && Number.isFinite(p.v));
|
||
|
||
const actualPoints = toPoints(membersHistory);
|
||
const goalPoints = toPoints(goalHistory);
|
||
if (actualPoints.length === 0 && goalPoints.length === 0) return null;
|
||
|
||
// Combined axes
|
||
const all = [...actualPoints, ...goalPoints];
|
||
const times = all.map((p) => p.t);
|
||
const values = all.map((p) => p.v);
|
||
let minT = Math.min(...times);
|
||
let maxT = Math.max(...times);
|
||
if (minT === maxT) {
|
||
// Single-point chart — pad the axis ±15 days so the dot isn't on
|
||
// top of the y-axis line.
|
||
const pad = 15 * 24 * 3600 * 1000;
|
||
minT -= pad;
|
||
maxT += pad;
|
||
}
|
||
// Always include 0 in y so growth from a small starting count reads true.
|
||
const rawMin = Math.min(...values, 0);
|
||
const rawMax = Math.max(...values);
|
||
const yTicks = niceYTicks(rawMin, rawMax, 4);
|
||
const yMin = yTicks[0];
|
||
const yMax = yTicks[yTicks.length - 1];
|
||
const yRange = yMax - yMin || 1;
|
||
const tRange = maxT - minT || 1;
|
||
|
||
// Chart geometry (viewBox units).
|
||
const W = 580;
|
||
const H = 200;
|
||
const padL = 40;
|
||
const padR = 28;
|
||
const padT = 14;
|
||
const padB = 30;
|
||
const plotW = W - padL - padR;
|
||
const plotH = H - padT - padB;
|
||
|
||
const xOf = (t: number) => padL + ((t - minT) / tRange) * plotW;
|
||
const yOf = (v: number) => padT + plotH - ((v - yMin) / yRange) * plotH;
|
||
|
||
// Both Actual and Goal use a carry-forward step line: between measurements
|
||
// the chart holds the prior value rather than interpolating diagonally,
|
||
// and the final value extends flat to the right edge of the chart. This
|
||
// way periods with no fresh measurement read as "unchanged since last
|
||
// reported" instead of suggesting a smooth dip or rise that we don't
|
||
// actually have evidence for.
|
||
const actualCoords = actualPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
|
||
let actualPath = "";
|
||
if (actualCoords.length === 1) {
|
||
const c = actualCoords[0];
|
||
actualPath = `M${c.x.toFixed(2)},${c.y.toFixed(2)} L${(W - padR).toFixed(2)},${c.y.toFixed(2)}`;
|
||
} else if (actualCoords.length > 1) {
|
||
const parts: string[] = [
|
||
`M${actualCoords[0].x.toFixed(2)},${actualCoords[0].y.toFixed(2)}`,
|
||
];
|
||
for (let i = 1; i < actualCoords.length; i++) {
|
||
parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i - 1].y.toFixed(2)}`);
|
||
parts.push(`L${actualCoords[i].x.toFixed(2)},${actualCoords[i].y.toFixed(2)}`);
|
||
}
|
||
const last = actualCoords[actualCoords.length - 1];
|
||
parts.push(`L${(W - padR).toFixed(2)},${last.y.toFixed(2)}`);
|
||
actualPath = parts.join(" ");
|
||
}
|
||
const actualArea = actualPath
|
||
? `${actualPath} L${(W - padR).toFixed(2)},${yOf(yMin).toFixed(2)} L${actualCoords[0].x.toFixed(2)},${yOf(yMin).toFixed(2)} Z`
|
||
: "";
|
||
|
||
// Step the goal: hold each value until the next change, then extend
|
||
// the final value to the right edge of the chart.
|
||
const goalCoords = goalPoints.map((p) => ({ x: xOf(p.t), y: yOf(p.v), v: p.v }));
|
||
let goalPath = "";
|
||
if (goalCoords.length === 1) {
|
||
const c = goalCoords[0];
|
||
goalPath = `M${xOf(minT).toFixed(2)},${c.y.toFixed(2)} L${xOf(maxT).toFixed(2)},${c.y.toFixed(2)}`;
|
||
} else if (goalCoords.length > 1) {
|
||
const parts: string[] = [];
|
||
parts.push(`M${goalCoords[0].x.toFixed(2)},${goalCoords[0].y.toFixed(2)}`);
|
||
for (let i = 1; i < goalCoords.length; i++) {
|
||
// step: horizontal to next x at previous y, then vertical to new y
|
||
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i - 1].y.toFixed(2)}`);
|
||
parts.push(`L${goalCoords[i].x.toFixed(2)},${goalCoords[i].y.toFixed(2)}`);
|
||
}
|
||
// Extend the most-recent goal to the right edge as a flat line.
|
||
const lastG = goalCoords[goalCoords.length - 1];
|
||
parts.push(`L${xOf(maxT).toFixed(2)},${lastG.y.toFixed(2)}`);
|
||
goalPath = parts.join(" ");
|
||
}
|
||
|
||
// Summary stat: take most-recent of each series for the gap callout.
|
||
const latestActual = actualPoints.length ? actualPoints[actualPoints.length - 1].v : null;
|
||
const latestGoal = goalPoints.length ? goalPoints[goalPoints.length - 1].v : null;
|
||
const gap =
|
||
latestActual !== null && latestGoal !== null ? latestGoal - latestActual : null;
|
||
const gapText =
|
||
gap === null
|
||
? null
|
||
: gap > 0
|
||
? `${numberFmt.format(gap)} to go`
|
||
: gap < 0
|
||
? `${numberFmt.format(-gap)} above goal`
|
||
: "at goal";
|
||
const gapTone =
|
||
gap === null
|
||
? ""
|
||
: gap > 0
|
||
? "text-clay-700"
|
||
: gap < 0
|
||
? "text-leaf-700"
|
||
: "text-leaf-700";
|
||
|
||
const xTicks = generateAxisTicks(minT, maxT);
|
||
const todayT = Date.now();
|
||
const todayInRange = todayT >= minT && todayT <= maxT;
|
||
|
||
return (
|
||
<section
|
||
aria-labelledby="membership-chart-heading"
|
||
className="rounded-lg border border-rule bg-paper-2/30 px-6 py-5 sm:px-7 sm:py-6"
|
||
>
|
||
<div className="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
|
||
<div>
|
||
<h2
|
||
id="membership-chart-heading"
|
||
className="font-display text-base font-medium leading-tight text-ink"
|
||
>
|
||
Membership — goal vs. actual
|
||
</h2>
|
||
<p className="mt-0.5 text-xs text-ink-mute">
|
||
Member-owner count tracked over time against the goal set for the org's
|
||
current stage.
|
||
</p>
|
||
</div>
|
||
{latestActual !== null && (
|
||
<p className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||
<span className="font-medium text-leaf-800">
|
||
{numberFmt.format(latestActual)}
|
||
</span>
|
||
{latestGoal !== null && (
|
||
<>
|
||
{" of "}
|
||
<span className="font-medium text-clay-700">
|
||
{numberFmt.format(latestGoal)}
|
||
</span>
|
||
{gapText && (
|
||
<span className={"ml-1.5 normal-case tracking-normal " + gapTone}>
|
||
· {gapText}
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
</p>
|
||
)}
|
||
</div>
|
||
|
||
<svg
|
||
viewBox={`0 0 ${W} ${H}`}
|
||
preserveAspectRatio="none"
|
||
role="img"
|
||
aria-label="Membership goal versus actual over time"
|
||
className="mt-4 block h-48 w-full sm:h-52"
|
||
>
|
||
{/* Y-axis gridlines + value labels */}
|
||
{yTicks.map((v, i) => {
|
||
const y = yOf(v);
|
||
return (
|
||
<g key={i}>
|
||
<line
|
||
x1={padL}
|
||
x2={W - padR}
|
||
y1={y}
|
||
y2={y}
|
||
className="stroke-rule-soft"
|
||
strokeWidth="0.5"
|
||
/>
|
||
<text
|
||
x={padL - 6}
|
||
y={y}
|
||
textAnchor="end"
|
||
dominantBaseline="middle"
|
||
className="fill-ink-mute text-[9px] tabular-nums"
|
||
>
|
||
{numberFmt.format(v)}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
|
||
{/* Today guide */}
|
||
{todayInRange && (
|
||
<line
|
||
x1={xOf(todayT)}
|
||
x2={xOf(todayT)}
|
||
y1={padT}
|
||
y2={padT + plotH}
|
||
className="stroke-clay-300"
|
||
strokeWidth="0.75"
|
||
strokeDasharray="2 3"
|
||
/>
|
||
)}
|
||
|
||
{/* Goal step line */}
|
||
{goalPath && (
|
||
<path
|
||
d={goalPath}
|
||
fill="none"
|
||
className="stroke-clay-600"
|
||
strokeWidth="1.4"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="miter"
|
||
strokeDasharray="5 3"
|
||
/>
|
||
)}
|
||
{goalCoords.map((c, i) => (
|
||
<circle
|
||
key={`g-${i}`}
|
||
cx={c.x}
|
||
cy={c.y}
|
||
r="2.5"
|
||
className="fill-clay-600"
|
||
/>
|
||
))}
|
||
|
||
{/* Actual line with faint area fill */}
|
||
{actualArea && (
|
||
<path d={actualArea} className="fill-leaf-500" opacity="0.10" />
|
||
)}
|
||
{actualPath && (
|
||
<path
|
||
d={actualPath}
|
||
fill="none"
|
||
className="stroke-leaf-700"
|
||
strokeWidth="1.75"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
)}
|
||
{actualCoords.map((c, i) => (
|
||
<circle
|
||
key={`a-${i}`}
|
||
cx={c.x}
|
||
cy={c.y}
|
||
r={i === actualCoords.length - 1 ? 3.5 : 2}
|
||
className={i === actualCoords.length - 1 ? "fill-leaf-800" : "fill-leaf-700"}
|
||
/>
|
||
))}
|
||
|
||
{/* Most-recent point value labels */}
|
||
{actualCoords.length > 0 && (
|
||
<text
|
||
x={actualCoords[actualCoords.length - 1].x + 6}
|
||
y={actualCoords[actualCoords.length - 1].y - 6}
|
||
className="fill-leaf-800 text-[10px] font-medium tabular-nums"
|
||
>
|
||
{numberFmt.format(actualCoords[actualCoords.length - 1].v)}
|
||
</text>
|
||
)}
|
||
{goalCoords.length > 0 && (
|
||
<text
|
||
x={xOf(maxT) - 4}
|
||
y={goalCoords[goalCoords.length - 1].y - 6}
|
||
textAnchor="end"
|
||
className="fill-clay-700 text-[10px] font-medium tabular-nums"
|
||
>
|
||
{numberFmt.format(goalCoords[goalCoords.length - 1].v)}
|
||
</text>
|
||
)}
|
||
|
||
{/* X-axis baseline + ticks */}
|
||
<line
|
||
x1={padL}
|
||
x2={W - padR}
|
||
y1={padT + plotH}
|
||
y2={padT + plotH}
|
||
className="stroke-rule"
|
||
strokeWidth="0.75"
|
||
/>
|
||
{xTicks.map((tk, i) => {
|
||
const x = xOf(tk.t);
|
||
return (
|
||
<g key={i}>
|
||
<line
|
||
x1={x}
|
||
x2={x}
|
||
y1={padT + plotH}
|
||
y2={padT + plotH + 3}
|
||
className="stroke-rule"
|
||
strokeWidth="0.75"
|
||
/>
|
||
<text
|
||
x={x}
|
||
y={padT + plotH + 14}
|
||
textAnchor="middle"
|
||
className="fill-ink-mute text-[9px] uppercase tracking-[0.08em] tabular-nums"
|
||
>
|
||
{tk.label}
|
||
</text>
|
||
</g>
|
||
);
|
||
})}
|
||
</svg>
|
||
|
||
{/* Legend */}
|
||
<ul className="mt-2 flex flex-wrap items-center gap-x-5 gap-y-1.5 text-[11px] text-ink-soft">
|
||
<li className="inline-flex items-center gap-2">
|
||
<span aria-hidden className="block h-px w-6 bg-leaf-700">
|
||
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-leaf-700" />
|
||
</span>
|
||
<span>
|
||
Actual{membersField?.label && membersField.label !== "Member-Owners (current)" ? ` (${membersField.label})` : ""}
|
||
</span>
|
||
</li>
|
||
<li className="inline-flex items-center gap-2">
|
||
<span aria-hidden className="block h-px w-6 border-t border-dashed border-clay-600">
|
||
<span className="relative -top-[3px] left-1/2 -translate-x-1/2 inline-block h-1.5 w-1.5 rounded-full bg-clay-600" />
|
||
</span>
|
||
<span>
|
||
Goal{goalField?.label && goalField.label !== "Member-Owner Goal for current Stage" ? ` (${goalField.label})` : ""}
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Choose 3–5 round-number tick values that span [min, max]. Step is snapped
|
||
* to 1 / 2 / 2.5 / 5 / 10 × 10^N so labels read as Y-axis values normally do.
|
||
*/
|
||
function niceYTicks(min: number, max: number, target = 4): number[] {
|
||
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0];
|
||
if (min === max) return [min - 1, min, min + 1];
|
||
const range = max - min;
|
||
const rawStep = range / target;
|
||
const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));
|
||
const normalized = rawStep / magnitude;
|
||
let step: number;
|
||
if (normalized < 1.5) step = 1 * magnitude;
|
||
else if (normalized < 3) step = 2 * magnitude;
|
||
else if (normalized < 4) step = 2.5 * magnitude;
|
||
else if (normalized < 7) step = 5 * magnitude;
|
||
else step = 10 * magnitude;
|
||
const niceMin = Math.floor(min / step) * step;
|
||
const niceMax = Math.ceil(max / step) * step;
|
||
const ticks: number[] = [];
|
||
for (let v = niceMin; v <= niceMax + step * 0.0001; v += step) {
|
||
ticks.push(Math.round(v * 1e6) / 1e6); // de-jitter float arithmetic
|
||
}
|
||
return ticks;
|
||
}
|
||
|
||
function ReportSection({
|
||
section,
|
||
fields,
|
||
history,
|
||
options,
|
||
isCurrent,
|
||
defaultOpen,
|
||
}: {
|
||
section: StageSectionConfig;
|
||
fields: FieldConfig[];
|
||
history: Record<string, FieldHistoryEntry[]>;
|
||
options: Record<number, SelectOption[]>;
|
||
isCurrent: boolean;
|
||
defaultOpen: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(defaultOpen);
|
||
const headingId = useId();
|
||
const panelId = useId();
|
||
|
||
const cardClass = isCurrent
|
||
? "border-2 border-leaf-600 shadow-[0_1px_0_0_rgba(0,0,0,0.04),0_8px_24px_-12px_rgba(60,80,40,0.18)] bg-white/95"
|
||
: "border border-rule bg-white/95 shadow-sm";
|
||
|
||
return (
|
||
<section
|
||
aria-labelledby={headingId}
|
||
className={"overflow-hidden rounded-lg transition " + cardClass}
|
||
>
|
||
<h2 id={headingId} className="m-0">
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((v) => !v)}
|
||
aria-expanded={open}
|
||
aria-controls={panelId}
|
||
className={
|
||
"group relative flex w-full items-center gap-4 px-5 py-4 text-left transition-colors sm:px-6 " +
|
||
(isCurrent ? "bg-leaf-50/60" : "hover:bg-paper-2/60")
|
||
}
|
||
>
|
||
<StageRankMark rank={section.rank} isCurrent={isCurrent} />
|
||
<span className="flex-1 min-w-0">
|
||
<span className="block font-display text-lg font-medium leading-tight tracking-tight text-ink sm:text-xl">
|
||
{section.label}
|
||
</span>
|
||
<span className="mt-1 flex items-center gap-3 text-xs text-ink-mute">
|
||
{isCurrent && (
|
||
<span className="inline-flex items-center gap-1.5 rounded-full border border-clay-200 bg-clay-100/70 px-2 py-0.5 font-medium text-clay-700 uppercase tracking-[0.08em]">
|
||
<span className="h-1.5 w-1.5 rounded-full bg-clay-600" aria-hidden />
|
||
Current stage
|
||
</span>
|
||
)}
|
||
<span className="tabular-nums">
|
||
{fields.length} {fields.length === 1 ? "field" : "fields"} with entries
|
||
</span>
|
||
</span>
|
||
</span>
|
||
<Chevron open={open} />
|
||
</button>
|
||
</h2>
|
||
|
||
<div
|
||
id={panelId}
|
||
role="region"
|
||
aria-labelledby={headingId}
|
||
hidden={!open}
|
||
className="border-t border-rule-soft"
|
||
>
|
||
<div className="divide-y divide-rule-soft">
|
||
{(() => {
|
||
// Compute the slot for the inline Membership chart: render it
|
||
// immediately after whichever of (Members current, Member Goal)
|
||
// appears last among this section's fields-with-history. If
|
||
// neither field is in this section, the index is -1 and the
|
||
// chart is skipped — naturally scoping the chart to whichever
|
||
// section those questions live in (Stage 0 today).
|
||
const memberIdx = fields.findIndex((f) => f.name === MEMBERS_ACTUAL_NAME);
|
||
const goalIdx = fields.findIndex((f) => f.name === MEMBERS_GOAL_NAME);
|
||
const chartIdx = Math.max(memberIdx, goalIdx);
|
||
const membersFieldInSection = memberIdx >= 0 ? fields[memberIdx] : undefined;
|
||
const goalFieldInSection = goalIdx >= 0 ? fields[goalIdx] : undefined;
|
||
|
||
// Build a map from field name → its group config (if any). A
|
||
// field referenced in multiple groups belongs to the first one.
|
||
const groupByField = new Map<string, { id: string; label?: string }>();
|
||
for (const g of section.fieldGroups ?? []) {
|
||
for (const fname of g.fields) {
|
||
if (!groupByField.has(fname)) {
|
||
groupByField.set(fname, { id: g.id, label: g.label });
|
||
}
|
||
}
|
||
}
|
||
|
||
// Walk fields in declared order; whenever we hit one that
|
||
// belongs to a group we haven't emitted yet, collect every
|
||
// visible field from that group (those with history) and emit
|
||
// them as one bordered cluster. Other group members get
|
||
// skipped when we encounter them later in the loop.
|
||
const fieldsWithHistorySet = new Set(fields.map((f) => f.name));
|
||
const emittedGroups = new Set<string>();
|
||
const out: React.ReactNode[] = [];
|
||
|
||
fields.forEach((f, i) => {
|
||
const grp = groupByField.get(f.name);
|
||
if (grp && !emittedGroups.has(grp.id)) {
|
||
emittedGroups.add(grp.id);
|
||
const sectionGroup = (section.fieldGroups ?? []).find((g) => g.id === grp.id)!;
|
||
const groupedFields = sectionGroup.fields
|
||
.map((name) => fields.find((ff) => ff.name === name))
|
||
.filter((ff): ff is FieldConfig => !!ff && fieldsWithHistorySet.has(ff.name));
|
||
if (groupedFields.length === 0) return;
|
||
out.push(
|
||
<FieldHistoryGroup
|
||
key={`group-${grp.id}`}
|
||
label={grp.label}
|
||
fields={groupedFields}
|
||
history={history}
|
||
options={options}
|
||
/>,
|
||
);
|
||
} else if (!grp) {
|
||
out.push(
|
||
<FieldHistoryRow
|
||
key={f.name}
|
||
field={f}
|
||
entries={history[f.name] ?? []}
|
||
options={options}
|
||
/>,
|
||
);
|
||
}
|
||
if (i === chartIdx && chartIdx >= 0) {
|
||
out.push(
|
||
<div key={`chart-${f.name}`} className="bg-paper-2/20 px-5 py-5 sm:px-7 sm:py-6">
|
||
<MembershipChart
|
||
membersField={membersFieldInSection}
|
||
goalField={goalFieldInSection}
|
||
membersHistory={history[MEMBERS_ACTUAL_NAME]}
|
||
goalHistory={history[MEMBERS_GOAL_NAME]}
|
||
/>
|
||
</div>,
|
||
);
|
||
}
|
||
});
|
||
|
||
return out;
|
||
})()}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Mirrors the form's FieldGroupCard: a leaf-tinted left rule + small
|
||
* uppercase mini-label, with the grouped history rows stacked beneath
|
||
* and separated by the same divide-y as the standalone rows. Eats only
|
||
* ~14px of horizontal space (vs ~40px for a fully-boxed treatment).
|
||
*/
|
||
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
|
||
return (
|
||
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
|
||
<StageIcon
|
||
rank={rank}
|
||
className={
|
||
"absolute inset-0 h-12 w-12 transition-opacity " +
|
||
(isCurrent ? "text-leaf-700 opacity-100" : "text-leaf-600 opacity-50")
|
||
}
|
||
/>
|
||
<span
|
||
className={
|
||
"relative z-10 inline-flex h-5 w-5 items-center justify-center rounded-full text-[10px] font-semibold tabular-nums shadow-sm " +
|
||
(isCurrent ? "bg-leaf-700 text-paper" : "bg-paper text-ink-soft border border-rule")
|
||
}
|
||
style={{ marginLeft: 28, marginTop: 28 }}
|
||
>
|
||
{rank}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function RailMarker({
|
||
rank,
|
||
state,
|
||
nextState,
|
||
}: {
|
||
rank: number;
|
||
state: PathwayState;
|
||
nextState?: PathwayState;
|
||
}) {
|
||
return (
|
||
<div
|
||
aria-hidden
|
||
className="pointer-events-none hidden md:block absolute -left-9 top-0 bottom-0 w-7"
|
||
>
|
||
{nextState && (
|
||
<span
|
||
className={
|
||
"absolute left-1/2 -translate-x-1/2 top-11 -bottom-11 " +
|
||
(nextState === "future"
|
||
? "w-0 border-l border-dashed border-rule"
|
||
: "w-px bg-leaf-500/70")
|
||
}
|
||
/>
|
||
)}
|
||
<MarkerCircle rank={rank} state={state} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) {
|
||
if (state === "past") {
|
||
return (
|
||
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-5 w-5 items-center justify-center rounded-full bg-leaf-700 text-paper shadow-sm">
|
||
<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round" className="h-2.5 w-2.5">
|
||
<path d="M3 6.5l2 2 4-5" />
|
||
</svg>
|
||
<span className="sr-only">Stage {rank} (past)</span>
|
||
</span>
|
||
);
|
||
}
|
||
if (state === "current") {
|
||
return (
|
||
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-6 w-6 items-center justify-center rounded-full bg-leaf-700 text-paper text-[10px] font-semibold shadow-md ring-2 ring-leaf-100">
|
||
{rank}
|
||
<span className="sr-only">Stage {rank} (current)</span>
|
||
</span>
|
||
);
|
||
}
|
||
return (
|
||
<span className="absolute left-1/2 -translate-x-1/2 top-6 inline-flex h-5 w-5 items-center justify-center rounded-full border border-dashed border-rule bg-paper text-ink-mute">
|
||
<span className="text-[9px] font-medium tabular-nums">{rank}</span>
|
||
<span className="sr-only">Stage {rank} (no entries)</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function MobileStem({ show, state }: { show: boolean; state: PathwayState }) {
|
||
if (!show) return null;
|
||
return (
|
||
<div aria-hidden className="md:hidden -mt-4 mb-1.5 flex justify-center">
|
||
<span
|
||
className={
|
||
"block h-4 " +
|
||
(state === "future"
|
||
? "w-0 border-l border-dashed border-rule"
|
||
: "w-px bg-leaf-500/70")
|
||
}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LoadingState() {
|
||
return (
|
||
<div className="rounded-lg border border-rule bg-paper px-6 py-12 text-center">
|
||
<div
|
||
aria-hidden
|
||
className="mx-auto mb-4 h-7 w-7 animate-spin rounded-full border-[1.5px] border-rule border-t-leaf-700"
|
||
/>
|
||
<p className="font-display text-base text-ink-soft italic">Loading your activity report…</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function EmptyState() {
|
||
return (
|
||
<div className="rounded-lg border border-dashed border-rule bg-paper-2/30 px-6 py-10 text-center">
|
||
<p className="font-display text-lg text-ink-soft">No entries on file yet.</p>
|
||
<p className="mt-2 text-sm text-ink-mute">
|
||
Your co-op's first survey will appear here once it's submitted.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ErrorState({ message }: { message: string }) {
|
||
return (
|
||
<div role="alert" className="rounded-lg border-2 border-clay-200 bg-clay-100/30 px-6 py-7">
|
||
<h2 className="font-display text-xl font-medium text-clay-700">
|
||
We couldn't open your report.
|
||
</h2>
|
||
<p className="mt-3 text-sm leading-relaxed text-ink-soft">{message}</p>
|
||
<p className="mt-4 text-sm text-ink-soft">
|
||
If this keeps happening, please contact your <a href="mailto:chris@fci.coop">Chris @ FCI</a>.
|
||
</p>
|
||
</div>
|
||
);
|
||
}
|