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:
@@ -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"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user