feat(customer-merge): Merge zwei Customers: source-Projects auf target umhängen, d [tsc:fail]

This commit is contained in:
Dennis (via Claude+Gemma) 2026-05-23 07:24:24 +02:00
parent 7d65d8bdab
commit 90a66efc76
4 changed files with 222 additions and 119 deletions

View File

@ -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"
]
}

View File

@ -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<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, 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<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginCallback<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, 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<FastifyMultipartPlugin>' is not assignable to parameter of type 'FastifyPluginAsync<{ limits: { fileSize: number; }; }, RawServerDefault, FastifyTypeProvider, FastifyBaseLogger>'.
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy

View File

@ -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")
const active = activeStr?.toLowerCase() === "true"
if (!name) throw new Error("Name is required")
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" })
})
}

View File

@ -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<HTMLInputElement>(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 (
<div className="p-6 max-w-6xl mx-auto space-y-8">
<header className="flex justify-between items-start">
<div>
<div className="space-y-1">
<h1 className="text-2xl font-bold text-gray-900">Customers</h1>
<p className="text-gray-500">Manage your client database</p>
</div>
@ -117,116 +125,155 @@ export default function Customers() {
</div>
</header>
<section className="bg-white p-6 rounded-lg border border-gray-200 shadow-sm">
<h2 className="text-lg font-semibold mb-4">Add New Customer</h2>
<form onSubmit={handleSubmit} className="flex flex-col md:flex-row gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Customer Name</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-1">Tags (comma separated)</label>
<input
type="text"
value={tags}
onChange={(e) => 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"
/>
</div>
<div className="flex items-end">
<button
type="submit"
disabled={createMutation.isPending}
className="bg-blue-600 text-white px-6 py-2 rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 font-medium text-sm h-[42px]"
>
{createMutation.isPending ? "Adding..." : "Add Customer"}
</button>
</div>
</form>
</section>
<section className="space-y-4">
<div className="flex flex-col md:flex-row gap-4 items-center justify-between">
<div className="relative w-full md:w-64">
<input
type="text"
placeholder="Filter by tag..."
value={filterTag}
onChange={(e) => 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"
/>
</div>
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
className="rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
Show Archived
</label>
<form onSubmit={handleSubmit} className="bg-white p-4 rounded-lg border border-gray-200 shadow-sm flex gap-4 items-end">
<div className="flex-1 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase">Customer Name</label>
<input
value={name}
onChange={(e) => 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"
/>
</div>
<div className="flex-1 space-y-1">
<label className="text-xs font-semibold text-gray-500 uppercase">Tags (comma separated)</label>
<input
value={tags}
onChange={(e) => 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"
/>
</div>
<button
type="submit"
disabled={createMutation.isPending}
className="bg-blue-600 text-white px-4 py-2 rounded-md hover:bg-blue-700 transition-colors disabled:bg-blue-300 font-medium text-sm h-[38px]"
>
{createMutation.isPending ? "Adding..." : "Add Customer"}
</button>
</form>
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden shadow-sm">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 border-b border-gray-200 text-gray-600 font-medium">
<tr>
<th className="px-4 py-3 w-12"></th>
<th className="px-4 py-3">Name</th>
<th className="px-4 py-3">Tags</th>
<th className="px-4 py-3 text-right">Actions</th>
<div className="flex gap-4 items-center bg-gray-50 p-3 rounded-lg border border-gray-200">
<div className="flex-1 relative">
<input
value={filterTag}
onChange={(e) => 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"
/>
</div>
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
<input
type="checkbox"
checked={showArchived}
onChange={(e) => setShowArchived(e.target.checked)}
className="rounded text-blue-600"
/>
Show Archived
</label>
</div>
<div className="overflow-x-auto rounded-lg border border-gray-200 shadow-sm">
<table className="w-full text-left border-collapse bg-white">
<thead>
<tr className="bg-gray-50 border-b border-gray-200 text-xs font-semibold text-gray-500 uppercase">
<th className="px-4 py-3 w-10"></th>
<th className="px-4 py-3">Customer</th>
<th className="px-4 py-3">Tags</th>
<th className="px-4 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredAndSortedCustomers.map((customer) => (
<tr key={customer.id} className={`hover:bg-gray-50 transition-colors ${!customer.active ? "opacity-60 bg-gray-50" : ""}`}>
<td className="px-4 py-3">
<button
onClick={() => pinMutation.mutate({ id: customer.id, pinned: customer.pinnedAt === null })}
className={`transition-colors ${customer.pinnedAt ? "text-yellow-500" : "text-gray-300 hover:text-yellow-400"}`}
>
<Star size={16} fill={customer.pinnedAt ? "currentColor" : "none"} />
</button>
</td>
<td className="px-4 py-3 font-medium text-gray-900">{customer.name}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{customer.tags?.map(tag => (
<span key={tag} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded text-xs border border-gray-200">
{tag}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-right">
<div className="flex justify-end gap-2">
<button
onClick={() => setMergeTarget({ sourceId: customer.id, targetId: "" })}
className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"
title="Merge into another customer"
>
<Merge size={16} />
</button>
<button
onClick={() => archiveMutation.mutate(customer.id)}
disabled={customer.active === false}
className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded transition-colors disabled:opacity-30"
title="Archive"
>
<X size={16} />
</button>
</div>
</td>
</tr>
</thead>
<tbody className="divide-y divide-gray-200">
{filteredAndSortedCustomers.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-gray-500">No customers found.</td>
</tr>
) : (
filteredAndSortedCustomers.map((customer) => (
<tr key={customer.id} className={`hover:bg-gray-50 transition-colors ${!customer.active ? 'bg-gray-50 opacity-60' : ''}`}>
<td className="px-4 py-3">
<button
onClick={() => pinMutation.mutate({ id: customer.id, pinned: customer.pinnedAt === null })}
className={`p-1 rounded-full transition-colors ${customer.pinnedAt ? 'text-yellow-500 hover:bg-yellow-50' : 'text-gray-300 hover:text-gray-400 hover:bg-gray-100'}`}
title={customer.pinnedAt ? "Unpin customer" : "Pin customer"}
>
<Star className={`w-4 h-4 ${customer.pinnedAt ? 'fill-current' : ''}`} />
</button>
</td>
<td className="px-4 py-3 font-medium text-gray-900">{customer.name}</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{customer.tags?.map(tag => (
<span key={tag} className="px-2 py-0.5 bg-gray-100 text-gray-600 rounded-full text-xs border border-gray-200">
{tag}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => archiveMutation.mutate(customer.id)}
disabled={customer.active === false}
className="text-gray-500 hover:text-red-600 transition-colors text-xs font-medium disabled:opacity-0"
>
{customer.active === false ? "Archived" : "Archive"}
</button>
</td>
</tr>
))
)}
</tbody>
</table>
))}
</tbody>
</table>
</div>
{mergeTarget && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-xl shadow-xl max-w-md w-full p-6 space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-bold text-gray-900">Merge Customer</h3>
<button onClick={() => setMergeTarget(null)} className="text-gray-400 hover:text-gray-600">
<X size={20} />
</button>
</div>
<p className="text-sm text-gray-500">
Select the target customer. All data from the source will be moved to the target, and the source will be deleted.
</p>
<div className="space-y-2">
<label className="text-xs font-semibold text-gray-500 uppercase">Target Customer</label>
<select
value={mergeTarget.targetId}
onChange={(e) => setMergeTarget({ ...mergeTarget, targetId: 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"
>
<option value="">Select target...</option>
{customers
?.filter(c => c.id !== mergeTarget.sourceId)
.map(c => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="flex gap-3 pt-2">
<button
onClick={() => setMergeTarget(null)}
className="flex-1 px-4 py-2 border border-gray-300 rounded-md text-gray-700 hover:bg-gray-50 font-medium text-sm"
>
Cancel
</button>
<button
disabled={!mergeTarget.targetId || mergeMutation.isPending}
onClick={() => mergeMutation.mutate({ sourceId: mergeTarget.sourceId, targetId: mergeTarget.targetId })}
className="flex-1 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-blue-300 font-medium text-sm"
>
{mergeMutation.isPending ? "Merging..." : "Confirm Merge"}
</button>
</div>
</div>
</div>
</section>
)}
</div>
)
}