"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"; 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 = { 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({ 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 ; if (load.kind === "error") return ; 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 (
{sectionsToRender.length === 0 ? ( ) : (
    {sectionsToRender.map((entry, i) => { const { section, pathwayState, fieldsWithHistory } = entry; const nextState = i < sectionsToRender.length - 1 ? sectionsToRender[i + 1].pathwayState : undefined; return (
  1. 0} state={pathwayState} />
  2. ); })}
)}
); } 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 (

Report for

{orgName}

Current stage

{currentStageLabel || "—"}

); } function Stat({ label, value }: { label: string; value: string }) { return (
{label}
{value}
); } function StageProgress({ currentRank }: { currentRank: number }) { return (
{[0, 1, 2, 3, 4, 5].map((r) => ( ))}
); } function ReportSection({ section, fields, history, options, isCurrent, defaultOpen, }: { section: StageSectionConfig; fields: FieldConfig[]; history: Record; options: Record; 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 (

); } function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) { return ( {rank} ); } function Chevron({ open }: { open: boolean }) { return ( ); } function FieldHistoryRow({ field, entries, options, }: { field: FieldConfig; entries: FieldHistoryEntry[]; options: Record; }) { const [expanded, setExpanded] = useState(false); const latest = entries[0]; const priorEntries = entries.slice(1); return (

{field.label}

{field.help && (

{field.help}

)}

as of {formatShortDate(latest.date)} {priorEntries.length > 0 && ( <> {" · "} )}

{expanded && priorEntries.length > 0 && (
    {priorEntries.map((e) => (
  1. {formatShortDate(e.date)}
  2. ))}
)}
); } const currencyFmt = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 2, }); const numberFmt = new Intl.NumberFormat("en-US"); function FormattedValue({ value, field, options, }: { value: unknown; field: FieldConfig; options: Record; }) { if (value === null || value === undefined || value === "") return <>—; const opts: SelectOption[] | undefined = field.optionGroupId ? options[field.optionGroupId] : field.options; switch (field.type) { case "currency": { const n = typeof value === "number" ? value : Number(value); return <>{Number.isFinite(n) ? currencyFmt.format(n) : String(value)}; } case "percent": { const n = typeof value === "number" ? value : Number(value); return <>{Number.isFinite(n) ? `${n}%` : String(value)}; } case "number": { const n = typeof value === "number" ? value : Number(value); return <>{Number.isFinite(n) ? numberFmt.format(n) : String(value)}; } case "date": return <>{formatLongDate(String(value))}; case "boolean": return <>{value ? "Yes" : "No"}; case "select": case "readonly": { const v = String(value); const found = opts?.find((o) => o.value === v); return <>{found?.label ?? v}; } case "multiselect": { let parts: string[]; if (Array.isArray(value)) { parts = value.map(String); } else { parts = String(value).split(/[|,]/).map((s) => s.trim()).filter(Boolean); } const labels = parts.map((p) => opts?.find((o) => o.value === p)?.label ?? p); return <>{labels.join(", ")}; } case "file": return <>{String(value)}; case "textarea": case "text": case "email": case "phone": default: return <>{String(value)}; } } function formatShortDate(iso: string): string { if (!iso) return "—"; const d = new Date(iso); if (Number.isNaN(d.getTime())) return iso; return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" }); } function formatLongDate(iso: string): string { if (!iso) return "—"; const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso); const d = m ? new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])) : new Date(iso); if (Number.isNaN(d.getTime())) return iso; return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }); } function computeDateRange(dates: string[]): { from: string; to: string } | null { if (dates.length === 0) return null; const times = dates .map((d) => new Date(d).getTime()) .filter((t) => Number.isFinite(t)); if (times.length === 0) return null; const min = new Date(Math.min(...times)).toISOString(); const max = new Date(Math.max(...times)).toISOString(); return { from: min, to: max }; } function RailMarker({ rank, state, nextState, }: { rank: number; state: PathwayState; nextState?: PathwayState; }) { return (
{nextState && ( )}
); } function MarkerCircle({ rank, state }: { rank: number; state: PathwayState }) { if (state === "past") { return ( Stage {rank} (past) ); } if (state === "current") { return ( {rank} Stage {rank} (current) ); } return ( {rank} Stage {rank} (no entries) ); } function MobileStem({ show, state }: { show: boolean; state: PathwayState }) { if (!show) return null; return (
); } function LoadingState() { return (

Loading your activity report…

); } function EmptyState() { return (

No entries on file yet.

Your co-op's first check-in will appear here once it's submitted.

); } function ErrorState({ message }: { message: string }) { return (

We couldn't open your report.

{message}

If this keeps happening, please contact your engagement coordinator.

); }