226 lines
7.1 KiB
TypeScript
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 };
|