"use client"; /** * CellRenderer — renders a RecordPropertyValue by property.type. * * Covers all 24 PropertyType values from prisma/schema.prisma (docs/api/ * databases.md §3.4 "Property Types — Config Shapes"). Display formatting * lives here; the query/filter/formula *engine* stays in * @factory/database-engine + the server — nothing is re-evaluated locally. * * Read-only types: created_time, created_by, last_edited_time, * last_edited_by, rollup, formula, unique_id. The `button` type renders an * actionable chip (executed via record mutations, see the handler table). */ import { memo, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import { AlertTriangle, Check, ChevronRight, ExternalLink, File as FileIcon, Link2, Mail, Paperclip, Phone, User as UserIcon } from "lucide-react"; import { cn } from "@/lib/utils"; import { apiFetch } from "@/lib/api"; import { DatabaseProperty, RecordDto, RecordValue, findOption, useRelations, useUpdateRecord } from "@/hooks/use-database"; export interface UserRef { id: string; name: string; avatarUrl?: string | null; } /** 18-swatch color palette → fallback hex (tokens map to CSS vars at runtime). */ const COLOR_FALLBACK: Record = { default: "transparent", gray: "#f1f1ef", brown: "#f7e9e7", orange: "#fbecdd", yellow: "#fdf3d0", green: "#edf3ec", blue: "#e7f0f6", purple: "#f1edf7", pink: "#faedf3", red: "#fdebec" }; const COLOR_DOT: Record = { gray: "#787774", brown: "#9f6b53", orange: "#d9730d", yellow: "#cb912f", green: "#448361", blue: "#337ea9", purple: "#9065b0", pink: "#c14c8a", red: "#d44c47" }; function optionStyle(color?: string): React.CSSProperties { const c = color ?? "default"; return { backgroundColor: `var(--factory-color-${c}_bg, ${COLOR_FALLBACK[c] ?? "transparent"})`, color: `var(--factory-color-${c}, inherit)` }; } // --------------------------------------------------------------------------- // Formatting helpers // --------------------------------------------------------------------------- /** * Number formatting per property.config: * { format: "number"|"decimal"|"currency"|"percent"|"scientific", currency, precision }. * Convention: percent values are stored as fractions (0.75 → 75%). */ export function formatNumberValue(value: unknown, config?: { format?: string; currency?: string; precision?: number }): string { if (value == null || value === "") return "—"; const n = typeof value === "number" ? value : Number(value); if (!Number.isFinite(n)) return String(value); const precision = config?.precision ?? 2; switch (config?.format) { case "currency": return new Intl.NumberFormat(undefined, { style: "currency", currency: config?.currency ?? "USD", maximumFractionDigits: precision }).format(n); case "percent": return new Intl.NumberFormat(undefined, { style: "percent", maximumFractionDigits: precision }).format(n); case "decimal": return new Intl.NumberFormat(undefined, { minimumFractionDigits: 0, maximumFractionDigits: precision }).format(n); case "scientific": return n.toExponential(precision); default: return new Intl.NumberFormat(undefined, { maximumFractionDigits: precision }).format(n); } } /** Date formatting: config { includeTime, timeZone } — defaults to UTC. */ export function formatDateValue(iso: string | null | undefined, config?: { includeTime?: boolean; timeZone?: string | null }): string { if (!iso) return ""; const d = new Date(iso); if (Number.isNaN(d.getTime())) return ""; const timeZone = config?.timeZone ?? "UTC"; const date = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "short", day: "2-digit" }).format(d); if (!config?.includeTime) return date; const time = new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit" }).format(d); return `${date} ${time}`; } function formatBytes(bytes: number): string { if (!Number.isFinite(bytes) || bytes < 0) return ""; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`; } // --------------------------------------------------------------------------- // Small presentational atoms // --------------------------------------------------------------------------- export function UserAvatar({ user, size = 20 }: { user: UserRef; size?: number }) { if (user.avatarUrl) { return ( {user.name} ); } return ( {(user.name ?? "?").slice(0, 1).toUpperCase()} ); } function PersonCellContent({ userIds, users }: { userIds?: string[]; users?: Record }) { const ids = userIds ?? []; if (ids.length === 0) return null; return ( {ids.slice(0, 3).map((id) => { const u = users?.[id]; return ( {u ? : } {u?.name ?? id.slice(-4)} ); })} {ids.length > 3 && +{ids.length - 3}} ); } // --------------------------------------------------------------------------- // File cells // --------------------------------------------------------------------------- interface FileMeta { id: string; name: string; size: number; mimeType: string; url?: string | null; thumbnailUrl?: string | null; } /** Lightweight file metadata for Files cells (GET /files/:id). */ function useFileMeta(fileId: string | undefined): FileMeta | null { const q = useQuery({ queryKey: ["file", fileId ?? "none"], enabled: !!fileId, queryFn: async () => { const b = await apiFetch(`/files/${fileId}`); const d = b && typeof b === "object" && "data" in (b as Record) ? (b as { data: FileMeta }).data : (b as FileMeta); return d; }, staleTime: 5 * 60 * 1000 }); return q.data ?? null; } function FileChip({ fileId }: { fileId: string }) { const meta = useFileMeta(fileId); const [preview, setPreview] = useState(false); return ( ); } function FilesCellContent({ fileIds }: { fileIds?: string[] }) { const ids = fileIds ?? []; if (ids.length === 0) return null; return ( {ids.slice(0, 3).map((id) => ( ))} {ids.length > 3 && +{ids.length - 3}} ); } // --------------------------------------------------------------------------- // Relation cell (chips of linked record titles → click navigate) // --------------------------------------------------------------------------- function RelationCellContent({ value, property, onOpenRecord, titleOf }: { value?: RecordValue; property: DatabaseProperty; onOpenRecord?: (record: RecordDto) => void; /** Fallback resolver when the lazy fetch hasn't landed yet. */ titleOf?: (recordId: string) => string; }) { const ids = value?.recordIds ?? []; const targetDatabaseId = property.config.targetDatabaseId; const { data: resolved } = useRelations(targetDatabaseId, targetDatabaseId, ids); if (ids.length === 0) return null; return ( {ids.slice(0, 3).map((id) => { const r = resolved?.find((x) => x.recordId === id)?.record; const label = r ? titleOf?.(id) ?? r.title ?? "Record" : titleOf?.(id) ?? "…"; return ( ); })} {ids.length > 3 && +{ids.length - 3}} ); } // --------------------------------------------------------------------------- // Per-type display // --------------------------------------------------------------------------- export interface CellRendererProps { record: RecordDto; property: DatabaseProperty; value?: RecordValue | null; /** Read-only context (shared pages, locked levels) — no edit affordance. */ readOnly?: boolean; /** Called when the user clicks an editable cell. */ onEdit?: (record: RecordDto, property: DatabaseProperty) => void; /** userId → {name, avatarUrl} for person cells. */ users?: Record; /** Open a record page (title links, relation chips). */ onOpenRecord?: (record: RecordDto) => void; } const READ_ONLY_TYPES = new Set([ "created_time", "created_by", "last_edited_time", "last_edited_by", "rollup", "formula", "unique_id" ]); export const isEditableProperty = (p: DatabaseProperty) => !READ_ONLY_TYPES.has(p.type) && p.type !== "button"; /** * Cell types whose display already carries its own interactive controls: * relation (per-record chips), files (file chips). Wrapping those in the * outer click-to-edit ); } // --------------------------------------------------------------------------- export const CellRenderer = memo(function CellRenderer(props: CellRendererProps) { const { record, property, readOnly, onEdit } = props; if (property.type === "button") { return readOnly ? renderValue(props) : ; } const editable = !readOnly && isEditableProperty(property); // Intrinsically-interactive cells (title/relation/files) render their own // ); }); /** * Convenience: value + property → display string (used by list/board/gallery * cards and record headers). Avoids mounting a full cell for tiny chips. */ export function cellToText( value: RecordValue | undefined | null, property: DatabaseProperty, users?: Record ): string { if (value == null) return ""; if (value.text != null) return value.text; if (value.value != null) { if (property.type === "checkbox") return value.value ? "Yes" : "No"; if (property.type === "number") return formatNumberValue(value.value, property.config); return String(value.value); } if (value.optionId && property.config.options) { return findOption(property.config.options, value.optionId)?.name ?? ""; } if (value.optionIds?.length && property.config.options) { return value.optionIds .map((id) => findOption(property.config.options, id)?.name) .filter(Boolean) .join(", "); } if (value.start) return formatDateValue(value.start, property.config); if (value.end) return `${formatDateValue(value.start, property.config)} → ${formatDateValue(value.end, property.config)}`; if (value.userIds?.length) { return value.userIds.map((id) => users?.[id]?.name ?? id).join(", "); } if (value.recordIds) return `${value.recordIds.length} linked`; if (value.fileIds) return `${value.fileIds.length} file(s)`; if (value.error) return "⚠ formula error"; return ""; }