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:
@@ -11,6 +11,9 @@
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@planarchy/api": "workspace:*",
|
||||
"@planarchy/application": "workspace:*",
|
||||
|
||||
@@ -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}`}
|
||||
>
|
||||
×
|
||||
</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">×</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setError(null)}
|
||||
className="text-red-400 hover:text-red-600 text-lg leading-none ml-4"
|
||||
>
|
||||
×
|
||||
</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">×</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>
|
||||
|
||||
Reference in New Issue
Block a user