Files
CapaKraken/apps/web/src/components/admin/ClientsAdminClient.tsx
T

1107 lines
38 KiB
TypeScript

"use client";
import { createPortal } from "react-dom";
import {
closestCenter,
DndContext,
DragOverlay,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from "@dnd-kit/core";
import type { DragEndEvent, DragStartEvent } from "@dnd-kit/core";
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
import { trpc } from "~/lib/trpc/client.js";
import { useAnchoredOverlay } from "~/hooks/useAnchoredOverlay.js";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ClientRow = {
id: string;
name: string;
code: string | null;
parentId: string | null;
sortOrder: number;
isActive: boolean;
tags: string[];
_count?: { children: number; projects: number };
};
type TreeNode = ClientRow & {
children: TreeNode[];
depth: number;
};
// ---------------------------------------------------------------------------
// Tree helpers
// ---------------------------------------------------------------------------
function buildTree(clients: ClientRow[]): TreeNode[] {
const map = new Map<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 = [
{ bg: "bg-purple-100 dark:bg-purple-900/40", text: "text-purple-700 dark:text-purple-300", border: "border-purple-200 dark:border-purple-700" },
{ bg: "bg-emerald-100 dark:bg-emerald-900/40", text: "text-emerald-700 dark:text-emerald-300", border: "border-emerald-200 dark:border-emerald-700" },
{ bg: "bg-amber-100 dark:bg-amber-900/40", text: "text-amber-700 dark:text-amber-300", border: "border-amber-200 dark:border-amber-700" },
{ bg: "bg-rose-100 dark:bg-rose-900/40", text: "text-rose-700 dark:text-rose-300", border: "border-rose-200 dark:border-rose-700" },
{ bg: "bg-sky-100 dark:bg-sky-900/40", text: "text-sky-700 dark:text-sky-300", border: "border-sky-200 dark:border-sky-700" },
{ bg: "bg-indigo-100 dark:bg-indigo-900/40", text: "text-indigo-700 dark:text-indigo-300", border: "border-indigo-200 dark:border-indigo-700" },
{ bg: "bg-teal-100 dark:bg-teal-900/40", text: "text-teal-700 dark:text-teal-300", border: "border-teal-200 dark:border-teal-700" },
{ bg: "bg-orange-100 dark:bg-orange-900/40", text: "text-orange-700 dark:text-orange-300", border: "border-orange-200 dark:border-orange-700" },
];
function hashString(str: string): number {
let hash = 0;
for (let i = 0; i < str.length; i++) {
hash = (hash << 5) - hash + str.charCodeAt(i);
hash |= 0;
}
return Math.abs(hash);
}
function getTagColor(tag: string) {
return TAG_COLORS[hashString(tag) % TAG_COLORS.length]!;
}
// ---------------------------------------------------------------------------
// Tag pill component
// ---------------------------------------------------------------------------
function TagPill({
tag,
onRemove,
}: {
tag: string;
onRemove?: () => void;
}) {
const color = getTagColor(tag);
return (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${color.bg} ${color.text} ${color.border}`}
>
{tag}
{onRemove && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
className="hover:opacity-70 leading-none text-[10px] ml-0.5"
aria-label={`Remove tag ${tag}`}
>
&times;
</button>
)}
</span>
);
}
// ---------------------------------------------------------------------------
// Inline tag adder with auto-suggest
// ---------------------------------------------------------------------------
function TagAdder({
existingTags,
allKnownTags,
onAdd,
onClose,
}: {
existingTags: string[];
allKnownTags: string[];
onAdd: (tag: string) => void;
onClose: () => void;
}) {
const [value, setValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const [showSuggestions, setShowSuggestions] = useState(false);
useEffect(() => {
inputRef.current?.focus();
}, []);
const suggestions = useMemo(() => {
if (!value.trim()) return allKnownTags.filter((t) => !existingTags.includes(t)).slice(0, 8);
const lower = value.toLowerCase();
return allKnownTags
.filter((t) => t.toLowerCase().includes(lower) && !existingTags.includes(t))
.slice(0, 8);
}, [value, allKnownTags, existingTags]);
const { panelRef, position } = useAnchoredOverlay<HTMLInputElement>({
open: showSuggestions && suggestions.length > 0,
onClose: () => {
setShowSuggestions(false);
if (!value.trim()) {
onClose();
}
},
align: "start",
triggerRef: inputRef,
});
function submit(tag: string) {
const trimmed = tag.trim();
if (!trimmed || existingTags.includes(trimmed)) return;
onAdd(trimmed);
setValue("");
}
return (
<div className="relative inline-block">
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => {
setValue(e.target.value);
setShowSuggestions(true);
}}
onFocus={() => setShowSuggestions(true)}
onBlur={() => {
// Delay to allow click on suggestion
setTimeout(() => {
setShowSuggestions(false);
if (!value.trim()) onClose();
}, 200);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit(value);
}
if (e.key === "Escape") {
onClose();
}
}}
placeholder="Tag..."
className="w-24 px-2 py-0.5 text-xs rounded-full border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-1 focus:ring-brand-400"
/>
{showSuggestions && suggestions.length > 0 && typeof document !== "undefined"
? createPortal(
<div
ref={panelRef}
className="fixed z-[9998] max-h-32 w-40 overflow-y-auto rounded-lg border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-800"
style={{
top: position.top,
left: position.left,
}}
>
{suggestions.map((s) => (
<button
key={s}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
submit(s);
setShowSuggestions(false);
}}
className="block w-full text-left px-3 py-1 text-xs text-gray-700 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700"
>
<TagPill tag={s} />
</button>
))}
</div>,
document.body,
)
: null}
</div>
);
}
// ---------------------------------------------------------------------------
// Drag handle icon
// ---------------------------------------------------------------------------
function GripIcon({ className }: { className?: string }) {
return (
<svg className={className} width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<circle cx="5" cy="3" r="1.5" />
<circle cx="11" cy="3" r="1.5" />
<circle cx="5" cy="8" r="1.5" />
<circle cx="11" cy="8" r="1.5" />
<circle cx="5" cy="13" r="1.5" />
<circle cx="11" cy="13" r="1.5" />
</svg>
);
}
// ---------------------------------------------------------------------------
// 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
// ---------------------------------------------------------------------------
function SortableClientCard({
client,
allClients,
allKnownTags,
onUpdateName,
onUpdateSortOrder,
onUpdateTags,
onUpdateParent,
onDelete,
onToggleExpand,
isExpanded,
isDragOverlay,
}: {
client: TreeNode;
allClients: ClientRow[];
allKnownTags: string[];
onUpdateName: (id: string, name: string) => void;
onUpdateSortOrder: (id: string, sortOrder: number) => void;
onUpdateTags: (id: string, tags: string[]) => void;
onUpdateParent: (id: string, parentId: string | null) => void;
onDelete: (id: string) => void;
onToggleExpand: (id: string) => void;
isExpanded: boolean;
isDragOverlay?: boolean;
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: client.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
};
const [editingName, setEditingName] = useState(false);
const [nameValue, setNameValue] = useState(client.name);
const [editingSortOrder, setEditingSortOrder] = useState(false);
const [sortOrderValue, setSortOrderValue] = useState(client.sortOrder);
const [addingTag, setAddingTag] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [showParentSelector, setShowParentSelector] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const sortInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (editingName) nameInputRef.current?.focus();
}, [editingName]);
useEffect(() => {
if (editingSortOrder) sortInputRef.current?.focus();
}, [editingSortOrder]);
// Sync external updates
useEffect(() => {
setNameValue(client.name);
}, [client.name]);
useEffect(() => {
setSortOrderValue(client.sortOrder);
}, [client.sortOrder]);
function saveName() {
const trimmed = nameValue.trim();
if (trimmed && trimmed !== client.name) {
onUpdateName(client.id, trimmed);
} else {
setNameValue(client.name);
}
setEditingName(false);
}
function saveSortOrder() {
if (sortOrderValue !== client.sortOrder) {
onUpdateSortOrder(client.id, sortOrderValue);
}
setEditingSortOrder(false);
}
const hasChildren = (client._count?.children ?? client.children.length) > 0;
const childCount = client._count?.children ?? client.children.length;
const depth = client.depth;
// Nesting depth styling: alternating subtle backgrounds
const depthBg = depth > 0
? depth % 2 === 1
? "bg-gray-50/50 dark:bg-gray-800/80"
: "bg-white dark:bg-gray-800/60"
: "bg-white dark:bg-gray-800";
const cardClasses = [
"flex items-center gap-3 px-4 py-3 rounded-xl border transition-all group",
isDragOverlay
? "bg-white dark:bg-gray-800 border-brand-500 shadow-xl scale-[1.02] ring-2 ring-brand-500"
: isDragging
? "opacity-30 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700"
: `${depthBg} border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-sm`,
].join(" ");
// Indent padding: 0 for root, 32px per level
const indentPx = depth * 32;
// Find parent name for display
const parentClient = client.parentId
? allClients.find((c) => c.id === client.parentId)
: null;
return (
<div
ref={setNodeRef}
style={style}
className={depth > 0 ? "relative" : undefined}
>
{/* Tree connector line */}
{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` }}
>
{/* Expand/collapse toggle */}
<div className="flex-shrink-0 w-5">
{hasChildren ? (
<button
type="button"
onClick={() => onToggleExpand(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"
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);
}
}}
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"
/>
) : (
<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
type="button"
onClick={() => setShowParentSelector(true)}
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"
>
{parentClient ? `\u2190 ${parentClient.name}` : "\u2190 Move..."}
</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
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>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function ClientsAdminClient() {
const [search, setSearch] = useState("");
const [newName, setNewName] = useState("");
const [newParentId, setNewParentId] = useState<string | null>(null);
const [error, setError] = 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 utils = trpc.useUtils();
const { data: rawList, isLoading } = trpc.clientEntity.list.useQuery();
const clients = useMemo(() => {
return ((rawList ?? []) as unknown as ClientRow[]).slice().sort(
(a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name),
);
}, [rawList]);
// Map for fast lookup
const clientMap = useMemo(() => {
const m = new Map<string, ClientRow>();
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<string>();
for (const c of clients) {
for (const t of c.tags ?? []) set.add(t);
}
return Array.from(set).sort();
}, [clients]);
// Build tree, then flatten with search filtering
const tree = useMemo(() => buildTree(clients), [clients]);
// When searching: filter matching clients and include their ancestors
const displayNodes = useMemo(() => {
if (!search.trim()) {
return flattenTree(tree, collapsedIds);
}
const lower = search.toLowerCase();
// Find IDs of clients matching the search
const matchingIds = new Set<string>();
for (const c of clients) {
if (
c.name.toLowerCase().includes(lower) ||
(c.code ?? "").toLowerCase().includes(lower) ||
(c.tags ?? []).some((t) => t.toLowerCase().includes(lower))
) {
matchingIds.add(c.id);
}
}
// Also include all ancestors of matching clients so tree path is visible
const visibleIds = new Set(matchingIds);
for (const id of matchingIds) {
const ancestors = getAncestorIds(id, clientMap);
for (const aid of ancestors) visibleIds.add(aid);
}
// Build tree from only visible clients
const visibleClients = clients.filter((c) => visibleIds.has(c.id));
const filteredTree = buildTree(visibleClients);
// When searching, show all nodes expanded (don't respect collapsed state)
return flattenTree(filteredTree, new Set());
}, [search, tree, collapsedIds, clients, clientMap]);
// Drag sensors
const sensors = useSensors(
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
);
// Mutations
const invalidateAll = useCallback(() => {
void utils.clientEntity.list.invalidate();
void utils.clientEntity.getTree.invalidate();
}, [utils]);
const createMut = trpc.clientEntity.create.useMutation({
onSuccess: () => {
invalidateAll();
setNewName("");
setNewParentId(null);
},
onError: (e) => setError(e.message),
});
const updateMut = trpc.clientEntity.update.useMutation({
onSuccess: invalidateAll,
onError: (e) => setError(e.message),
});
const deleteMut = trpc.clientEntity.delete.useMutation({
onSuccess: invalidateAll,
onError: (e) => setError(e.message),
});
const batchSortMut = trpc.clientEntity.batchUpdateSortOrder.useMutation({
onSuccess: invalidateAll,
onError: (e) => setError(e.message),
});
// Handlers
function handleCreate() {
const trimmed = newName.trim();
if (!trimmed) return;
// Compute max sortOrder among siblings
const siblings = clients.filter((c) =>
newParentId ? c.parentId === newParentId : !c.parentId,
);
const maxSort = siblings.reduce((max, c) => Math.max(max, c.sortOrder), 0);
createMut.mutate({
name: trimmed,
sortOrder: maxSort + 1,
...(newParentId ? { parentId: newParentId } : {}),
});
}
function handleUpdateName(id: string, name: string) {
updateMut.mutate({ id, data: { name } });
}
function handleUpdateSortOrder(id: string, sortOrder: number) {
updateMut.mutate({ id, data: { sortOrder } });
}
function handleUpdateTags(id: string, tags: string[]) {
updateMut.mutate({ id, data: { tags } });
}
function handleUpdateParent(id: string, parentId: string | null) {
updateMut.mutate({ id, data: { parentId } });
}
function handleDelete(id: string) {
deleteMut.mutate({ id });
}
function handleToggleExpand(id: string) {
setCollapsedIds((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
}
// Drag and drop — moves the whole subtree with the dragged client
function handleDragStart(event: DragStartEvent) {
setActiveId(event.active.id as string);
}
function handleDragEnd(event: DragEndEvent) {
setActiveId(null);
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldIndex = displayNodes.findIndex((c) => c.id === active.id);
const newIndex = displayNodes.findIndex((c) => c.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
// Get the dragged node and all its descendants (the subtree)
const draggedId = active.id as string;
const descendantIds = getDescendantIds(draggedId, clients);
// Filter display nodes to only siblings at the same parent level as the dragged node
const draggedClient = clientMap.get(draggedId);
if (!draggedClient) return;
const overClient = displayNodes[newIndex];
if (!overClient) return;
// Only allow reorder among siblings (same parentId)
if (draggedClient.parentId !== overClient.parentId) return;
// Get sibling nodes in display order (excluding descendants of dragged)
const siblings = displayNodes.filter(
(c) => c.parentId === draggedClient.parentId && !descendantIds.has(c.id),
);
const sibOldIndex = siblings.findIndex((c) => c.id === active.id);
const sibNewIndex = siblings.findIndex((c) => c.id === over.id);
if (sibOldIndex === -1 || sibNewIndex === -1) return;
const reordered = arrayMove(siblings, sibOldIndex, sibNewIndex);
const updates = reordered.map((c, i) => ({ id: c.id, sortOrder: i }));
// Optimistic update
utils.clientEntity.list.setData(undefined, (prev) => {
if (!prev) return prev;
const sortMap = new Map(updates.map((u) => [u.id, u.sortOrder]));
return prev.map((c) => {
const newSort = sortMap.get(c.id);
if (newSort !== undefined) return { ...c, sortOrder: newSort };
return c;
}) as typeof prev;
});
batchSortMut.mutate(updates);
}
const activeClient = activeId
? displayNodes.find((c) => c.id === activeId)
: null;
return (
<div className="p-6 max-w-4xl mx-auto">
{/* Header */}
<div className="mb-6">
<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">
Manage clients for project assignment and chargeability reporting{" "}
<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>
</div>
{/* Add new client -- sticky row */}
<div className="mb-4 flex items-center gap-2">
<input
ref={newInputRef}
type="text"
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") handleCreate();
}}
placeholder="New client name..."
className="flex-1 border border-gray-300 dark:border-gray-600 rounded-xl px-4 py-2.5 text-sm bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-brand-400 focus:border-transparent"
/>
<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
type="button"
onClick={handleCreate}
disabled={!newName.trim() || createMut.isPending}
className="px-5 py-2.5 bg-brand-600 text-white rounded-xl hover:bg-brand-700 text-sm font-medium disabled:opacity-50 transition-colors flex-shrink-0"
>
{createMut.isPending ? "Adding..." : "Add"}
</button>
</div>
{/* Search/filter */}
<div className="mb-4 flex items-center gap-2">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter by name or tag..."
className="border border-gray-300 dark:border-gray-600 rounded-xl px-4 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-full sm:w-72 bg-white dark:bg-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500"
/>
{!search && clients.length > 0 && (
<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>
{/* Error */}
{error && (
<div className="mb-4 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 px-4 py-3 text-sm text-red-700 dark:text-red-400 flex items-center justify-between">
{error}
<button
type="button"
onClick={() => setError(null)}
className="text-red-400 hover:text-red-600 text-lg leading-none ml-4"
>
&times;
</button>
</div>
)}
{/* Client list */}
<div className="space-y-2">
{isLoading && (
<div className="text-center py-12 text-gray-400 dark:text-gray-500">Loading...</div>
)}
{!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">
{search ? "No clients match your filter." : "No clients yet. Add one above."}
</div>
)}
{!isLoading && displayNodes.length > 0 && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={displayNodes.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
{displayNodes.map((client) => (
<SortableClientCard
key={client.id}
client={client}
allClients={clients}
allKnownTags={allKnownTags}
onUpdateName={handleUpdateName}
onUpdateSortOrder={handleUpdateSortOrder}
onUpdateTags={handleUpdateTags}
onUpdateParent={handleUpdateParent}
onDelete={handleDelete}
onToggleExpand={handleToggleExpand}
isExpanded={!collapsedIds.has(client.id)}
/>
))}
</SortableContext>
<DragOverlay>
{activeClient ? (
<SortableClientCard
client={{ ...activeClient, depth: 0 }}
allClients={clients}
allKnownTags={allKnownTags}
onUpdateName={() => {}}
onUpdateSortOrder={() => {}}
onUpdateTags={() => {}}
onUpdateParent={() => {}}
onDelete={() => {}}
onToggleExpand={() => {}}
isExpanded={false}
isDragOverlay
/>
) : null}
</DragOverlay>
</DndContext>
)}
</div>
{/* Count */}
{!isLoading && clients.length > 0 && (
<div className="mt-4 text-xs text-gray-400 dark:text-gray-500 text-right">
{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>
);
}