"use client"; import * as React from "react"; import { ArrowDown, ArrowUp, ArrowUpDown, Plus, } from "lucide-react"; import { Button } from "@/components/ui/button"; import { ScrollArea } from "@/components/ui/scroll-area"; import { getDueDateValue, getPriorityValue, } from "@/components/views/list/list-item"; import type { ViewConfig, ViewObject, ViewSort } from "@/lib/hooks/use-view-data"; import { useViewData } from "@/lib/hooks/use-view-data"; import { usePanelStore } from "@/lib/stores/panel-store"; import { cn } from "@/lib/utils"; import { TableCell, type TableFieldType } from "./table-cell"; type SortField = | "title" | "status" | "priority" | "dueDate" | "assignees" | "createdAt" | string; const CHECKBOX_W = 36; const DEFAULT_COL_WIDTHS: Record = { select: CHECKBOX_W, title: 280, status: 128, priority: 104, assignees: 148, dueDate: 120, created: 132, }; function collectCustomColumns(items: ViewObject[]) { const map = new Map(); for (const o of items) { for (const pv of o.propertyValues ?? []) { const def = pv.propertyDefinition; if (def.name === "Priority" || def.name === "Due Date") continue; if (!map.has(def.id)) map.set(def.id, def); } } return Array.from(map.values()); } function getPropertyValue(obj: ViewObject, propId: string): unknown { return obj.propertyValues?.find((p) => p.propertyDefinition.id === propId)?.value; } function mapCustomFieldType(ft: string): TableFieldType { if (ft === "select") return "select"; if (ft === "date") return "date"; return "text"; } function rankPriority(p: string): number { const order = ["High", "Medium", "Low"]; const i = order.indexOf(p); return i === -1 ? 999 : i; } function firstAssigneeName(o: ViewObject): string { return o.assignees?.[0]?.user?.name ?? ""; } function compareIsoDate(a: string, b: string): number { const ta = new Date(a).getTime(); const tb = new Date(b).getTime(); if (Number.isNaN(ta) || Number.isNaN(tb)) return a.localeCompare(b); return ta - tb; } function compareDue(a: string, b: string): number { if (a === "—" && b === "—") return 0; if (a === "—") return 1; if (b === "—") return -1; return compareIsoDate(a, b); } function sortItemsByField(items: ViewObject[], sort: ViewSort): ViewObject[] { const dir = sort.direction === "asc" ? 1 : -1; const copy = [...items]; copy.sort((a, b) => { let cmp = 0; if (sort.field.startsWith("prop:")) { const pid = sort.field.slice("prop:".length); const va = getPropertyValue(a, pid); const vb = getPropertyValue(b, pid); cmp = String(va ?? "").localeCompare(String(vb ?? "")); } else { switch (sort.field) { case "priority": { const pa = getPriorityValue(a); const pb = getPriorityValue(b); cmp = rankPriority(pa) - rankPriority(pb); if (cmp === 0) cmp = pa.localeCompare(pb); break; } case "dueDate": cmp = compareDue(getDueDateValue(a), getDueDateValue(b)); break; case "assignees": cmp = firstAssigneeName(a).localeCompare(firstAssigneeName(b)); break; case "title": cmp = a.title.localeCompare(b.title); break; case "status": cmp = String(a.status ?? "").localeCompare(String(b.status ?? "")); break; case "createdAt": cmp = compareIsoDate(a.createdAt, b.createdAt); break; default: cmp = 0; } } return cmp * dir; }); return copy; } function refineSort(items: ViewObject[], sorts: ViewSort[]): ViewObject[] { if (sorts.length === 0) return items; const s = sorts[0]; if (s.field === "title" || s.field === "status") { return items; } return sortItemsByField(items, s); } function nextSort(field: SortField, sorts: ViewSort[]): ViewSort[] { const first = sorts[0]; if (first?.field === field) { return [{ field, direction: first.direction === "asc" ? "desc" : "asc" }]; } return [{ field, direction: "asc" }]; } type RowEdit = { title?: string; status?: string | null; priority?: string; dueDate?: string; properties?: Record; }; function mergePriority(obj: ViewObject, priority: string): ViewObject { const pvs = [...(obj.propertyValues ?? [])]; const idx = pvs.findIndex((p) => p.propertyDefinition.name === "Priority"); const def = idx >= 0 ? pvs[idx].propertyDefinition : { id: "priority-def", name: "Priority", fieldType: "select" }; if (idx >= 0) { pvs[idx] = { ...pvs[idx], value: priority }; } else { pvs.push({ propertyDefinition: def, value: priority, }); } return { ...obj, propertyValues: pvs }; } function mergeDueDate(obj: ViewObject, due: string): ViewObject { const pvs = [...(obj.propertyValues ?? [])]; const idx = pvs.findIndex((p) => p.propertyDefinition.name === "Due Date"); const def = idx >= 0 ? pvs[idx].propertyDefinition : { id: "due-def", name: "Due Date", fieldType: "date" }; if (idx >= 0) { pvs[idx] = { ...pvs[idx], value: due }; } else { pvs.push({ propertyDefinition: def, value: due, }); } return { ...obj, propertyValues: pvs }; } function mergeProperty( obj: ViewObject, propId: string, val: unknown, def?: { id: string; name: string; fieldType: string }, ): ViewObject { const pvs = [...(obj.propertyValues ?? [])]; const idx = pvs.findIndex((p) => p.propertyDefinition.id === propId); if (idx >= 0) { pvs[idx] = { ...pvs[idx], value: val }; } else if (def) { pvs.push({ propertyDefinition: def, value: val }); } return { ...obj, propertyValues: pvs }; } function applyEdits( obj: ViewObject, edit: RowEdit | undefined, customCols: { id: string; name: string; fieldType: string }[], ): ViewObject { if (!edit) return obj; let next = obj; if (edit.title !== undefined) next = { ...next, title: edit.title }; if (edit.status !== undefined) next = { ...next, status: edit.status }; if (edit.priority !== undefined) next = mergePriority(next, edit.priority); if (edit.dueDate !== undefined) next = mergeDueDate(next, edit.dueDate); if (edit.properties) { for (const [pid, val] of Object.entries(edit.properties)) { const def = customCols.find((c) => c.id === pid); next = mergeProperty(next, pid, val, def); } } return next; } function ResizeHandle({ onResize, className, }: { onResize: (delta: number) => void; className?: string; }) { const start = React.useRef(0); const onMouseDown = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); start.current = e.clientX; const onMove = (ev: MouseEvent) => { const d = ev.clientX - start.current; start.current = ev.clientX; if (d !== 0) onResize(d); }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }; return (
); } function SortHeader({ label, field, sorts, onSort, width, stickyClass, children, }: { label: string; field: SortField; sorts: ViewSort[]; onSort: (field: SortField) => void; width: number; stickyClass?: string; children?: React.ReactNode; }) { const active = sorts[0]?.field === field; const dir = active ? sorts[0].direction : null; return ( {children} ); } function TableSkeleton({ colCount }: { colCount: number }) { return ( {Array.from({ length: 8 }).map((_, i) => ( {Array.from({ length: colCount }).map((__, j) => (
))} ))} ); } function EmptyState() { return (

No rows yet

Add a row or adjust filters to see data here.

); } export interface TableViewProps { config: ViewConfig; } export function TableView({ config }: TableViewProps) { const openDetail = usePanelStore((s) => s.open); const [sorts, setSorts] = React.useState(config.sorts); React.useEffect(() => { setSorts(config.sorts); }, [config.sorts]); const effectiveConfig = React.useMemo( () => ({ ...config, sorts }), [config, sorts], ); const { items, isLoading, total } = useViewData(effectiveConfig); const [localRows, setLocalRows] = React.useState([]); const [edits, setEdits] = React.useState>({}); const baseItems = React.useMemo(() => [...items, ...localRows], [items, localRows]); const customCols = React.useMemo(() => collectCustomColumns(baseItems), [baseItems]); const displayItems = React.useMemo( () => refineSort(baseItems, sorts), [baseItems, sorts], ); const [selected, setSelected] = React.useState>(new Set()); const [widths, setWidths] = React.useState>(() => { const w = { ...DEFAULT_COL_WIDTHS }; return w; }); React.useEffect(() => { setWidths((prev) => { const next = { ...prev }; for (const c of customCols) { if (next[c.id] === undefined) next[c.id] = 140; } return next; }); }, [customCols]); const setColWidth = React.useCallback((key: string, delta: number) => { setWidths((prev) => { const cur = prev[key] ?? 120; const min = key === "select" ? CHECKBOX_W : 72; const nextW = Math.max(min, cur + delta); return { ...prev, [key]: nextW }; }); }, []); const toggleSelect = React.useCallback((id: string) => { setSelected((prev) => { const n = new Set(prev); if (n.has(id)) n.delete(id); else n.add(id); return n; }); }, []); const toggleSelectAll = React.useCallback(() => { if (selected.size === displayItems.length && displayItems.length > 0) { setSelected(new Set()); return; } setSelected(new Set(displayItems.map((o) => o.id))); }, [displayItems, selected.size]); const onHeaderSort = React.useCallback((field: SortField) => { setSorts((s) => nextSort(field, s)); }, []); const patchEdit = React.useCallback((id: string, patch: RowEdit) => { setEdits((prev) => { const cur = prev[id]; const merged: RowEdit = { ...cur, ...patch }; if (patch.properties || cur?.properties) { merged.properties = { ...(cur?.properties ?? {}), ...(patch.properties ?? {}), }; } return { ...prev, [id]: merged }; }); }, []); const getRow = React.useCallback( (obj: ViewObject) => applyEdits(obj, edits[obj.id], customCols), [edits, customCols], ); const colCount = 1 + 1 + 1 + 1 + 1 + 1 + 1 + customCols.length; const allSelected = displayItems.length > 0 && selected.size === displayItems.length; const tableMinWidth = React.useMemo(() => { let sum = 0; sum += widths.select ?? DEFAULT_COL_WIDTHS.select; sum += widths.title ?? DEFAULT_COL_WIDTHS.title; sum += widths.status ?? DEFAULT_COL_WIDTHS.status; sum += widths.priority ?? DEFAULT_COL_WIDTHS.priority; sum += widths.assignees ?? DEFAULT_COL_WIDTHS.assignees; sum += widths.dueDate ?? DEFAULT_COL_WIDTHS.dueDate; sum += widths.created ?? DEFAULT_COL_WIDTHS.created; for (const c of customCols) { sum += widths[c.id] ?? 140; } return sum; }, [widths, customCols]); const addRow = React.useCallback(() => { const id = `new-${Date.now()}`; const row: ViewObject = { id, type: "task", title: "New task", status: "open", icon: null, sortOrder: baseItems.length, parentId: null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), assignees: [], propertyValues: [ { propertyDefinition: { id: "p1", name: "Priority", fieldType: "select" }, value: "Medium", }, ], }; setLocalRows((r) => [...r, row]); setSelected(new Set([id])); }, [baseItems.length]); return (
setColWidth("title", d)} /> setColWidth("status", d)} /> setColWidth("priority", d)} /> setColWidth("assignees", d)} /> setColWidth("dueDate", d)} /> setColWidth("created", d)} /> {customCols.map((c) => ( setColWidth(c.id, d)} /> ))} {isLoading ? ( ) : total === 0 && localRows.length === 0 ? ( ) : ( {displayItems.map((raw, rowIndex) => { const obj = getRow(raw); const isSelected = selected.has(obj.id); const zebra = rowIndex % 2 === 1; const rowBg = isSelected ? "bg-primary/10" : zebra ? "bg-muted/[0.12]" : "bg-background"; return ( openDetail("object-detail", obj.id)} className={cn( "group/row border-b border-border/60 transition-colors", rowBg, )} > {customCols.map((c) => { const ft = mapCustomFieldType(c.fieldType); const val = getPropertyValue(obj, c.id); const opts = Array.from( new Set( baseItems .map((o) => getPropertyValue(o, c.id)) .filter((x): x is string => typeof x === "string" && x.length > 0), ), ); const selectOpts = opts.length > 0 ? opts : ["—"]; return ( ); })} ); })} )}
setColWidth("select", d)} />
toggleSelect(obj.id)} className={cn( "h-3.5 w-3.5 cursor-pointer rounded border border-input bg-background", "text-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1", )} aria-label={`Select ${obj.title}`} />
patchEdit(obj.id, { title: typeof v === "string" ? v : String(v) }) } className="rounded-none border-0" /> patchEdit(obj.id, { status: typeof v === "string" ? v : null }) } /> patchEdit(obj.id, { priority: typeof v === "string" ? v : "" }) } /> patchEdit(obj.id, { dueDate: v == null || v === "" ? "" : String(v), }) } /> patchEdit(obj.id, { properties: { [c.id]: v }, }) } />
); }