Loading...
- {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"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ {/* 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