Report: extract FieldHistory primitives to components/report/
This commit is contained in:
@@ -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