Complete architecture for a ClickUp/Notion/Miro-class project management app: - Turborepo monorepo with Next.js 15, TypeScript, PostgreSQL (Drizzle ORM) - Object-centered database schema (everything is an Object: tasks, projects, docs, whiteboards) - NextAuth v5 authentication with credentials + OAuth providers - tRPC v11 API layer with full CRUD for objects, properties, relations, templates, search - Three-panel UI: collapsible sidebar, center content area, push-in right panel - Purple/teal theme with light/dark mode via Shadcn/ui + Tailwind CSS - Multiple views: List, Kanban board (dnd-kit), Table (spreadsheet), Embedded iframe - TipTap rich text editor with slash commands, custom blocks (callout, toggle, mention, embed, divider), AI block - Real-time collaboration via Yjs + Hocuspocus with presence/cursors - tldraw whiteboard with custom shape cards (task, document, project) - MCP server exposing all app data/tools for AI agents - AI chat panel, editor AI slash commands, Cmd+K command palette - Template system with built-in templates (Bug Report, Meeting Notes, Sprint) - Full-text search with result highlighting - Docker Compose for full-stack deployment (web + collab + postgres + redis) Made-with: Cursor
805 lines
27 KiB
TypeScript
805 lines
27 KiB
TypeScript
"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<string, number> = {
|
|
select: CHECKBOX_W,
|
|
title: 280,
|
|
status: 128,
|
|
priority: 104,
|
|
assignees: 148,
|
|
dueDate: 120,
|
|
created: 132,
|
|
};
|
|
|
|
function collectCustomColumns(items: ViewObject[]) {
|
|
const map = new Map<string, { id: string; name: string; fieldType: string }>();
|
|
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<string, unknown>;
|
|
};
|
|
|
|
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 (
|
|
<div
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
onMouseDown={onMouseDown}
|
|
className={cn(
|
|
"absolute right-0 top-0 z-10 h-full w-1.5 translate-x-1/2 cursor-col-resize select-none hover:bg-primary/30",
|
|
className,
|
|
)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<th
|
|
scope="col"
|
|
style={{ width, minWidth: width }}
|
|
className={cn(
|
|
"sticky top-0 z-20 border-b border-r border-border/70 bg-muted/50 px-0 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground backdrop-blur supports-[backdrop-filter]:bg-muted/40",
|
|
stickyClass,
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => onSort(field)}
|
|
className="flex h-9 w-full items-center gap-1 px-2 text-left transition-colors hover:bg-accent/40 hover:text-foreground"
|
|
>
|
|
<span className="min-w-0 flex-1 truncate">{label}</span>
|
|
{active ? (
|
|
dir === "asc" ? (
|
|
<ArrowUp className="h-3 w-3 shrink-0 opacity-80" />
|
|
) : (
|
|
<ArrowDown className="h-3 w-3 shrink-0 opacity-80" />
|
|
)
|
|
) : (
|
|
<ArrowUpDown className="h-3 w-3 shrink-0 opacity-35" />
|
|
)}
|
|
</button>
|
|
{children}
|
|
</th>
|
|
);
|
|
}
|
|
|
|
function TableSkeleton({ colCount }: { colCount: number }) {
|
|
return (
|
|
<tbody>
|
|
{Array.from({ length: 8 }).map((_, i) => (
|
|
<tr key={i} className={cn("border-b border-border/50", i % 2 === 1 && "bg-muted/[0.12]")}>
|
|
{Array.from({ length: colCount }).map((__, j) => (
|
|
<td key={j} className="border-r border-border/50 px-2 py-1.5">
|
|
<div className="h-3 animate-pulse rounded bg-muted/80" />
|
|
</td>
|
|
))}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
);
|
|
}
|
|
|
|
function EmptyState() {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-2 px-6 py-16 text-center">
|
|
<p className="text-sm font-medium text-foreground">No rows yet</p>
|
|
<p className="max-w-sm text-xs text-muted-foreground">
|
|
Add a row or adjust filters to see data here.
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export interface TableViewProps {
|
|
config: ViewConfig;
|
|
}
|
|
|
|
export function TableView({ config }: TableViewProps) {
|
|
const openDetail = usePanelStore((s) => s.open);
|
|
const [sorts, setSorts] = React.useState<ViewSort[]>(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<ViewObject[]>([]);
|
|
const [edits, setEdits] = React.useState<Record<string, RowEdit>>({});
|
|
|
|
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<Set<string>>(new Set());
|
|
|
|
const [widths, setWidths] = React.useState<Record<string, number>>(() => {
|
|
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 (
|
|
<div className="flex h-full min-h-0 flex-col rounded-md border border-border bg-card text-card-foreground shadow-sm">
|
|
<ScrollArea className="min-h-0 flex-1">
|
|
<table
|
|
className="w-full border-collapse text-sm"
|
|
style={{ minWidth: tableMinWidth }}
|
|
>
|
|
<thead>
|
|
<tr>
|
|
<th
|
|
scope="col"
|
|
style={{
|
|
width: widths.select ?? DEFAULT_COL_WIDTHS.select,
|
|
minWidth: widths.select ?? DEFAULT_COL_WIDTHS.select,
|
|
}}
|
|
className={cn(
|
|
"sticky left-0 top-0 z-40 border-b border-r border-border/70 bg-muted/50 px-0 backdrop-blur supports-[backdrop-filter]:bg-muted/40",
|
|
)}
|
|
>
|
|
<div className="relative flex h-9 items-center justify-center border-b border-border/40">
|
|
<input
|
|
type="checkbox"
|
|
checked={allSelected}
|
|
onChange={toggleSelectAll}
|
|
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 all rows"
|
|
/>
|
|
<ResizeHandle onResize={(d) => setColWidth("select", d)} />
|
|
</div>
|
|
</th>
|
|
<SortHeader
|
|
label="Title"
|
|
field="title"
|
|
sorts={sorts}
|
|
onSort={onHeaderSort}
|
|
width={widths.title ?? DEFAULT_COL_WIDTHS.title}
|
|
stickyClass="left-9 z-30 border-border/70 bg-muted/50 backdrop-blur supports-[backdrop-filter]:bg-muted/40"
|
|
>
|
|
<ResizeHandle onResize={(d) => setColWidth("title", d)} />
|
|
</SortHeader>
|
|
<SortHeader
|
|
label="Status"
|
|
field="status"
|
|
sorts={sorts}
|
|
onSort={onHeaderSort}
|
|
width={widths.status ?? DEFAULT_COL_WIDTHS.status}
|
|
>
|
|
<ResizeHandle onResize={(d) => setColWidth("status", d)} />
|
|
</SortHeader>
|
|
<SortHeader
|
|
label="Priority"
|
|
field="priority"
|
|
sorts={sorts}
|
|
onSort={onHeaderSort}
|
|
width={widths.priority ?? DEFAULT_COL_WIDTHS.priority}
|
|
>
|
|
<ResizeHandle onResize={(d) => setColWidth("priority", d)} />
|
|
</SortHeader>
|
|
<SortHeader
|
|
label="Assignees"
|
|
field="assignees"
|
|
sorts={sorts}
|
|
onSort={onHeaderSort}
|
|
width={widths.assignees ?? DEFAULT_COL_WIDTHS.assignees}
|
|
>
|
|
<ResizeHandle onResize={(d) => setColWidth("assignees", d)} />
|
|
</SortHeader>
|
|
<SortHeader
|
|
label="Due date"
|
|
field="dueDate"
|
|
sorts={sorts}
|
|
onSort={onHeaderSort}
|
|
width={widths.dueDate ?? DEFAULT_COL_WIDTHS.dueDate}
|
|
>
|
|
<ResizeHandle onResize={(d) => setColWidth("dueDate", d)} />
|
|
</SortHeader>
|
|
<SortHeader
|
|
label="Created"
|
|
field="createdAt"
|
|
sorts={sorts}
|
|
onSort={onHeaderSort}
|
|
width={widths.created ?? DEFAULT_COL_WIDTHS.created}
|
|
>
|
|
<ResizeHandle onResize={(d) => setColWidth("created", d)} />
|
|
</SortHeader>
|
|
{customCols.map((c) => (
|
|
<SortHeader
|
|
key={c.id}
|
|
label={c.name}
|
|
field={`prop:${c.id}`}
|
|
sorts={sorts}
|
|
onSort={onHeaderSort}
|
|
width={widths[c.id] ?? 140}
|
|
>
|
|
<ResizeHandle onResize={(d) => setColWidth(c.id, d)} />
|
|
</SortHeader>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
{isLoading ? (
|
|
<TableSkeleton colCount={colCount} />
|
|
) : total === 0 && localRows.length === 0 ? (
|
|
<tbody>
|
|
<tr>
|
|
<td colSpan={colCount} className="p-0">
|
|
<EmptyState />
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
) : (
|
|
<tbody>
|
|
{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 (
|
|
<tr
|
|
key={obj.id}
|
|
onDoubleClick={() => openDetail("object-detail", obj.id)}
|
|
className={cn(
|
|
"group/row border-b border-border/60 transition-colors",
|
|
rowBg,
|
|
)}
|
|
>
|
|
<td
|
|
style={{
|
|
width: widths.select ?? DEFAULT_COL_WIDTHS.select,
|
|
minWidth: widths.select ?? DEFAULT_COL_WIDTHS.select,
|
|
}}
|
|
className={cn(
|
|
"sticky left-0 z-20 border-r border-border/60 px-0",
|
|
rowBg,
|
|
isSelected && "bg-primary/10",
|
|
!isSelected && "group-hover/row:bg-accent/35",
|
|
)}
|
|
>
|
|
<div className="flex h-9 items-center justify-center">
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => 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}`}
|
|
/>
|
|
</div>
|
|
</td>
|
|
<td
|
|
style={{
|
|
width: widths.title ?? DEFAULT_COL_WIDTHS.title,
|
|
minWidth: widths.title ?? DEFAULT_COL_WIDTHS.title,
|
|
}}
|
|
className={cn(
|
|
"sticky left-9 z-10 border-r border-border/60 p-0",
|
|
rowBg,
|
|
isSelected && "bg-primary/10",
|
|
!isSelected && "group-hover/row:bg-accent/35",
|
|
)}
|
|
>
|
|
<TableCell
|
|
fieldType="title"
|
|
value={obj.title}
|
|
onChange={(v) =>
|
|
patchEdit(obj.id, { title: typeof v === "string" ? v : String(v) })
|
|
}
|
|
className="rounded-none border-0"
|
|
/>
|
|
</td>
|
|
<td
|
|
style={{
|
|
width: widths.status ?? DEFAULT_COL_WIDTHS.status,
|
|
minWidth: widths.status ?? DEFAULT_COL_WIDTHS.status,
|
|
}}
|
|
className={cn(
|
|
"border-r border-border/60 p-0",
|
|
rowBg,
|
|
!isSelected && "group-hover/row:bg-accent/35",
|
|
)}
|
|
>
|
|
<TableCell
|
|
fieldType="status"
|
|
value={obj.status}
|
|
onChange={(v) =>
|
|
patchEdit(obj.id, { status: typeof v === "string" ? v : null })
|
|
}
|
|
/>
|
|
</td>
|
|
<td
|
|
style={{
|
|
width: widths.priority ?? DEFAULT_COL_WIDTHS.priority,
|
|
minWidth: widths.priority ?? DEFAULT_COL_WIDTHS.priority,
|
|
}}
|
|
className={cn(
|
|
"border-r border-border/60 p-0",
|
|
rowBg,
|
|
!isSelected && "group-hover/row:bg-accent/35",
|
|
)}
|
|
>
|
|
<TableCell
|
|
fieldType="priority"
|
|
value={getPriorityValue(obj)}
|
|
onChange={(v) =>
|
|
patchEdit(obj.id, { priority: typeof v === "string" ? v : "" })
|
|
}
|
|
/>
|
|
</td>
|
|
<td
|
|
style={{
|
|
width: widths.assignees ?? DEFAULT_COL_WIDTHS.assignees,
|
|
minWidth: widths.assignees ?? DEFAULT_COL_WIDTHS.assignees,
|
|
}}
|
|
className={cn(
|
|
"border-r border-border/60 p-0",
|
|
rowBg,
|
|
!isSelected && "group-hover/row:bg-accent/35",
|
|
)}
|
|
>
|
|
<TableCell
|
|
fieldType="assignees"
|
|
value={obj.assignees ?? []}
|
|
readOnly
|
|
/>
|
|
</td>
|
|
<td
|
|
style={{
|
|
width: widths.dueDate ?? DEFAULT_COL_WIDTHS.dueDate,
|
|
minWidth: widths.dueDate ?? DEFAULT_COL_WIDTHS.dueDate,
|
|
}}
|
|
className={cn(
|
|
"border-r border-border/60 p-0",
|
|
rowBg,
|
|
!isSelected && "group-hover/row:bg-accent/35",
|
|
)}
|
|
>
|
|
<TableCell
|
|
fieldType="date"
|
|
value={getDueDateValue(obj)}
|
|
onChange={(v) =>
|
|
patchEdit(obj.id, {
|
|
dueDate: v == null || v === "" ? "" : String(v),
|
|
})
|
|
}
|
|
/>
|
|
</td>
|
|
<td
|
|
style={{
|
|
width: widths.created ?? DEFAULT_COL_WIDTHS.created,
|
|
minWidth: widths.created ?? DEFAULT_COL_WIDTHS.created,
|
|
}}
|
|
className={cn(
|
|
"border-r border-border/60 p-0",
|
|
rowBg,
|
|
!isSelected && "group-hover/row:bg-accent/35",
|
|
)}
|
|
>
|
|
<TableCell fieldType="created" value={obj.createdAt} readOnly />
|
|
</td>
|
|
{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 (
|
|
<td
|
|
key={c.id}
|
|
style={{
|
|
width: widths[c.id] ?? 140,
|
|
minWidth: widths[c.id] ?? 140,
|
|
}}
|
|
className={cn(
|
|
"border-r border-border/60 p-0",
|
|
rowBg,
|
|
!isSelected && "group-hover/row:bg-accent/35",
|
|
)}
|
|
>
|
|
<TableCell
|
|
fieldType={ft}
|
|
value={val ?? ""}
|
|
selectOptions={selectOpts}
|
|
onChange={(v) =>
|
|
patchEdit(obj.id, {
|
|
properties: { [c.id]: v },
|
|
})
|
|
}
|
|
/>
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
)}
|
|
</table>
|
|
</ScrollArea>
|
|
|
|
<div className="flex shrink-0 border-t border-border/70 bg-muted/20 px-2 py-1.5">
|
|
<Button type="button" variant="outline" size="sm" className="h-8 gap-1 text-xs" onClick={addRow}>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
Add row
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|