feat: redesign Clients admin — drag-and-drop, inline edit, tags

Schema:
- Client model: add tags String[] field
- Shared types + Zod schemas updated for tags

API:
- client.create/update: accept tags array
- client.delete: with safety checks (no projects, no children)
- client.batchUpdateSortOrder: batch reorder in transaction

UI (complete redesign of ClientsAdminClient):
- Drag-and-drop reordering via @dnd-kit (sortable)
- Inline editing: click name/sortOrder to edit in-place
- Tag pills: auto-colored by hash, add/remove inline
- Tag auto-suggest from existing tags across all clients
- Sticky "Add Client" input row at top
- Search/filter by name, code, or tag
- Delete with inline confirmation
- Optimistic reorder (instant UI update)
- Full dark theme support

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-03-22 21:04:20 +01:00
parent a9a8a13424
commit 03922764db
7 changed files with 731 additions and 217 deletions
@@ -1,9 +1,31 @@
"use client";
import { useState } from "react";
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";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
type ClientRow = {
id: string;
name: string;
@@ -11,279 +33,666 @@ type ClientRow = {
parentId: string | null;
sortOrder: number;
isActive: boolean;
tags: string[];
_count?: { children: number; projects: number };
};
type ClientNode = ClientRow & { children: ClientNode[] };
// ---------------------------------------------------------------------------
// Tag color palette — deterministic color from tag name hash
// ---------------------------------------------------------------------------
type EditingClient = {
id?: string;
name: string;
code: string;
parentId: string;
sortOrder: number;
};
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 ClientTreeNode({
node,
onEdit,
onAddChild,
depth = 0,
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,
}: {
node: ClientNode;
onEdit: (c: ClientRow) => void;
onAddChild: (parentId: string) => void;
depth?: number;
tag: string;
onRemove?: () => void;
}) {
const [expanded, setExpanded] = useState(depth < 1);
const hasChildren = node.children.length > 0;
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]);
function submit(tag: string) {
const trimmed = tag.trim();
if (!trimmed || existingTags.includes(trimmed)) return;
onAdd(trimmed);
setValue("");
}
return (
<div>
<div
className="flex items-center gap-2 py-2 px-3 hover:bg-gray-50 dark:hover:bg-gray-800/30 rounded-lg group"
style={{ paddingLeft: `${depth * 24 + 12}px` }}
>
{hasChildren ? (
<button
type="button"
onClick={() => setExpanded(!expanded)}
className="w-5 h-5 flex items-center justify-center text-gray-400 hover:text-gray-600 text-xs"
>
{expanded ? "▼" : "▶"}
</button>
) : (
<span className="w-5" />
)}
<span className="text-sm font-medium text-gray-900 dark:text-gray-100 flex-1">
{node.name}
{node.code && <span className="text-gray-400 font-mono ml-1 text-xs">[{node.code}]</span>}
</span>
{!node.isActive && <span className="text-xs text-gray-400 italic">inactive</span>}
<div className="flex gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={() => onAddChild(node.id)}
className="text-xs text-green-600 hover:text-green-800 font-medium"
>
+ Child
</button>
<button
type="button"
onClick={() => onEdit(node)}
className="text-xs text-brand-600 hover:text-brand-800 font-medium"
>
Edit
</button>
<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 && (
<div className="absolute z-20 top-full left-0 mt-1 w-40 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg py-1 max-h-32 overflow-y-auto">
{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 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
>
<TagPill tag={s} />
</button>
))}
</div>
</div>
{expanded && node.children.map((child) => (
<ClientTreeNode key={child.id} node={child} onEdit={onEdit} onAddChild={onAddChild} depth={depth + 1} />
))}
)}
</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>
);
}
// ---------------------------------------------------------------------------
// Sortable client card
// ---------------------------------------------------------------------------
function SortableClientCard({
client,
allKnownTags,
onUpdateName,
onUpdateSortOrder,
onUpdateTags,
onDelete,
isDragOverlay,
}: {
client: ClientRow;
allKnownTags: string[];
onUpdateName: (id: string, name: string) => void;
onUpdateSortOrder: (id: string, sortOrder: number) => void;
onUpdateTags: (id: string, tags: string[]) => void;
onDelete: (id: string) => void;
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 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 cardClasses = [
"flex items-center gap-3 px-4 py-3 rounded-xl border transition-all group",
isDragOverlay
? "bg-white dark:bg-gray-800 border-brand-500 shadow-xl scale-[1.02] ring-2 ring-brand-500"
: isDragging
? "opacity-30 bg-gray-50 dark:bg-gray-900 border-gray-200 dark:border-gray-700"
: "bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600 hover:shadow-sm",
].join(" ");
return (
<div ref={setNodeRef} style={style} className={cardClasses}>
{/* 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 */}
<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
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>
);
}
// ---------------------------------------------------------------------------
// Main component
// ---------------------------------------------------------------------------
export function ClientsAdminClient() {
const [editing, setEditing] = useState<EditingClient | null>(null);
const [search, setSearch] = useState("");
const [newName, setNewName] = useState("");
const [error, setError] = useState<string | null>(null);
const [activeId, setActiveId] = useState<string | null>(null);
const newInputRef = useRef<HTMLInputElement>(null);
const utils = trpc.useUtils();
const { data: tree, isLoading } = trpc.clientEntity.getTree.useQuery();
const { data: flatList } = trpc.clientEntity.list.useQuery();
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]);
// 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]);
// Filtered list
const filteredClients = useMemo(() => {
if (!search.trim()) return clients;
const lower = search.toLowerCase();
return clients.filter(
(c) =>
c.name.toLowerCase().includes(lower) ||
(c.code ?? "").toLowerCase().includes(lower) ||
(c.tags ?? []).some((t) => t.toLowerCase().includes(lower)),
);
}, [clients, search]);
// 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: () => { void utils.clientEntity.getTree.invalidate(); void utils.clientEntity.list.invalidate(); setEditing(null); },
onSuccess: () => {
invalidateAll();
setNewName("");
},
onError: (e) => setError(e.message),
});
const updateMut = trpc.clientEntity.update.useMutation({
onSuccess: () => { void utils.clientEntity.getTree.invalidate(); void utils.clientEntity.list.invalidate(); setEditing(null); },
onSuccess: invalidateAll,
onError: (e) => setError(e.message),
});
const allClients = (flatList ?? []) as unknown as ClientRow[];
const deleteMut = trpc.clientEntity.delete.useMutation({
onSuccess: invalidateAll,
onError: (e) => setError(e.message),
});
function openCreate(parentId?: string) {
setEditing({ name: "", code: "", parentId: parentId ?? "", sortOrder: 0 });
setError(null);
const batchSortMut = trpc.clientEntity.batchUpdateSortOrder.useMutation({
onSuccess: invalidateAll,
onError: (e) => setError(e.message),
});
// Handlers
function handleCreate() {
const trimmed = newName.trim();
if (!trimmed) return;
const maxSort = clients.reduce((max, c) => Math.max(max, c.sortOrder), 0);
createMut.mutate({ name: trimmed, sortOrder: maxSort + 1 });
}
function openEdit(c: ClientRow) {
setEditing({
id: c.id,
name: c.name,
code: c.code ?? "",
parentId: c.parentId ?? "",
sortOrder: c.sortOrder,
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 handleDelete(id: string) {
deleteMut.mutate({ id });
}
// Drag and drop
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 = filteredClients.findIndex((c) => c.id === active.id);
const newIndex = filteredClients.findIndex((c) => c.id === over.id);
if (oldIndex === -1 || newIndex === -1) return;
const reordered = arrayMove(filteredClients, oldIndex, newIndex);
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;
});
setError(null);
batchSortMut.mutate(updates);
}
function handleSave() {
if (!editing) return;
setError(null);
if (editing.id) {
updateMut.mutate({
id: editing.id,
data: {
name: editing.name,
code: editing.code || undefined,
parentId: editing.parentId || undefined,
sortOrder: editing.sortOrder,
},
});
} else {
createMut.mutate({
name: editing.name,
code: editing.code || undefined,
parentId: editing.parentId || undefined,
sortOrder: editing.sortOrder,
});
}
}
const isPending = createMut.isPending || updateMut.isPending;
const treeNodes = (tree ?? []) as unknown as ClientNode[];
// Simple client-side filter on tree
function filterTree(nodes: ClientNode[], q: string): ClientNode[] {
if (!q) return nodes;
const lower = q.toLowerCase();
return nodes.reduce<ClientNode[]>((acc, node) => {
const filteredChildren = filterTree(node.children, q);
if (node.name.toLowerCase().includes(lower) || (node.code ?? "").toLowerCase().includes(lower) || filteredChildren.length > 0) {
acc.push({ ...node, children: filteredChildren });
}
return acc;
}, []);
}
const filteredTree = filterTree(treeNodes, search);
const activeClient = activeId ? filteredClients.find((c) => c.id === activeId) : null;
return (
<div className="p-6 max-w-4xl mx-auto">
<div className="flex items-center justify-between mb-6">
<div>
<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">
Client hierarchy for project assignment and chargeability reporting <InfoTooltip content="Clients are companies or brands that commission projects. The hierarchy supports parent/child relationships (e.g. BMW Group > BMW > MINI). Projects are assigned to clients for revenue tracking and chargeability reporting." />
</p>
</div>
{/* 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. Drag to reorder, 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"
/>
<button
type="button"
onClick={() => openCreate()}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium"
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"
>
+ Add Client
{createMut.isPending ? "Adding..." : "Add"}
</button>
</div>
{/* Search/filter */}
<div className="mb-4">
<input
type="search"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search clients..."
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-400 w-64 bg-white dark:bg-gray-900 dark:text-gray-100"
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"
/>
</div>
{/* Error */}
{error && (
<div className="mb-4 rounded-lg 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">
<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">&times;</button>
<button
type="button"
onClick={() => setError(null)}
className="text-red-400 hover:text-red-600 text-lg leading-none ml-4"
>
&times;
</button>
</div>
)}
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-2">
{isLoading && <div className="text-center py-8 text-gray-400">Loading...</div>}
{!isLoading && filteredTree.length === 0 && (
<div className="text-center py-8 text-gray-400">
{search ? "No clients match your search." : "No clients yet."}
{/* Client list */}
<div className="space-y-2">
{isLoading && (
<div className="text-center py-12 text-gray-400 dark:text-gray-500">Loading...</div>
)}
{!isLoading && filteredClients.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>
)}
{filteredTree.map((node) => (
<ClientTreeNode key={node.id} node={node} onEdit={openEdit} onAddChild={(pid) => openCreate(pid)} />
))}
{!isLoading && filteredClients.length > 0 && (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredClients.map((c) => c.id)}
strategy={verticalListSortingStrategy}
>
{filteredClients.map((client) => (
<SortableClientCard
key={client.id}
client={client}
allKnownTags={allKnownTags}
onUpdateName={handleUpdateName}
onUpdateSortOrder={handleUpdateSortOrder}
onUpdateTags={handleUpdateTags}
onDelete={handleDelete}
/>
))}
</SortableContext>
<DragOverlay>
{activeClient ? (
<SortableClientCard
client={activeClient}
allKnownTags={allKnownTags}
onUpdateName={() => {}}
onUpdateSortOrder={() => {}}
onUpdateTags={() => {}}
onDelete={() => {}}
isDragOverlay
/>
) : null}
</DragOverlay>
</DndContext>
)}
</div>
{/* Create/Edit Modal */}
{editing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white dark:bg-gray-900 rounded-xl shadow-2xl w-full max-w-md mx-4">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
{editing.id ? "Edit Client" : "Add Client"}
</h2>
<button type="button" onClick={() => setEditing(null)} className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 text-2xl leading-none">&times;</button>
</div>
<div className="px-6 py-5 space-y-4">
<div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Name <InfoTooltip content="The full name of the client. Shown in project assignment dropdowns and reports." /></label>
<input
type="text"
value={editing.name}
onChange={(e) => setEditing({ ...editing, name: e.target.value })}
placeholder="e.g. BMW Group"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Code <InfoTooltip content="A short abbreviation for this client (e.g. BMW). Used in compact views and rate card assignments." /></label>
<input
type="text"
value={editing.code}
onChange={(e) => setEditing({ ...editing, code: e.target.value })}
placeholder="BMW"
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400 font-mono"
/>
</div>
<div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Sort Order <InfoTooltip content="Controls the display order. Lower numbers appear first." /></label>
<input
type="number"
value={editing.sortOrder}
onChange={(e) => setEditing({ ...editing, sortOrder: parseInt(e.target.value) || 0 })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
/>
</div>
</div>
<div>
<label className="flex items-center text-xs font-medium text-gray-600 dark:text-gray-400 mb-1">Parent Client <InfoTooltip content="Set a parent to create a hierarchy (e.g. MINI under BMW Group). Child clients inherit the parent's reporting context." /></label>
<select
value={editing.parentId}
onChange={(e) => setEditing({ ...editing, parentId: e.target.value })}
className="border border-gray-300 dark:border-gray-600 rounded-lg px-3 py-2 text-sm w-full bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-brand-400"
>
<option value=""> Top level (no parent) </option>
{allClients
.filter((c) => c.id !== editing.id && c.isActive)
.map((c) => (
<option key={c.id} value={c.id}>
{c.name} {c.code ? `[${c.code}]` : ""}
</option>
))}
</select>
</div>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-gray-200 dark:border-gray-700 gap-3">
<button type="button" onClick={() => setEditing(null)} className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200">Cancel</button>
<button
type="button"
onClick={handleSave}
disabled={isPending || !editing.name}
className="px-4 py-2 bg-brand-600 text-white rounded-lg hover:bg-brand-700 text-sm font-medium disabled:opacity-50"
>
{isPending ? "Saving..." : editing.id ? "Update" : "Create"}
</button>
</div>
</div>
{/* Count */}
{!isLoading && clients.length > 0 && (
<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" : ""}
</div>
)}
</div>