From 03922764dbaa6125f4d068f538ae002f0d44f953 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hartmut=20N=C3=B6renberg?= Date: Sun, 22 Mar 2026 21:04:20 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20redesign=20Clients=20admin=20=E2=80=94?= =?UTF-8?q?=20drag-and-drop,=20inline=20edit,=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/web/package.json | 3 + .../components/admin/ClientsAdminClient.tsx | 841 +++++++++++++----- packages/api/src/router/client.ts | 44 +- packages/db/prisma/schema.prisma | 1 + packages/shared/src/schemas/client.schema.ts | 2 + packages/shared/src/types/client.ts | 1 + pnpm-lock.yaml | 56 ++ 7 files changed, 731 insertions(+), 217 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 6d38115..714d174 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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:*", diff --git a/apps/web/src/components/admin/ClientsAdminClient.tsx b/apps/web/src/components/admin/ClientsAdminClient.tsx index d26c6fa..4bcc889 100644 --- a/apps/web/src/components/admin/ClientsAdminClient.tsx +++ b/apps/web/src/components/admin/ClientsAdminClient.tsx @@ -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 ( + + {tag} + {onRemove && ( + + )} + + ); +} + +// --------------------------------------------------------------------------- +// 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(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 ( -
-
- {hasChildren ? ( - - ) : ( - - )} - - {node.name} - {node.code && [{node.code}]} - - {!node.isActive && inactive} -
- - +
+ { + 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 && ( +
+ {suggestions.map((s) => ( + + ))}
-
- {expanded && node.children.map((child) => ( - - ))} + )}
); } +// --------------------------------------------------------------------------- +// Drag handle icon +// --------------------------------------------------------------------------- + +function GripIcon({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} + +// --------------------------------------------------------------------------- +// 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(null); + const sortInputRef = useRef(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 ( +
+ {/* Drag handle */} + + + {/* Name */} +
+ {editingName ? ( + 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" + /> + ) : ( + + )} +
+ + {/* Tags */} +
+ {client.tags.map((tag) => ( + { + onUpdateTags(client.id, client.tags.filter((t) => t !== tag)); + }} + /> + ))} + {addingTag ? ( + { + onUpdateTags(client.id, [...client.tags, tag]); + }} + onClose={() => setAddingTag(false)} + /> + ) : ( + + )} +
+ + {/* Sort order */} +
+ {editingSortOrder ? ( + 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" + /> + ) : ( + + )} +
+ + {/* Delete */} +
+ {confirmDelete ? ( +
+ + +
+ ) : ( + + )} +
+
+ ); +} + +// --------------------------------------------------------------------------- +// Main component +// --------------------------------------------------------------------------- + export function ClientsAdminClient() { - const [editing, setEditing] = useState(null); const [search, setSearch] = useState(""); + const [newName, setNewName] = useState(""); const [error, setError] = useState(null); + const [activeId, setActiveId] = useState(null); + const newInputRef = useRef(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(); + 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((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 (
-
-
-

Clients

-

- Client hierarchy for project assignment and chargeability reporting -

-
+ {/* Header */} +
+

Clients

+

+ Manage clients for project assignment and chargeability reporting{" "} + +

+
+ + {/* Add new client — sticky row */} +
+ 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" + />
+ {/* Search/filter */}
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" />
+ {/* Error */} {error && ( -
+
{error} - +
)} -
- {isLoading &&
Loading...
} - {!isLoading && filteredTree.length === 0 && ( -
- {search ? "No clients match your search." : "No clients yet."} + {/* Client list */} +
+ {isLoading && ( +
Loading...
+ )} + + {!isLoading && filteredClients.length === 0 && ( +
+ {search ? "No clients match your filter." : "No clients yet. Add one above."}
)} - {filteredTree.map((node) => ( - openCreate(pid)} /> - ))} + + {!isLoading && filteredClients.length > 0 && ( + + c.id)} + strategy={verticalListSortingStrategy} + > + {filteredClients.map((client) => ( + + ))} + + + + {activeClient ? ( + {}} + onUpdateSortOrder={() => {}} + onUpdateTags={() => {}} + onDelete={() => {}} + isDragOverlay + /> + ) : null} + + + )}
- {/* Create/Edit Modal */} - {editing && ( -
-
-
-

- {editing.id ? "Edit Client" : "Add Client"} -

- -
- -
-
- - 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" - /> -
- -
-
- - 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" - /> -
-
- - 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" - /> -
-
- -
- - -
-
- -
- - -
-
+ {/* Count */} + {!isLoading && clients.length > 0 && ( +
+ {filteredClients.length} of {clients.length} client{clients.length !== 1 ? "s" : ""}
)}
diff --git a/packages/api/src/router/client.ts b/packages/api/src/router/client.ts index fb1eab5..6ea304c 100644 --- a/packages/api/src/router/client.ts +++ b/packages/api/src/router/client.ts @@ -2,7 +2,7 @@ import { CreateClientSchema, UpdateClientSchema } from "@planarchy/shared"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; import { findUniqueOrThrow } from "../db/helpers.js"; -import { createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js"; +import { adminProcedure, createTRPCRouter, managerProcedure, protectedProcedure } from "../trpc.js"; import type { ClientTree } from "@planarchy/shared"; @@ -13,6 +13,7 @@ interface FlatClient { parentId: string | null; isActive: boolean; sortOrder: number; + tags: string[]; createdAt: Date; updatedAt: Date; } @@ -102,6 +103,7 @@ export const clientRouter = createTRPCRouter({ ...(input.code ? { code: input.code } : {}), ...(input.parentId ? { parentId: input.parentId } : {}), sortOrder: input.sortOrder, + ...(input.tags ? { tags: input.tags } : {}), }, }); }), @@ -129,6 +131,7 @@ export const clientRouter = createTRPCRouter({ ...(input.data.sortOrder !== undefined ? { sortOrder: input.data.sortOrder } : {}), ...(input.data.isActive !== undefined ? { isActive: input.data.isActive } : {}), ...(input.data.parentId !== undefined ? { parentId: input.data.parentId } : {}), + ...(input.data.tags !== undefined ? { tags: input.data.tags } : {}), }, }); }), @@ -141,4 +144,43 @@ export const clientRouter = createTRPCRouter({ data: { isActive: false }, }); }), + + delete: adminProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const client = await findUniqueOrThrow( + ctx.db.client.findUnique({ + where: { id: input.id }, + include: { _count: { select: { projects: true, children: true } } }, + }), + "Client", + ); + if (client._count.projects > 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Cannot delete client with ${client._count.projects} project(s). Deactivate instead.`, + }); + } + if (client._count.children > 0) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Cannot delete client with ${client._count.children} child client(s). Remove children first.`, + }); + } + return ctx.db.client.delete({ where: { id: input.id } }); + }), + + batchUpdateSortOrder: managerProcedure + .input(z.array(z.object({ id: z.string(), sortOrder: z.number().int() }))) + .mutation(async ({ ctx, input }) => { + await ctx.db.$transaction( + input.map((item) => + ctx.db.client.update({ + where: { id: item.id }, + data: { sortOrder: item.sortOrder }, + }), + ), + ); + return { ok: true }; + }), }); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 6ffa41e..360afae 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -602,6 +602,7 @@ model Client { children Client[] @relation("ClientTree") isActive Boolean @default(true) sortOrder Int @default(0) + tags String[] @default([]) projects Project[] resourceClientUnits Resource[] @relation("resource_client_unit") diff --git a/packages/shared/src/schemas/client.schema.ts b/packages/shared/src/schemas/client.schema.ts index 75c5125..4d9a4fa 100644 --- a/packages/shared/src/schemas/client.schema.ts +++ b/packages/shared/src/schemas/client.schema.ts @@ -5,6 +5,7 @@ export const CreateClientSchema = z.object({ code: z.string().max(50).optional(), parentId: z.string().optional(), sortOrder: z.number().int().default(0), + tags: z.array(z.string().max(50)).optional(), }); export const UpdateClientSchema = z.object({ @@ -13,6 +14,7 @@ export const UpdateClientSchema = z.object({ sortOrder: z.number().int().optional(), isActive: z.boolean().optional(), parentId: z.string().nullable().optional(), + tags: z.array(z.string().max(50)).optional(), }); export type CreateClientInput = z.infer; diff --git a/packages/shared/src/types/client.ts b/packages/shared/src/types/client.ts index e57630d..1fee6be 100644 --- a/packages/shared/src/types/client.ts +++ b/packages/shared/src/types/client.ts @@ -5,6 +5,7 @@ export interface Client { parentId?: string | null; isActive: boolean; sortOrder: number; + tags: string[]; createdAt: Date; updatedAt: Date; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 41dfa83..01e6ca9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -30,6 +30,15 @@ importers: apps/web: dependencies: + '@dnd-kit/core': + specifier: ^6.3.1 + version: 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/sortable': + specifier: ^10.0.0 + version: 10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': + specifier: ^3.2.2 + version: 3.2.2(react@19.2.4) '@node-rs/argon2': specifier: ^2.0.2 version: 2.0.2 @@ -471,6 +480,28 @@ packages: '@dimforge/rapier3d-compat@0.12.0': resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + '@dnd-kit/accessibility@3.1.1': + resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} + peerDependencies: + react: '>=16.8.0' + + '@dnd-kit/core@6.3.1': + resolution: {integrity: sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@dnd-kit/sortable@10.0.0': + resolution: {integrity: sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==} + peerDependencies: + '@dnd-kit/core': ^6.3.0 + react: '>=16.8.0' + + '@dnd-kit/utilities@3.2.2': + resolution: {integrity: sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==} + peerDependencies: + react: '>=16.8.0' + '@emnapi/core@1.8.1': resolution: {integrity: sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg==} @@ -4574,6 +4605,31 @@ snapshots: '@dimforge/rapier3d-compat@0.12.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/accessibility': 3.1.1(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + react-dom: 19.2.4(react@19.2.4) + tslib: 2.8.1 + + '@dnd-kit/sortable@10.0.0(@dnd-kit/core@6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react@19.2.4)': + dependencies: + '@dnd-kit/core': 6.3.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@dnd-kit/utilities': 3.2.2(react@19.2.4) + react: 19.2.4 + tslib: 2.8.1 + + '@dnd-kit/utilities@3.2.2(react@19.2.4)': + dependencies: + react: 19.2.4 + tslib: 2.8.1 + '@emnapi/core@1.8.1': dependencies: '@emnapi/wasi-threads': 1.1.0