"use client"; import { createPortal } from "react-dom"; import { closestCenter, DndContext, DragOverlay, KeyboardSensor, PointerSensor, useSensor, useSensors, } from "@dnd-kit/core"; import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core"; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { InfoTooltip } from "~/components/ui/InfoTooltip.js"; import { trpc } from "~/lib/trpc/client.js"; import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js"; // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- type ClientRow = { id: string; name: string; code: string | null; parentId: string | null; sortOrder: number; isActive: boolean; tags: string[]; _count?: { children: number; projects: number }; }; type TreeNode = ClientRow & { children: TreeNode[]; depth: number; }; // --------------------------------------------------------------------------- // Tree helpers // --------------------------------------------------------------------------- function buildTree(clients: ClientRow[]): TreeNode[] { const map = new Map(); const roots: TreeNode[] = []; // First pass: create nodes for (const c of clients) { map.set(c.id, { ...c, children: [], depth: 0 }); } // Second pass: link parents for (const c of clients) { const node = map.get(c.id)!; if (c.parentId && map.has(c.parentId)) { map.get(c.parentId)!.children.push(node); } else { roots.push(node); } } // Third pass: set depths and sort children function setDepths(nodes: TreeNode[], depth: number) { for (const n of nodes) { n.depth = depth; n.children.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); setDepths(n.children, depth + 1); } } roots.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)); setDepths(roots, 0); return roots; } /** Flatten tree into display order, respecting collapsed state. */ function flattenTree(nodes: TreeNode[], collapsedIds: Set): TreeNode[] { const result: TreeNode[] = []; function walk(list: TreeNode[]) { for (const n of list) { result.push(n); if (n.children.length > 0 && !collapsedIds.has(n.id)) { walk(n.children); } } } walk(nodes); return result; } /** Get all descendant IDs of a node (recursive). */ function getDescendantIds(clientId: string, clients: ClientRow[]): Set { const ids = new Set(); function collect(parentId: string) { for (const c of clients) { if (c.parentId === parentId && !ids.has(c.id)) { ids.add(c.id); collect(c.id); } } } collect(clientId); return ids; } /** Get all ancestor IDs of a node (walk up). */ function getAncestorIds(clientId: string, clientMap: Map): Set { const ids = new Set(); let current = clientMap.get(clientId); while (current?.parentId) { ids.add(current.parentId); current = clientMap.get(current.parentId); } return ids; } // --------------------------------------------------------------------------- // Tag color palette -- deterministic color from tag name hash // --------------------------------------------------------------------------- const TAG_COLORS = [ { bg: "bg-purple-100 dark:bg-purple-900/40", text: "text-purple-700 dark:text-purple-300", border: "border-purple-200 dark:border-purple-700" }, { bg: "bg-emerald-100 dark:bg-emerald-900/40", text: "text-emerald-700 dark:text-emerald-300", border: "border-emerald-200 dark:border-emerald-700" }, { bg: "bg-amber-100 dark:bg-amber-900/40", text: "text-amber-700 dark:text-amber-300", border: "border-amber-200 dark:border-amber-700" }, { bg: "bg-rose-100 dark:bg-rose-900/40", text: "text-rose-700 dark:text-rose-300", border: "border-rose-200 dark:border-rose-700" }, { bg: "bg-sky-100 dark:bg-sky-900/40", text: "text-sky-700 dark:text-sky-300", border: "border-sky-200 dark:border-sky-700" }, { bg: "bg-indigo-100 dark:bg-indigo-900/40", text: "text-indigo-700 dark:text-indigo-300", border: "border-indigo-200 dark:border-indigo-700" }, { bg: "bg-teal-100 dark:bg-teal-900/40", text: "text-teal-700 dark:text-teal-300", border: "border-teal-200 dark:border-teal-700" }, { bg: "bg-orange-100 dark:bg-orange-900/40", text: "text-orange-700 dark:text-orange-300", border: "border-orange-200 dark:border-orange-700" }, ]; function hashString(str: string): number { let hash = 0; for (let i = 0; i < str.length; i++) { hash = (hash << 5) - hash + str.charCodeAt(i); hash |= 0; } return Math.abs(hash); } function getTagColor(tag: string) { return TAG_COLORS[hashString(tag) % TAG_COLORS.length]!; } // --------------------------------------------------------------------------- // Tag pill component // --------------------------------------------------------------------------- function TagPill({ tag, onRemove, }: { tag: string; onRemove?: () => void; }) { const color = getTagColor(tag); return ( {tag} {onRemove && ( )} ); } // --------------------------------------------------------------------------- // Inline tag adder with auto-suggest // --------------------------------------------------------------------------- function TagAdder({ existingTags, allKnownTags, onAdd, onClose, }: { existingTags: string[]; allKnownTags: string[]; onAdd: (tag: string) => void; onClose: () => void; }) { const [value, setValue] = useState(""); const inputRef = useRef(null); const [showSuggestions, setShowSuggestions] = useState(false); useEffect(() => { inputRef.current?.focus(); }, []); const suggestions = useMemo(() => { if (!value.trim()) return allKnownTags.filter((t) => !existingTags.includes(t)).slice(0, 8); const lower = value.toLowerCase(); return allKnownTags .filter((t) => t.toLowerCase().includes(lower) && !existingTags.includes(t)) .slice(0, 8); }, [value, allKnownTags, existingTags]); const { panelRef, position } = useAnchoredOverlay({ open: showSuggestions && suggestions.length > 0, onClose: () => { setShowSuggestions(false); if (!value.trim()) { onClose(); } }, align: "start", triggerRef: inputRef, }); function submit(tag: string) { const trimmed = tag.trim(); if (!trimmed || existingTags.includes(trimmed)) return; onAdd(trimmed); setValue(""); } return (
{ setValue(e.target.value); setShowSuggestions(true); }} onFocus={() => setShowSuggestions(true)} onBlur={() => { // Delay to allow click on suggestion setTimeout(() => { setShowSuggestions(false); if (!value.trim()) onClose(); }, 200); }} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); submit(value); } if (e.key === "Escape") { onClose(); } }} placeholder="Tag..." className="w-24 px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-400" /> {showSuggestions && suggestions.length > 0 && typeof document !== "undefined" ? createPortal(
{suggestions.map((s) => ( ))}
, document.body, ) : null}
); } // --------------------------------------------------------------------------- // Drag handle icon // --------------------------------------------------------------------------- function GripIcon({ className }: { className?: string }) { return ( ); } // --------------------------------------------------------------------------- // Chevron icon for expand/collapse // --------------------------------------------------------------------------- function ChevronIcon({ expanded, className }: { expanded: boolean; className?: string }) { return ( ); } // --------------------------------------------------------------------------- // Parent selector dropdown // --------------------------------------------------------------------------- function ParentSelector({ clients, currentId, currentParentId, onChange, }: { clients: ClientRow[]; currentId?: string; currentParentId: string | null; onChange: (parentId: string | null) => void; }) { // Exclude self and all descendants to prevent circular refs const excludeIds = useMemo(() => { if (!currentId) return new Set(); const ids = getDescendantIds(currentId, clients); ids.add(currentId); return ids; }, [currentId, clients]); const options = useMemo(() => { return clients .filter((c) => !excludeIds.has(c.id) && c.isActive) .sort((a, b) => a.name.localeCompare(b.name)); }, [clients, excludeIds]); return ( ); } // --------------------------------------------------------------------------- // Sortable client card // --------------------------------------------------------------------------- function SortableClientCard({ client, allClients, allKnownTags, onUpdateName, onUpdateSortOrder, onUpdateTags, onUpdateParent, onDelete, onToggleExpand, isExpanded, isDragOverlay, }: { client: TreeNode; allClients: ClientRow[]; allKnownTags: string[]; onUpdateName: (id: string, name: string) => void; onUpdateSortOrder: (id: string, sortOrder: number) => void; onUpdateTags: (id: string, tags: string[]) => void; onUpdateParent: (id: string, parentId: string | null) => void; onDelete: (id: string) => void; onToggleExpand: (id: string) => void; isExpanded: boolean; isDragOverlay?: boolean; }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging, } = useSortable({ id: client.id }); const style = { transform: CSS.Transform.toString(transform), transition, }; const [editingName, setEditingName] = useState(false); const [nameValue, setNameValue] = useState(client.name); const [editingSortOrder, setEditingSortOrder] = useState(false); const [sortOrderValue, setSortOrderValue] = useState(client.sortOrder); const [addingTag, setAddingTag] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const [showParentSelector, setShowParentSelector] = useState(false); const nameInputRef = useRef(null); const sortInputRef = useRef(null); useEffect(() => { if (editingName) nameInputRef.current?.focus(); }, [editingName]); useEffect(() => { if (editingSortOrder) sortInputRef.current?.focus(); }, [editingSortOrder]); // Sync external updates useEffect(() => { setNameValue(client.name); }, [client.name]); useEffect(() => { setSortOrderValue(client.sortOrder); }, [client.sortOrder]); function saveName() { const trimmed = nameValue.trim(); if (trimmed && trimmed !== client.name) { onUpdateName(client.id, trimmed); } else { setNameValue(client.name); } setEditingName(false); } function saveSortOrder() { if (sortOrderValue !== client.sortOrder) { onUpdateSortOrder(client.id, sortOrderValue); } setEditingSortOrder(false); } const hasChildren = (client._count?.children ?? client.children.length) > 0; const childCount = client._count?.children ?? client.children.length; const depth = client.depth; // Nesting depth styling: alternating subtle backgrounds const depthBg = depth > 0 ? depth % 2 === 1 ? "bg-gray-50/50 dark:bg-gray-800/80" : "bg-white dark:bg-gray-800/60" : "bg-white dark:bg-gray-800"; const cardClasses = [ "flex items-center gap-3 px-4 py-3 rounded-xl border transition-all group", isDragOverlay ? "bg-white dark:bg-gray-800 border-brand-500 shadow-xl scale-[1.02] ring-2 ring-brand-500" : isDragging ? "opacity-30 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700" : `${depthBg} border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-sm`, ].join(" "); // Indent padding: 0 for root, 32px per level const indentPx = depth * 32; // Find parent name for display const parentClient = client.parentId ? allClients.find((c) => c.id === client.parentId) : null; return (
0 ? "relative" : undefined} > {/* Tree connector line */} {depth > 0 && !isDragOverlay && (
)}
{/* Expand/collapse toggle */}
{hasChildren ? ( ) : ( )}
{/* Drag handle */} {/* Name + parent info */}
{editingName ? ( setNameValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") saveName(); if (e.key === "Escape") { setNameValue(client.name); setEditingName(false); } }} onBlur={saveName} className="w-full px-2 py-1 -ml-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400" /> ) : (
{/* Children count badge */} {hasChildren && ( {childCount} )}
)}
{/* Parent selector */}
{showParentSelector ? (
{ if (newParentId !== client.parentId) { onUpdateParent(client.id, newParentId); } setShowParentSelector(false); }} />
) : ( )}
{/* Tags */}
{client.tags.map((tag) => ( { onUpdateTags(client.id, client.tags.filter((t) => t !== tag)); }} /> ))} {addingTag ? ( { onUpdateTags(client.id, [...client.tags, tag]); }} onClose={() => setAddingTag(false)} /> ) : ( )}
{/* Sort order */}
{editingSortOrder ? ( setSortOrderValue(parseInt(e.target.value) || 0)} onKeyDown={(e) => { if (e.key === "Enter") saveSortOrder(); if (e.key === "Escape") { setSortOrderValue(client.sortOrder); setEditingSortOrder(false); } }} onBlur={saveSortOrder} className="w-full px-1 py-0.5 text-xs text-center rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-600 dark:text-gray-400 focus:outline-none focus:ring-1 focus:ring-brand-400" /> ) : ( )}
{/* Delete */}
{confirmDelete ? (
) : ( )}
); } // --------------------------------------------------------------------------- // Main component // --------------------------------------------------------------------------- export function ClientsAdminClient() { const [search, setSearch] = useState(""); const [newName, setNewName] = useState(""); const [newParentId, setNewParentId] = useState(null); const [error, setError] = useState(null); const [activeId, setActiveId] = useState(null); const [collapsedIds, setCollapsedIds] = useState>(new Set()); const newInputRef = useRef(null); const utils = trpc.useUtils(); const { data: rawList, isLoading } = trpc.clientEntity.list.useQuery(); const clients = useMemo(() => { return ((rawList ?? []) as unknown as ClientRow[]).slice().sort( (a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name), ); }, [rawList]); // Map for fast lookup const clientMap = useMemo(() => { const m = new Map(); for (const c of clients) m.set(c.id, c); return m; }, [clients]); // All known tags across all clients for auto-suggest const allKnownTags = useMemo(() => { const set = new Set(); for (const c of clients) { for (const t of c.tags ?? []) set.add(t); } return Array.from(set).sort(); }, [clients]); // Build tree, then flatten with search filtering const tree = useMemo(() => buildTree(clients), [clients]); // When searching: filter matching clients and include their ancestors const displayNodes = useMemo(() => { if (!search.trim()) { return flattenTree(tree, collapsedIds); } const lower = search.toLowerCase(); // Find IDs of clients matching the search const matchingIds = new Set(); for (const c of clients) { if ( c.name.toLowerCase().includes(lower) || (c.code ?? "").toLowerCase().includes(lower) || (c.tags ?? []).some((t) => t.toLowerCase().includes(lower)) ) { matchingIds.add(c.id); } } // Also include all ancestors of matching clients so tree path is visible const visibleIds = new Set(matchingIds); for (const id of matchingIds) { const ancestors = getAncestorIds(id, clientMap); for (const aid of ancestors) visibleIds.add(aid); } // Build tree from only visible clients const visibleClients = clients.filter((c) => visibleIds.has(c.id)); const filteredTree = buildTree(visibleClients); // When searching, show all nodes expanded (don't respect collapsed state) return flattenTree(filteredTree, new Set()); }, [search, tree, collapsedIds, clients, clientMap]); // Drag sensors const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), ); // Mutations const invalidateAll = useCallback(() => { void utils.clientEntity.list.invalidate(); void utils.clientEntity.getTree.invalidate(); }, [utils]); const createMut = trpc.clientEntity.create.useMutation({ onSuccess: () => { invalidateAll(); setNewName(""); setNewParentId(null); }, onError: (e) => setError(e.message), }); const updateMut = trpc.clientEntity.update.useMutation({ onSuccess: invalidateAll, onError: (e) => setError(e.message), }); const deleteMut = trpc.clientEntity.delete.useMutation({ onSuccess: invalidateAll, onError: (e) => setError(e.message), }); const batchSortMut = trpc.clientEntity.batchUpdateSortOrder.useMutation({ onSuccess: invalidateAll, onError: (e) => setError(e.message), }); // Handlers function handleCreate() { const trimmed = newName.trim(); if (!trimmed) return; // Compute max sortOrder among siblings const siblings = clients.filter((c) => newParentId ? c.parentId === newParentId : !c.parentId, ); const maxSort = siblings.reduce((max, c) => Math.max(max, c.sortOrder), 0); createMut.mutate({ name: trimmed, sortOrder: maxSort + 1, ...(newParentId ? { parentId: newParentId } : {}), }); } function handleUpdateName(id: string, name: string) { updateMut.mutate({ id, data: { name } }); } function handleUpdateSortOrder(id: string, sortOrder: number) { updateMut.mutate({ id, data: { sortOrder } }); } function handleUpdateTags(id: string, tags: string[]) { updateMut.mutate({ id, data: { tags } }); } function handleUpdateParent(id: string, parentId: string | null) { updateMut.mutate({ id, data: { parentId } }); } function handleDelete(id: string) { deleteMut.mutate({ id }); } function handleToggleExpand(id: string) { setCollapsedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); } // Drag and drop — moves the whole subtree with the dragged client function handleDragStart(event: DragStartEvent) { setActiveId(event.active.id as string); } function handleDragEnd(event: DragEndEvent) { setActiveId(null); const { active, over } = event; if (!over || active.id === over.id) return; const oldIndex = displayNodes.findIndex((c) => c.id === active.id); const newIndex = displayNodes.findIndex((c) => c.id === over.id); if (oldIndex === -1 || newIndex === -1) return; // Get the dragged node and all its descendants (the subtree) const draggedId = active.id as string; const descendantIds = getDescendantIds(draggedId, clients); // Filter display nodes to only siblings at the same parent level as the dragged node const draggedClient = clientMap.get(draggedId); if (!draggedClient) return; const overClient = displayNodes[newIndex]; if (!overClient) return; // Only allow reorder among siblings (same parentId) if (draggedClient.parentId !== overClient.parentId) return; // Get sibling nodes in display order (excluding descendants of dragged) const siblings = displayNodes.filter( (c) => c.parentId === draggedClient.parentId && !descendantIds.has(c.id), ); const sibOldIndex = siblings.findIndex((c) => c.id === active.id); const sibNewIndex = siblings.findIndex((c) => c.id === over.id); if (sibOldIndex === -1 || sibNewIndex === -1) return; const reordered = arrayMove(siblings, sibOldIndex, sibNewIndex); const updates = reordered.map((c, i) => ({ id: c.id, sortOrder: i })); // Optimistic update utils.clientEntity.list.setData(undefined, (prev) => { if (!prev) return prev; const sortMap = new Map(updates.map((u) => [u.id, u.sortOrder])); return prev.map((c) => { const newSort = sortMap.get(c.id); if (newSort !== undefined) return { ...c, sortOrder: newSort }; return c; }) as typeof prev; }); batchSortMut.mutate(updates); } const activeClient = activeId ? displayNodes.find((c) => c.id === activeId) : null; return (
{/* Header */}

Clients

Manage clients for project assignment and chargeability reporting{" "}

{/* Add new client -- sticky row */}
setNewName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") handleCreate(); }} placeholder="New client name..." className="flex-1 border border-gray-300 dark:border-gray-600 rounded-xl px-4 py-2.5 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:border-transparent" />
{/* Search/filter */}
setSearch(e.target.value)} placeholder="Filter by name or tag..." className="border border-gray-300 dark:border-gray-600 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-full sm:w-72 bg-white dark:bg-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500" /> {!search && clients.length > 0 && ( )}
{/* Error */} {error && (
{error}
)} {/* Client list */}
{isLoading && (
Loading...
)} {!isLoading && displayNodes.length === 0 && (
{search ? "No clients match your filter." : "No clients yet. Add one above."}
)} {!isLoading && displayNodes.length > 0 && ( c.id)} strategy={verticalListSortingStrategy} > {displayNodes.map((client) => ( ))} {activeClient ? ( {}} onUpdateSortOrder={() => {}} onUpdateTags={() => {}} onUpdateParent={() => {}} onDelete={() => {}} onToggleExpand={() => {}} isExpanded={false} isDragOverlay /> ) : null} )}
{/* Count */} {!isLoading && clients.length > 0 && (
{displayNodes.length} of {clients.length} client{clients.length !== 1 ? "s" : ""} {clients.filter((c) => c.parentId).length > 0 && ( ({clients.filter((c) => !c.parentId).length} top-level, {clients.filter((c) => c.parentId).length} nested) )}
)}
); }