8f7c69056f
BenchResourceCard, MobileProjectCard, MobileCapacityCard, DynamicFieldRenderer, BudgetStatusBar, and TimelineHeader use no hooks, event handlers, or browser APIs — they can be server components, reducing client bundle size. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
147 lines
4.5 KiB
TypeScript
147 lines
4.5 KiB
TypeScript
import { clsx } from "clsx";
|
|
import { formatDateLong } from "~/lib/format.js";
|
|
import { FieldType } from "@capakraken/shared";
|
|
import type { BlueprintFieldDefinition } from "@capakraken/shared";
|
|
|
|
interface Props {
|
|
fieldDefs: BlueprintFieldDefinition[];
|
|
values: Record<string, unknown>;
|
|
className?: string;
|
|
}
|
|
|
|
function renderValue(fieldDef: BlueprintFieldDefinition, value: unknown): React.ReactNode {
|
|
if (value === null || value === undefined || value === "") {
|
|
return <span className="text-gray-400">—</span>;
|
|
}
|
|
|
|
switch (fieldDef.type) {
|
|
case FieldType.TEXT:
|
|
case FieldType.TEXTAREA:
|
|
case FieldType.URL:
|
|
case FieldType.EMAIL:
|
|
return <span className="text-gray-900">{String(value)}</span>;
|
|
|
|
case FieldType.NUMBER:
|
|
return (
|
|
<span className="text-gray-900">
|
|
{typeof value === "number" ? value.toLocaleString() : String(value)}
|
|
</span>
|
|
);
|
|
|
|
case FieldType.BOOLEAN: {
|
|
const bool = value === true || value === "true" || value === 1;
|
|
return (
|
|
<span
|
|
className={clsx(
|
|
"inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium",
|
|
bool ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500",
|
|
)}
|
|
>
|
|
{bool ? "Yes" : "No"}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
case FieldType.DATE: {
|
|
const dateStr = String(value);
|
|
const parsed = new Date(dateStr);
|
|
if (isNaN(parsed.getTime())) {
|
|
return <span className="text-gray-900">{dateStr}</span>;
|
|
}
|
|
return <span className="text-gray-900">{formatDateLong(parsed)}</span>;
|
|
}
|
|
|
|
case FieldType.SELECT: {
|
|
const strVal = String(value);
|
|
const option = fieldDef.options?.find((o) => o.value === strVal);
|
|
return <span className="text-gray-900">{option?.label ?? strVal}</span>;
|
|
}
|
|
|
|
case FieldType.MULTI_SELECT: {
|
|
const rawVals = Array.isArray(value) ? value : [value];
|
|
const strVals = rawVals.map((v) => String(v)).filter(Boolean);
|
|
|
|
if (strVals.length === 0) {
|
|
return <span className="text-gray-400">—</span>;
|
|
}
|
|
|
|
const labels = strVals.map((v) => {
|
|
const option = fieldDef.options?.find((o) => o.value === v);
|
|
return { value: v, label: option?.label ?? v, color: option?.color };
|
|
});
|
|
|
|
return (
|
|
<div className="flex flex-wrap gap-1">
|
|
{labels.map(({ value: v, label }) => (
|
|
<span
|
|
key={v}
|
|
className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-brand-50 text-brand-700"
|
|
>
|
|
{label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
default:
|
|
return <span className="text-gray-900">{String(value)}</span>;
|
|
}
|
|
}
|
|
|
|
function FieldRow({ fieldDef, value }: { fieldDef: BlueprintFieldDefinition; value: unknown }) {
|
|
return (
|
|
<div className="flex flex-col gap-0.5">
|
|
<dt className="text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
{fieldDef.label}
|
|
</dt>
|
|
<dd className="text-sm">{renderValue(fieldDef, value)}</dd>
|
|
{fieldDef.description && <p className="text-xs text-gray-400">{fieldDef.description}</p>}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function DynamicFieldRenderer({ fieldDefs, values, className }: Props) {
|
|
const sorted = [...fieldDefs].sort((a, b) => a.order - b.order);
|
|
|
|
// Separate grouped and ungrouped fields
|
|
const ungrouped = sorted.filter((f) => !f.group);
|
|
const groupMap = new Map<string, BlueprintFieldDefinition[]>();
|
|
|
|
for (const field of sorted) {
|
|
if (!field.group) continue;
|
|
const existing = groupMap.get(field.group) ?? [];
|
|
existing.push(field);
|
|
groupMap.set(field.group, existing);
|
|
}
|
|
|
|
if (sorted.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className={clsx("space-y-6", className)}>
|
|
{ungrouped.length > 0 && (
|
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{ungrouped.map((field) => (
|
|
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
|
|
))}
|
|
</dl>
|
|
)}
|
|
|
|
{[...groupMap.entries()].map(([group, fields]) => (
|
|
<div key={group} className="space-y-3">
|
|
<h4 className="text-sm font-semibold text-gray-700 border-b border-gray-200 pb-1">
|
|
{group}
|
|
</h4>
|
|
<dl className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{fields.map((field) => (
|
|
<FieldRow key={field.id} fieldDef={field} value={values[field.key]} />
|
|
))}
|
|
</dl>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|