Files
WebForm-mw/components/report/FieldHistory.tsx
T

226 lines
7.1 KiB
TypeScript

"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 };