1107 lines
38 KiB
TypeScript
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}`}
|
|
>
|
|
×
|
|
</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"
|
|
>
|
|
×
|
|
</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"
|
|
>
|
|
×
|
|
</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>
|
|
);
|
|
}
|