Report: extract FieldHistory primitives to components/report/
This commit is contained in:
+11
-219
@@ -11,6 +11,17 @@ import type {
|
|||||||
} from "@/types/form";
|
} from "@/types/form";
|
||||||
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
import { STAGE_OPTION_GROUP_ID } from "@/config/form";
|
||||||
import { StageIcon } from "./StageIcon";
|
import { StageIcon } from "./StageIcon";
|
||||||
|
import {
|
||||||
|
FieldHistoryGroup,
|
||||||
|
FieldHistoryRow,
|
||||||
|
FormattedValue,
|
||||||
|
formatShortDate,
|
||||||
|
formatLongDate,
|
||||||
|
computeDateRange,
|
||||||
|
Chevron,
|
||||||
|
currencyFmt,
|
||||||
|
numberFmt,
|
||||||
|
} from "./report/FieldHistory";
|
||||||
|
|
||||||
interface ReportViewProps {
|
interface ReportViewProps {
|
||||||
config: FormConfig;
|
config: FormConfig;
|
||||||
@@ -1083,38 +1094,6 @@ function ReportSection({
|
|||||||
* and separated by the same divide-y as the standalone rows. Eats only
|
* and separated by the same divide-y as the standalone rows. Eats only
|
||||||
* ~14px of horizontal space (vs ~40px for a fully-boxed treatment).
|
* ~14px of horizontal space (vs ~40px for a fully-boxed treatment).
|
||||||
*/
|
*/
|
||||||
function FieldHistoryGroup({
|
|
||||||
label,
|
|
||||||
fields,
|
|
||||||
history,
|
|
||||||
options,
|
|
||||||
}: {
|
|
||||||
label?: string;
|
|
||||||
fields: FieldConfig[];
|
|
||||||
history: Record<string, FieldHistoryEntry[]>;
|
|
||||||
options: Record<number, SelectOption[]>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="border-l-2 border-leaf-300/60">
|
|
||||||
{label && (
|
|
||||||
<p className="px-5 pt-3 pb-1 pl-7 text-[10px] uppercase tracking-[0.1em] font-medium text-ink-soft sm:px-7 sm:pl-9">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="divide-y divide-rule-soft">
|
|
||||||
{fields.map((f) => (
|
|
||||||
<FieldHistoryRow
|
|
||||||
key={f.name}
|
|
||||||
field={f}
|
|
||||||
entries={history[f.name] ?? []}
|
|
||||||
options={options}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
|
function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
|
<span aria-hidden className="relative inline-flex h-12 w-12 flex-shrink-0 items-center justify-center">
|
||||||
@@ -1138,193 +1117,6 @@ function StageRankMark({ rank, isCurrent }: { rank: number; isCurrent: boolean }
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Chevron({ open }: { open: boolean }) {
|
|
||||||
return (
|
|
||||||
<svg
|
|
||||||
aria-hidden
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="1.5"
|
|
||||||
strokeLinecap="round"
|
|
||||||
className={"h-5 w-5 flex-shrink-0 text-ink-mute transition-transform duration-300 " + (open ? "rotate-180" : "")}
|
|
||||||
>
|
|
||||||
<path d="M5 8 L10 13 L15 8" />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function FieldHistoryRow({
|
|
||||||
field,
|
|
||||||
entries,
|
|
||||||
options,
|
|
||||||
}: {
|
|
||||||
field: FieldConfig;
|
|
||||||
entries: FieldHistoryEntry[];
|
|
||||||
options: Record<number, SelectOption[]>;
|
|
||||||
}) {
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const latest = entries[0];
|
|
||||||
const priorEntries = entries.slice(1);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="px-5 py-4 sm:px-7">
|
|
||||||
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-6">
|
|
||||||
<div className="min-w-0 sm:max-w-[16rem]">
|
|
||||||
<p className="text-sm font-medium text-ink">{field.label}</p>
|
|
||||||
{field.help && (
|
|
||||||
<p className="mt-0.5 text-xs leading-relaxed text-ink-mute">{field.help}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0 text-left sm:text-right">
|
|
||||||
<p className="font-display text-lg font-medium leading-snug text-leaf-800 tabular-nums">
|
|
||||||
<FormattedValue value={latest.value} field={field} options={options} />
|
|
||||||
</p>
|
|
||||||
<p className="mt-0.5 text-[11px] uppercase tracking-[0.1em] text-ink-mute">
|
|
||||||
as of {formatShortDate(latest.date)}
|
|
||||||
{priorEntries.length > 0 && (
|
|
||||||
<>
|
|
||||||
{" · "}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpanded((v) => !v)}
|
|
||||||
aria-expanded={expanded}
|
|
||||||
className="font-medium normal-case tracking-normal text-leaf-700 hover:text-leaf-800 hover:underline focus:outline-none focus-visible:underline"
|
|
||||||
>
|
|
||||||
{expanded ? "Hide" : `${priorEntries.length} earlier ${priorEntries.length === 1 ? "entry" : "entries"}`}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && priorEntries.length > 0 && (
|
|
||||||
<ol className="mt-3 space-y-1.5 border-l-2 border-rule-soft pl-4 sm:ml-auto sm:max-w-[24rem]">
|
|
||||||
{priorEntries.map((e) => (
|
|
||||||
<li
|
|
||||||
key={e.activityId}
|
|
||||||
className="flex items-baseline justify-between gap-4 text-sm"
|
|
||||||
>
|
|
||||||
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
|
||||||
{formatShortDate(e.date)}
|
|
||||||
</span>
|
|
||||||
<span className="text-right text-ink-soft tabular-nums">
|
|
||||||
<FormattedValue value={e.value} field={field} options={options} />
|
|
||||||
</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ol>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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<number, SelectOption[]>;
|
|
||||||
}) {
|
|
||||||
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<string, unknown>;
|
|
||||||
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({
|
function RailMarker({
|
||||||
rank,
|
rank,
|
||||||
state,
|
state,
|
||||||
|
|||||||
@@ -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<number, SelectOption[]>;
|
||||||
|
}) {
|
||||||
|
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<string, unknown>;
|
||||||
|
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 (
|
||||||
|
<svg
|
||||||
|
aria-hidden
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
className={"h-5 w-5 flex-shrink-0 text-ink-mute transition-transform duration-300 " + (open ? "rotate-180" : "")}
|
||||||
|
>
|
||||||
|
<path d="M5 8 L10 13 L15 8" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldHistoryRow({
|
||||||
|
field,
|
||||||
|
entries,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
field: FieldConfig;
|
||||||
|
entries: FieldHistoryEntry[];
|
||||||
|
options: Record<number, SelectOption[]>;
|
||||||
|
}) {
|
||||||
|
const [expanded, setExpanded] = useState(false);
|
||||||
|
const latest = entries[0];
|
||||||
|
const priorEntries = entries.slice(1);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-5 py-4 sm:px-7">
|
||||||
|
<div className="flex flex-col gap-1.5 sm:flex-row sm:items-baseline sm:justify-between sm:gap-6">
|
||||||
|
<div className="min-w-0 sm:max-w-[16rem]">
|
||||||
|
<p className="text-sm font-medium text-ink">{field.label}</p>
|
||||||
|
{field.help && (
|
||||||
|
<p className="mt-0.5 text-xs leading-relaxed text-ink-mute">{field.help}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 text-left sm:text-right">
|
||||||
|
<p className="font-display text-lg font-medium leading-snug text-leaf-800 tabular-nums">
|
||||||
|
<FormattedValue value={latest.value} field={field} options={options} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-[11px] uppercase tracking-[0.1em] text-ink-mute">
|
||||||
|
as of {formatShortDate(latest.date)}
|
||||||
|
{priorEntries.length > 0 && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
aria-expanded={expanded}
|
||||||
|
className="font-medium normal-case tracking-normal text-leaf-700 hover:text-leaf-800 hover:underline focus:outline-none focus-visible:underline"
|
||||||
|
>
|
||||||
|
{expanded ? "Hide" : `${priorEntries.length} earlier ${priorEntries.length === 1 ? "entry" : "entries"}`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && priorEntries.length > 0 && (
|
||||||
|
<ol className="mt-3 space-y-1.5 border-l-2 border-rule-soft pl-4 sm:ml-auto sm:max-w-[24rem]">
|
||||||
|
{priorEntries.map((e) => (
|
||||||
|
<li
|
||||||
|
key={e.activityId}
|
||||||
|
className="flex items-baseline justify-between gap-4 text-sm"
|
||||||
|
>
|
||||||
|
<span className="text-[11px] uppercase tracking-[0.1em] text-ink-mute tabular-nums">
|
||||||
|
{formatShortDate(e.date)}
|
||||||
|
</span>
|
||||||
|
<span className="text-right text-ink-soft tabular-nums">
|
||||||
|
<FormattedValue value={e.value} field={field} options={options} />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FieldHistoryGroup({
|
||||||
|
label,
|
||||||
|
fields,
|
||||||
|
history,
|
||||||
|
options,
|
||||||
|
}: {
|
||||||
|
label?: string;
|
||||||
|
fields: FieldConfig[];
|
||||||
|
history: Record<string, FieldHistoryEntry[]>;
|
||||||
|
options: Record<number, SelectOption[]>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="border-l-2 border-leaf-300/60">
|
||||||
|
{label && (
|
||||||
|
<p className="px-5 pt-3 pb-1 pl-7 text-[10px] uppercase tracking-[0.1em] font-medium text-ink-soft sm:px-7 sm:pl-9">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="divide-y divide-rule-soft">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<FieldHistoryRow
|
||||||
|
key={f.name}
|
||||||
|
field={f}
|
||||||
|
entries={history[f.name] ?? []}
|
||||||
|
options={options}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { currencyFmt, numberFmt };
|
||||||
Reference in New Issue
Block a user