diff --git a/apps/web/src/components/admin/ClientsAdminClient.tsx b/apps/web/src/components/admin/ClientsAdminClient.tsx index 4bcc889..043b6fe 100644 --- a/apps/web/src/components/admin/ClientsAdminClient.tsx +++ b/apps/web/src/components/admin/ClientsAdminClient.tsx @@ -37,8 +37,91 @@ type ClientRow = { _count?: { children: number; projects: number }; }; +type TreeNode = ClientRow & { + children: TreeNode[]; + depth: number; +}; + // --------------------------------------------------------------------------- -// Tag color palette — deterministic color from tag name hash +// 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 = [ @@ -206,25 +289,100 @@ function GripIcon({ className }: { className?: string }) { ); } +// --------------------------------------------------------------------------- +// 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: ClientRow; + 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 { @@ -247,6 +405,7 @@ function SortableClientCard({ 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); @@ -284,161 +443,257 @@ function SortableClientCard({ 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" - : "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-sm", + : `${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 ( -
- {/* Drag handle */} - - - {/* Name */} -
- {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" - /> - ) : ( - - )} -
- - {/* 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 ? ( -
+ {/* 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); + } }} - className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700" - > - Delete - + 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 ? ( +
+ + +
+ ) : ( + + )} +
); @@ -451,8 +706,10 @@ function SortableClientCard({ 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(); @@ -464,6 +721,13 @@ export function ClientsAdminClient() { ); }, [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(); @@ -473,17 +737,41 @@ export function ClientsAdminClient() { return Array.from(set).sort(); }, [clients]); - // Filtered list - const filteredClients = useMemo(() => { - if (!search.trim()) return 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(); - return clients.filter( - (c) => + // 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)), - ); - }, [clients, search]); + (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( @@ -501,6 +789,7 @@ export function ClientsAdminClient() { onSuccess: () => { invalidateAll(); setNewName(""); + setNewParentId(null); }, onError: (e) => setError(e.message), }); @@ -524,8 +813,16 @@ export function ClientsAdminClient() { function handleCreate() { const trimmed = newName.trim(); if (!trimmed) return; - const maxSort = clients.reduce((max, c) => Math.max(max, c.sortOrder), 0); - createMut.mutate({ name: trimmed, sortOrder: maxSort + 1 }); + // 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) { @@ -540,11 +837,27 @@ export function ClientsAdminClient() { updateMut.mutate({ id, data: { tags } }); } + function handleUpdateParent(id: string, parentId: string | null) { + updateMut.mutate({ id, data: { parentId } }); + } + function handleDelete(id: string) { deleteMut.mutate({ id }); } - // Drag and drop + 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); } @@ -554,11 +867,34 @@ export function ClientsAdminClient() { const { active, over } = event; if (!over || active.id === over.id) return; - const oldIndex = filteredClients.findIndex((c) => c.id === active.id); - const newIndex = filteredClients.findIndex((c) => c.id === over.id); + const oldIndex = displayNodes.findIndex((c) => c.id === active.id); + const newIndex = displayNodes.findIndex((c) => c.id === over.id); if (oldIndex === -1 || newIndex === -1) return; - const reordered = arrayMove(filteredClients, oldIndex, newIndex); + // 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 @@ -575,7 +911,9 @@ export function ClientsAdminClient() { batchSortMut.mutate(updates); } - const activeClient = activeId ? filteredClients.find((c) => c.id === activeId) : null; + const activeClient = activeId + ? displayNodes.find((c) => c.id === activeId) + : null; return (
@@ -584,11 +922,11 @@ export function ClientsAdminClient() {

Clients

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

- {/* Add new client — sticky row */} + {/* Add new client -- sticky row */}
+ + )}
{/* Error */} @@ -642,13 +1012,13 @@ export function ClientsAdminClient() {
Loading...
)} - {!isLoading && filteredClients.length === 0 && ( + {!isLoading && displayNodes.length === 0 && (
{search ? "No clients match your filter." : "No clients yet. Add one above."}
)} - {!isLoading && filteredClients.length > 0 && ( + {!isLoading && displayNodes.length > 0 && ( c.id)} + items={displayNodes.map((c) => c.id)} strategy={verticalListSortingStrategy} > - {filteredClients.map((client) => ( + {displayNodes.map((client) => ( ))} @@ -675,12 +1049,16 @@ export function ClientsAdminClient() { {activeClient ? ( {}} onUpdateSortOrder={() => {}} onUpdateTags={() => {}} + onUpdateParent={() => {}} onDelete={() => {}} + onToggleExpand={() => {}} + isExpanded={false} isDragOverlay /> ) : null} @@ -692,7 +1070,12 @@ export function ClientsAdminClient() { {/* Count */} {!isLoading && clients.length > 0 && (
- {filteredClients.length} of {clients.length} client{clients.length !== 1 ? "s" : ""} + {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) + + )}
)}