diff --git a/components/ReportView.tsx b/components/ReportView.tsx index 63c3127..47bee74 100644 --- a/components/ReportView.tsx +++ b/components/ReportView.tsx @@ -11,6 +11,17 @@ import type { } from "@/types/form"; import { STAGE_OPTION_GROUP_ID } from "@/config/form"; import { StageIcon } from "./StageIcon"; +import { + FieldHistoryGroup, + FieldHistoryRow, + FormattedValue, + formatShortDate, + formatLongDate, + computeDateRange, + Chevron, + currencyFmt, + numberFmt, +} from "./report/FieldHistory"; interface ReportViewProps { config: FormConfig; @@ -1083,38 +1094,6 @@ function ReportSection({ * 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 FieldHistoryGroup({ - label, - fields, - history, - options, -}: { - label?: string; - fields: FieldConfig[]; - history: Record; - options: Record; -}) { - return ( -
- {label && ( -

- {label} -

- )} -
- {fields.map((f) => ( - - ))} -
-
- ); -} - function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) { return ( @@ -1138,193 +1117,6 @@ function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean } ); } -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": { - // Prefill / history wraps file values into { id, file_name } so the - // UI can show a human-readable name. Fall back to whatever scalar - // came through if the shape is different. - if (typeof value === "object" && value !== null) { - const o = value as Record; - const fname = o.file_name ?? o.name ?? o.label ?? o.filename; - if (typeof fname === "string" && fname.length > 0) return <>{fname}; - if (typeof o.id !== "undefined") return <>Attachment #{String(o.id)}; - } - 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, diff --git a/components/report/FieldHistory.tsx b/components/report/FieldHistory.tsx new file mode 100644 index 0000000..51b0402 --- /dev/null +++ b/components/report/FieldHistory.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useState } from "react"; +import type { FieldConfig, FieldHistoryEntry, SelectOption } from "@/types/form"; + +const currencyFmt = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, +}); + +const numberFmt = new Intl.NumberFormat("en-US"); + +export 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": { + // Prefill / history wraps file values into { id, file_name } so the + // UI can show a human-readable name. Fall back to whatever scalar + // came through if the shape is different. + if (typeof value === "object" && value !== null) { + const o = value as Record; + const fname = o.file_name ?? o.name ?? o.label ?? o.filename; + if (typeof fname === "string" && fname.length > 0) return <>{fname}; + if (typeof o.id !== "undefined") return <>Attachment #{String(o.id)}; + } + return <>{String(value)}; + } + case "textarea": + case "text": + case "email": + case "phone": + default: + return <>{String(value)}; + } +} + +export 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" }); +} + +export 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" }); +} + +export 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 }; +} + +export function Chevron({ open }: { open: boolean }) { + return ( + + + + ); +} + +export 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. + ))} +
+ )} +
+ ); +} + +export function FieldHistoryGroup({ + label, + fields, + history, + options, +}: { + label?: string; + fields: FieldConfig[]; + history: Record; + options: Record; +}) { + return ( +
+ {label && ( +

+ {label} +

+ )} +
+ {fields.map((f) => ( + + ))} +
+
+ ); +} + +export { currencyFmt, numberFmt };