feat: Clients admin — hierarchical tree view with parent/child support

- Tree structure: clients rendered as indented tree with visual connectors
- Expand/collapse: chevron toggle, children count badge, expand/collapse all
- Parent selector: dropdown on add + "Move..." inline picker on each client
  (excludes self and descendants to prevent circular refs)
- Search: shows matching clients AND their ancestors for context
- Depth visualization: alternating backgrounds, border-l connectors
- Drag-and-drop: preserved, reorders among siblings only

No API changes needed — router already supported parentId in CRUD.

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-22 21:14:51 +01:00
parent 03922764db
commit 1f4c230b8b
@@ -37,8 +37,91 @@ type ClientRow = {
_count?: { children: number; projects: number }; _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<string, TreeNode>();
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<string>): 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<string> {
const ids = new Set<string>();
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<string, ClientRow>): Set<string> {
const ids = new Set<string>();
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 = [ 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 (
<svg
className={`transition-transform duration-150 ${expanded ? "rotate-90" : "rotate-0"} ${className ?? ""}`}
width="14"
height="14"
viewBox="0 0 14 14"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M5 3l4 4-4 4" />
</svg>
);
}
// ---------------------------------------------------------------------------
// 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<string>();
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 (
<select
value={currentParentId ?? ""}
onChange={(e) => onChange(e.target.value || null)}
className="px-2 py-1 text-xs rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-1 focus:ring-brand-400 max-w-[160px]"
>
<option value="">(Top Level)</option>
{options.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
);
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Sortable client card // Sortable client card
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function SortableClientCard({ function SortableClientCard({
client, client,
allClients,
allKnownTags, allKnownTags,
onUpdateName, onUpdateName,
onUpdateSortOrder, onUpdateSortOrder,
onUpdateTags, onUpdateTags,
onUpdateParent,
onDelete, onDelete,
onToggleExpand,
isExpanded,
isDragOverlay, isDragOverlay,
}: { }: {
client: ClientRow; client: TreeNode;
allClients: ClientRow[];
allKnownTags: string[]; allKnownTags: string[];
onUpdateName: (id: string, name: string) => void; onUpdateName: (id: string, name: string) => void;
onUpdateSortOrder: (id: string, sortOrder: number) => void; onUpdateSortOrder: (id: string, sortOrder: number) => void;
onUpdateTags: (id: string, tags: string[]) => void; onUpdateTags: (id: string, tags: string[]) => void;
onUpdateParent: (id: string, parentId: string | null) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
onToggleExpand: (id: string) => void;
isExpanded: boolean;
isDragOverlay?: boolean; isDragOverlay?: boolean;
}) { }) {
const { const {
@@ -247,6 +405,7 @@ function SortableClientCard({
const [sortOrderValue, setSortOrderValue] = useState(client.sortOrder); const [sortOrderValue, setSortOrderValue] = useState(client.sortOrder);
const [addingTag, setAddingTag] = useState(false); const [addingTag, setAddingTag] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false);
const [showParentSelector, setShowParentSelector] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null); const nameInputRef = useRef<HTMLInputElement>(null);
const sortInputRef = useRef<HTMLInputElement>(null); const sortInputRef = useRef<HTMLInputElement>(null);
@@ -284,161 +443,257 @@ function SortableClientCard({
setEditingSortOrder(false); 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 = [ const cardClasses = [
"flex items-center gap-3 px-4 py-3 rounded-xl border transition-all group", "flex items-center gap-3 px-4 py-3 rounded-xl border transition-all group",
isDragOverlay isDragOverlay
? "bg-white dark:bg-gray-800 border-brand-500 shadow-xl scale-[1.02] ring-2 ring-brand-500" ? "bg-white dark:bg-gray-800 border-brand-500 shadow-xl scale-[1.02] ring-2 ring-brand-500"
: isDragging : isDragging
? "opacity-30 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700" ? "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(" "); ].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 ( return (
<div ref={setNodeRef} style={style} className={cardClasses}> <div
{/* Drag handle */} ref={setNodeRef}
<button style={style}
type="button" className={depth > 0 ? "relative" : undefined}
className="cursor-grab active:cursor-grabbing text-gray-300 dark:text-gray-600 hover:text-gray-500 dark:hover:text-gray-400 flex-shrink-0 touch-none" >
{...attributes} {/* Tree connector line */}
{...listeners} {depth > 0 && !isDragOverlay && (
<div
className="absolute top-0 bottom-0 border-l-2 border-gray-300 dark:border-gray-600"
style={{ left: `${indentPx - 16}px` }}
/>
)}
<div
className={cardClasses}
style={{ marginLeft: `${indentPx}px` }}
> >
<GripIcon /> {/* Expand/collapse toggle */}
</button> <div className="flex-shrink-0 w-5">
{hasChildren ? (
{/* Name */}
<div className="flex-1 min-w-0">
{editingName ? (
<input
ref={nameInputRef}
type="text"
value={nameValue}
onChange={(e) => 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"
/>
) : (
<button
type="button"
onClick={() => setEditingName(true)}
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-brand-600 dark:hover:text-brand-400 truncate text-left w-full cursor-text"
title="Click to edit name"
>
{client.name}
{client.code && (
<span className="text-gray-400 dark:text-gray-500 font-mono ml-2 text-xs">
[{client.code}]
</span>
)}
{!client.isActive && (
<span className="ml-2 text-xs text-gray-400 italic">inactive</span>
)}
</button>
)}
</div>
{/* Tags */}
<div className="flex items-center gap-1 flex-wrap flex-shrink-0">
{client.tags.map((tag) => (
<TagPill
key={tag}
tag={tag}
onRemove={() => {
onUpdateTags(client.id, client.tags.filter((t) => t !== tag));
}}
/>
))}
{addingTag ? (
<TagAdder
existingTags={client.tags}
allKnownTags={allKnownTags}
onAdd={(tag) => {
onUpdateTags(client.id, [...client.tags, tag]);
}}
onClose={() => setAddingTag(false)}
/>
) : (
<button
type="button"
onClick={() => setAddingTag(true)}
className="inline-flex items-center justify-center w-5 h-5 rounded-full text-xs text-gray-400 dark:text-gray-500 hover:text-brand-500 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
title="Add tag"
>
+
</button>
)}
</div>
{/* Sort order */}
<div className="flex-shrink-0 w-12">
{editingSortOrder ? (
<input
ref={sortInputRef}
type="number"
value={sortOrderValue}
onChange={(e) => 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"
/>
) : (
<button
type="button"
onClick={() => setEditingSortOrder(true)}
className="w-full text-xs text-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 cursor-text tabular-nums"
title="Click to edit sort order"
>
#{client.sortOrder}
</button>
)}
</div>
{/* Delete */}
<div className="flex-shrink-0">
{confirmDelete ? (
<div className="flex items-center gap-1">
<button <button
type="button" type="button"
onClick={() => { onClick={() => onToggleExpand(client.id)}
onDelete(client.id); className="flex items-center justify-center w-5 h-5 rounded text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
setConfirmDelete(false); title={isExpanded ? "Collapse" : "Expand"}
>
<ChevronIcon expanded={isExpanded} />
</button>
) : (
<span className="block w-5" />
)}
</div>
{/* Drag handle */}
<button
type="button"
className="cursor-grab active:cursor-grabbing text-gray-300 dark:text-gray-600 hover:text-gray-500 dark:hover:text-gray-400 flex-shrink-0 touch-none"
{...attributes}
{...listeners}
>
<GripIcon />
</button>
{/* Name + parent info */}
<div className="flex-1 min-w-0">
{editingName ? (
<input
ref={nameInputRef}
type="text"
value={nameValue}
onChange={(e) => 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" 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"
Delete />
</button> ) : (
<div className="flex items-center gap-2 min-w-0">
<button
type="button"
onClick={() => setEditingName(true)}
className="text-sm font-medium text-gray-900 dark:text-gray-100 hover:text-brand-600 dark:hover:text-brand-400 truncate text-left cursor-text"
title="Click to edit name"
>
{client.name}
{client.code && (
<span className="text-gray-400 dark:text-gray-500 font-mono ml-2 text-xs">
[{client.code}]
</span>
)}
{!client.isActive && (
<span className="ml-2 text-xs text-gray-400 italic">inactive</span>
)}
</button>
{/* Children count badge */}
{hasChildren && (
<span className="inline-flex items-center px-1.5 py-0.5 rounded-full text-[10px] font-medium bg-gray-100 dark:bg-gray-700 text-gray-500 dark:text-gray-400 tabular-nums flex-shrink-0">
{childCount}
</span>
)}
</div>
)}
</div>
{/* Parent selector */}
<div className="flex-shrink-0">
{showParentSelector ? (
<div className="flex items-center gap-1">
<ParentSelector
clients={allClients}
currentId={client.id}
currentParentId={client.parentId}
onChange={(newParentId) => {
if (newParentId !== client.parentId) {
onUpdateParent(client.id, newParentId);
}
setShowParentSelector(false);
}}
/>
<button
type="button"
onClick={() => setShowParentSelector(false)}
className="text-xs text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
&times;
</button>
</div>
) : (
<button <button
type="button" type="button"
onClick={() => setConfirmDelete(false)} onClick={() => setShowParentSelector(true)}
className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300" className="text-[11px] text-gray-400 dark:text-gray-500 hover:text-brand-500 dark:hover:text-brand-400 opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap"
title="Change parent"
> >
Cancel {parentClient ? `\u2190 ${parentClient.name}` : "\u2190 Move..."}
</button> </button>
</div> )}
) : ( </div>
<button
type="button" {/* Tags */}
onClick={() => setConfirmDelete(true)} <div className="flex items-center gap-1 flex-wrap flex-shrink-0">
className="text-gray-300 dark:text-gray-600 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity" {client.tags.map((tag) => (
title="Delete client" <TagPill
> key={tag}
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5"> tag={tag}
<path d="M2 4h12M5.33 4V2.67a1.33 1.33 0 011.34-1.34h2.66a1.33 1.33 0 011.34 1.34V4m2 0v9.33a1.33 1.33 0 01-1.34 1.34H4.67a1.33 1.33 0 01-1.34-1.34V4h9.34z" /> onRemove={() => {
</svg> onUpdateTags(client.id, client.tags.filter((t) => t !== tag));
</button> }}
)} />
))}
{addingTag ? (
<TagAdder
existingTags={client.tags}
allKnownTags={allKnownTags}
onAdd={(tag) => {
onUpdateTags(client.id, [...client.tags, tag]);
}}
onClose={() => setAddingTag(false)}
/>
) : (
<button
type="button"
onClick={() => setAddingTag(true)}
className="inline-flex items-center justify-center w-5 h-5 rounded-full text-xs text-gray-400 dark:text-gray-500 hover:text-brand-500 dark:hover:text-brand-400 hover:bg-gray-100 dark:hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
title="Add tag"
>
+
</button>
)}
</div>
{/* Sort order */}
<div className="flex-shrink-0 w-12">
{editingSortOrder ? (
<input
ref={sortInputRef}
type="number"
value={sortOrderValue}
onChange={(e) => 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"
/>
) : (
<button
type="button"
onClick={() => setEditingSortOrder(true)}
className="w-full text-xs text-center text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 cursor-text tabular-nums"
title="Click to edit sort order"
>
#{client.sortOrder}
</button>
)}
</div>
{/* Delete */}
<div className="flex-shrink-0">
{confirmDelete ? (
<div className="flex items-center gap-1">
<button
type="button"
onClick={() => {
onDelete(client.id);
setConfirmDelete(false);
}}
className="text-xs px-2 py-1 bg-red-600 text-white rounded hover:bg-red-700"
>
Delete
</button>
<button
type="button"
onClick={() => setConfirmDelete(false)}
className="text-xs px-2 py-1 text-gray-500 hover:text-gray-700 dark:hover:text-gray-300"
>
Cancel
</button>
</div>
) : (
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="text-gray-300 dark:text-gray-600 hover:text-red-500 dark:hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete client"
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 4h12M5.33 4V2.67a1.33 1.33 0 011.34-1.34h2.66a1.33 1.33 0 011.34 1.34V4m2 0v9.33a1.33 1.33 0 01-1.34 1.34H4.67a1.33 1.33 0 01-1.34-1.34V4h9.34z" />
</svg>
</button>
)}
</div>
</div> </div>
</div> </div>
); );
@@ -451,8 +706,10 @@ function SortableClientCard({
export function ClientsAdminClient() { export function ClientsAdminClient() {
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [newParentId, setNewParentId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
const newInputRef = useRef<HTMLInputElement>(null); const newInputRef = useRef<HTMLInputElement>(null);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
@@ -464,6 +721,13 @@ export function ClientsAdminClient() {
); );
}, [rawList]); }, [rawList]);
// Map for fast lookup
const clientMap = useMemo(() => {
const m = new Map<string, ClientRow>();
for (const c of clients) m.set(c.id, c);
return m;
}, [clients]);
// All known tags across all clients for auto-suggest // All known tags across all clients for auto-suggest
const allKnownTags = useMemo(() => { const allKnownTags = useMemo(() => {
const set = new Set<string>(); const set = new Set<string>();
@@ -473,17 +737,41 @@ export function ClientsAdminClient() {
return Array.from(set).sort(); return Array.from(set).sort();
}, [clients]); }, [clients]);
// Filtered list // Build tree, then flatten with search filtering
const filteredClients = useMemo(() => { const tree = useMemo(() => buildTree(clients), [clients]);
if (!search.trim()) return clients;
// When searching: filter matching clients and include their ancestors
const displayNodes = useMemo(() => {
if (!search.trim()) {
return flattenTree(tree, collapsedIds);
}
const lower = search.toLowerCase(); const lower = search.toLowerCase();
return clients.filter( // Find IDs of clients matching the search
(c) => const matchingIds = new Set<string>();
for (const c of clients) {
if (
c.name.toLowerCase().includes(lower) || c.name.toLowerCase().includes(lower) ||
(c.code ?? "").toLowerCase().includes(lower) || (c.code ?? "").toLowerCase().includes(lower) ||
(c.tags ?? []).some((t) => t.toLowerCase().includes(lower)), (c.tags ?? []).some((t) => t.toLowerCase().includes(lower))
); ) {
}, [clients, search]); 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 // Drag sensors
const sensors = useSensors( const sensors = useSensors(
@@ -501,6 +789,7 @@ export function ClientsAdminClient() {
onSuccess: () => { onSuccess: () => {
invalidateAll(); invalidateAll();
setNewName(""); setNewName("");
setNewParentId(null);
}, },
onError: (e) => setError(e.message), onError: (e) => setError(e.message),
}); });
@@ -524,8 +813,16 @@ export function ClientsAdminClient() {
function handleCreate() { function handleCreate() {
const trimmed = newName.trim(); const trimmed = newName.trim();
if (!trimmed) return; if (!trimmed) return;
const maxSort = clients.reduce((max, c) => Math.max(max, c.sortOrder), 0); // Compute max sortOrder among siblings
createMut.mutate({ name: trimmed, sortOrder: maxSort + 1 }); 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) { function handleUpdateName(id: string, name: string) {
@@ -540,11 +837,27 @@ export function ClientsAdminClient() {
updateMut.mutate({ id, data: { tags } }); updateMut.mutate({ id, data: { tags } });
} }
function handleUpdateParent(id: string, parentId: string | null) {
updateMut.mutate({ id, data: { parentId } });
}
function handleDelete(id: string) { function handleDelete(id: string) {
deleteMut.mutate({ id }); 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) { function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string); setActiveId(event.active.id as string);
} }
@@ -554,11 +867,34 @@ export function ClientsAdminClient() {
const { active, over } = event; const { active, over } = event;
if (!over || active.id === over.id) return; if (!over || active.id === over.id) return;
const oldIndex = filteredClients.findIndex((c) => c.id === active.id); const oldIndex = displayNodes.findIndex((c) => c.id === active.id);
const newIndex = filteredClients.findIndex((c) => c.id === over.id); const newIndex = displayNodes.findIndex((c) => c.id === over.id);
if (oldIndex === -1 || newIndex === -1) return; 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 })); const updates = reordered.map((c, i) => ({ id: c.id, sortOrder: i }));
// Optimistic update // Optimistic update
@@ -575,7 +911,9 @@ export function ClientsAdminClient() {
batchSortMut.mutate(updates); batchSortMut.mutate(updates);
} }
const activeClient = activeId ? filteredClients.find((c) => c.id === activeId) : null; const activeClient = activeId
? displayNodes.find((c) => c.id === activeId)
: null;
return ( return (
<div className="p-6 max-w-4xl mx-auto"> <div className="p-6 max-w-4xl mx-auto">
@@ -584,11 +922,11 @@ export function ClientsAdminClient() {
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Clients</h1> <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-50">Clients</h1>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1"> <p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
Manage clients for project assignment and chargeability reporting{" "} Manage clients for project assignment and chargeability reporting{" "}
<InfoTooltip content="Clients are companies or brands that commission projects. Drag to reorder, click to edit inline, and use tags for categorization." /> <InfoTooltip content="Clients are companies or brands that commission projects. Use the tree hierarchy for parent/child relationships. Drag to reorder within a level, click to edit inline, and use tags for categorization." />
</p> </p>
</div> </div>
{/* Add new client sticky row */} {/* Add new client -- sticky row */}
<div className="mb-4 flex items-center gap-2"> <div className="mb-4 flex items-center gap-2">
<input <input
ref={newInputRef} ref={newInputRef}
@@ -601,6 +939,19 @@ export function ClientsAdminClient() {
placeholder="New client name..." 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" 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"
/> />
<select
value={newParentId ?? ""}
onChange={(e) => setNewParentId(e.target.value || null)}
className="border border-gray-300 dark:border-gray-600 rounded-xl px-3 py-2.5 text-sm bg-white dark:bg-gray-900 text-gray-700 dark:text-gray-300 focus:outline-none focus:ring-2 focus:ring-brand-400 max-w-[200px]"
title="Parent client (optional)"
>
<option value="">No parent (top level)</option>
{clients.filter((c) => c.isActive).map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
<button <button
type="button" type="button"
onClick={handleCreate} onClick={handleCreate}
@@ -612,7 +963,7 @@ export function ClientsAdminClient() {
</div> </div>
{/* Search/filter */} {/* Search/filter */}
<div className="mb-4"> <div className="mb-4 flex items-center gap-2">
<input <input
type="search" type="search"
value={search} value={search}
@@ -620,6 +971,25 @@ export function ClientsAdminClient() {
placeholder="Filter by name or tag..." 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" 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 && (
<button
type="button"
onClick={() => {
// Toggle collapse all / expand all
const allWithChildren = clients.filter(
(c) => (c._count?.children ?? 0) > 0,
);
if (collapsedIds.size === 0) {
setCollapsedIds(new Set(allWithChildren.map((c) => c.id)));
} else {
setCollapsedIds(new Set());
}
}}
className="text-xs text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-300 whitespace-nowrap px-2 py-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
>
{collapsedIds.size === 0 ? "Collapse all" : "Expand all"}
</button>
)}
</div> </div>
{/* Error */} {/* Error */}
@@ -642,13 +1012,13 @@ export function ClientsAdminClient() {
<div className="text-center py-12 text-gray-400 dark:text-gray-500">Loading...</div> <div className="text-center py-12 text-gray-400 dark:text-gray-500">Loading...</div>
)} )}
{!isLoading && filteredClients.length === 0 && ( {!isLoading && displayNodes.length === 0 && (
<div className="text-center py-12 text-gray-400 dark:text-gray-500 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700"> <div className="text-center py-12 text-gray-400 dark:text-gray-500 bg-white dark:bg-gray-800 rounded-xl border border-gray-200 dark:border-gray-700">
{search ? "No clients match your filter." : "No clients yet. Add one above."} {search ? "No clients match your filter." : "No clients yet. Add one above."}
</div> </div>
)} )}
{!isLoading && filteredClients.length > 0 && ( {!isLoading && displayNodes.length > 0 && (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
@@ -656,18 +1026,22 @@ export function ClientsAdminClient() {
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <SortableContext
items={filteredClients.map((c) => c.id)} items={displayNodes.map((c) => c.id)}
strategy={verticalListSortingStrategy} strategy={verticalListSortingStrategy}
> >
{filteredClients.map((client) => ( {displayNodes.map((client) => (
<SortableClientCard <SortableClientCard
key={client.id} key={client.id}
client={client} client={client}
allClients={clients}
allKnownTags={allKnownTags} allKnownTags={allKnownTags}
onUpdateName={handleUpdateName} onUpdateName={handleUpdateName}
onUpdateSortOrder={handleUpdateSortOrder} onUpdateSortOrder={handleUpdateSortOrder}
onUpdateTags={handleUpdateTags} onUpdateTags={handleUpdateTags}
onUpdateParent={handleUpdateParent}
onDelete={handleDelete} onDelete={handleDelete}
onToggleExpand={handleToggleExpand}
isExpanded={!collapsedIds.has(client.id)}
/> />
))} ))}
</SortableContext> </SortableContext>
@@ -675,12 +1049,16 @@ export function ClientsAdminClient() {
<DragOverlay> <DragOverlay>
{activeClient ? ( {activeClient ? (
<SortableClientCard <SortableClientCard
client={activeClient} client={{ ...activeClient, depth: 0 }}
allClients={clients}
allKnownTags={allKnownTags} allKnownTags={allKnownTags}
onUpdateName={() => {}} onUpdateName={() => {}}
onUpdateSortOrder={() => {}} onUpdateSortOrder={() => {}}
onUpdateTags={() => {}} onUpdateTags={() => {}}
onUpdateParent={() => {}}
onDelete={() => {}} onDelete={() => {}}
onToggleExpand={() => {}}
isExpanded={false}
isDragOverlay isDragOverlay
/> />
) : null} ) : null}
@@ -692,7 +1070,12 @@ export function ClientsAdminClient() {
{/* Count */} {/* Count */}
{!isLoading && clients.length > 0 && ( {!isLoading && clients.length > 0 && (
<div className="mt-4 text-xs text-gray-400 dark:text-gray-500 text-right"> <div className="mt-4 text-xs text-gray-400 dark:text-gray-500 text-right">
{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 && (
<span className="ml-2">
({clients.filter((c) => !c.parentId).length} top-level, {clients.filter((c) => c.parentId).length} nested)
</span>
)}
</div> </div>
)} )}
</div> </div>