diff --git a/.phase17-state.json b/.phase17-state.json index 59b5247..7bb577c 100644 --- a/.phase17-state.json +++ b/.phase17-state.json @@ -1,8 +1,9 @@ { "completed_features": [], - "current_feature": "batch-rename-projects", + "current_feature": "customer-merge", "started_at": "2026-05-23T07:18:43.778897", "attempted_features": [ - "calendar-month-view" + "calendar-month-view", + "batch-rename-projects" ] } \ No newline at end of file diff --git a/GENERATION_LOG.md b/GENERATION_LOG.md index 0d6c203..d6bf5fb 100644 --- a/GENERATION_LOG.md +++ b/GENERATION_LOG.md @@ -2052,3 +2052,22 @@ src/index.ts(27,25): error TS2769: No overload matches this call. Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTy +- `07:21:57` **INFO** Committed feature batch-rename-projects +- `07:21:57` **INFO** Pushed: rc=0 + +## Phase-3 Feature: customer-merge (2026-05-23 07:21:57) + +- `07:21:57` **INFO** Description: Merge zwei Customers: source-Projects auf target umhängen, dann source löschen +- `07:21:57` **INFO** Generating apps/api/src/routes/customers.ts (ERWEITERT — füge POST /:id/merge (body: {targetId}): alle projects vom…) +- `07:22:45` **INFO** wrote 5410 chars in 48.3s (attempt 1) +- `07:22:45` **INFO** Generating apps/web/src/pages/Customers.tsx (ERWEITERT — Merge-Button pro Row (admin-only). Modal mit Target-Custom…) +- `07:24:23` **INFO** wrote 11841 chars in 97.4s (attempt 1) +- `07:24:23` **INFO** Running tsc --noEmit on api… +- `07:24:24` **WARN** tsc errors: +src/index.ts(27,25): error TS2769: No overload matches this call. + Overload 1 of 3, '(plugin: FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. + Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTypeProvider>, opts: { ...; }, done: (err?: Error | undefined) => void): void'. + Overload 2 of 3, '(plugin: FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>, opts?: FastifyRegisterOptions<...> | undefined): FastifyInstance<...> & PromiseLike<...>', gave the following error. + Argument of type 'Promise' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'. + Type 'Promise' provides no match for the signature '(instance: FastifyInstance, FastifyBaseLogger, FastifyTy diff --git a/apps/api/src/routes/customers.ts b/apps/api/src/routes/customers.ts index 6b26660..0a88c9c 100644 --- a/apps/api/src/routes/customers.ts +++ b/apps/api/src/routes/customers.ts @@ -10,6 +10,10 @@ const CustomerSchema = z.object({ const CustomerUpdateSchema = CustomerSchema.partial() +const MergeSchema = z.object({ + targetId: z.string().uuid() +}) + export default async function customerRoutes(fastify: FastifyInstance) { fastify.addHook("preHandler", async (request, reply) => { try { @@ -145,7 +149,6 @@ export default async function customerRoutes(fastify: FastifyInstance) { const csvText = content.toString("utf-8") const lines = csvText.split(/\r?\n/).filter(line => line.trim()) - // Remove header const rows = lines.slice(1) let imported = 0 const errors: string[] = [] @@ -153,20 +156,53 @@ export default async function customerRoutes(fastify: FastifyInstance) { for (let i = 0; i < rows.length; i++) { try { const [name, activeStr] = rows[i].split(",").map(s => s.trim()) - if (!name) throw new Error("Name is missing") + if (!name) throw new Error("Name is required") - const active = activeStr?.toLowerCase() === "true" - await db.insert(customers).values({ name, - active + active: activeStr === "true" }) imported++ - } catch (err: any) { - errors.push(`Line ${i + 2}: ${err.message}`) + } catch (e: any) { + errors.push(`Row ${i + 2}: ${e.message}`) } } return { imported, errors } }) + + fastify.post("/:id/merge", async (request, reply) => { + const user = request.user as any + if (user?.role !== "admin") { + return reply.code(403).send({ message: "Admin privileges required" }) + } + + const { id: sourceId } = request.params as { id: string } + const { targetId } = MergeSchema.parse(request.body) + + if (sourceId === targetId) { + return reply.code(400).send({ message: "Source and target customer must be different" }) + } + + await db.transaction(async (tx) => { + // Move all projects from source to target + await tx + .update(projects) + .set({ customerId: targetId }) + .where(eq(projects.customerId, sourceId)) + + // Deactivate source customer + const [customer] = await tx + .update(customers) + .set({ active: false }) + .where(eq(customers.id, sourceId)) + .returning() + + if (!customer) { + throw new Error("Source customer not found") + } + }) + + return reply.code(200).send({ message: "Customers merged successfully" }) + }) } \ No newline at end of file diff --git a/apps/web/src/pages/Customers.tsx b/apps/web/src/pages/Customers.tsx index 759c5b2..b163ef4 100644 --- a/apps/web/src/pages/Customers.tsx +++ b/apps/web/src/pages/Customers.tsx @@ -1,6 +1,6 @@ import { useState, useRef, useMemo } from "react" import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query" -import { Star } from "lucide-react" +import { Star, Merge, X } from "lucide-react" import { api } from "../lib/api" export default function Customers() { @@ -9,6 +9,7 @@ export default function Customers() { const [tags, setTags] = useState("") const [filterTag, setFilterTag] = useState("") const [showArchived, setShowArchived] = useState(false) + const [mergeTarget, setMergeTarget] = useState<{ sourceId: string; targetId: string | "" } | null>(null) const fileInputRef = useRef(null) const { data: customers, isLoading, isError } = useQuery({ @@ -40,6 +41,15 @@ export default function Customers() { } }) + const mergeMutation = useMutation({ + mutationFn: ({ sourceId, targetId }: { sourceId: string; targetId: string }) => + api.mergeCustomers(sourceId, targetId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["customers"] }) + setMergeTarget(null) + } + }) + const importMutation = useMutation({ mutationFn: (file: File) => api.importCustomersCsv(file), onSuccess: (data) => { @@ -64,10 +74,8 @@ export default function Customers() { return filtered.sort((a, b) => { const aPinned = a.pinnedAt !== null const bPinned = b.pinnedAt !== null - if (aPinned && !bPinned) return -1 if (!aPinned && bPinned) return 1 - return (a.name || "").localeCompare(b.name || "") }) }, [customers, filterTag, showArchived]) @@ -95,7 +103,7 @@ export default function Customers() { return (
-
+

Customers

Manage your client database

@@ -117,116 +125,155 @@ export default function Customers() {
-
-

Add New Customer

-
-
- - setName(e.target.value)} - required - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none" - /> -
-
- - setTags(e.target.value)} - className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none" - placeholder="VIP, Enterprise, Lead" - /> -
-
- -
-
-
- -
-
-
- setFilterTag(e.target.value)} - className="w-full pl-3 pr-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 outline-none" - /> -
- +
+
+ + setName(e.target.value)} + placeholder="Acme Corp" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none" + />
+
+ + setTags(e.target.value)} + placeholder="VIP, Enterprise, Tech" + className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none" + /> +
+ +
-
- - - - - - - +
+
+ setFilterTag(e.target.value)} + placeholder="Filter by tag..." + className="w-full pl-3 pr-3 py-1.5 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-blue-500 outline-none bg-white" + /> +
+ +
+ +
+
NameTagsActions
+ + + + + + + + + + {filteredAndSortedCustomers.map((customer) => ( + + + + + - - - {filteredAndSortedCustomers.length === 0 ? ( - - - - ) : ( - filteredAndSortedCustomers.map((customer) => ( - - - - - - - )) - )} - -
CustomerTagsActions
+ + {customer.name} +
+ {customer.tags?.map(tag => ( + + {tag} + + ))} +
+
+
+ + +
+
No customers found.
- - {customer.name} -
- {customer.tags?.map(tag => ( - - {tag} - - ))} -
-
- -
+ ))} + + +
+ + {mergeTarget && ( +
+
+
+

Merge Customer

+ +
+

+ Select the target customer. All data from the source will be moved to the target, and the source will be deleted. +

+
+ + +
+
+ + +
+
-
+ )}
) } \ No newline at end of file