feat(bulk-customer-tag): Bulk-Add-Tag zu mehreren Customers [tsc:fail]
This commit is contained in:
parent
571b564508
commit
c5d0d3b0ef
@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"completed_features": [],
|
"completed_features": [],
|
||||||
"current_feature": "advanced-filters",
|
"current_feature": "bulk-customer-tag",
|
||||||
"started_at": "2026-05-23T08:55:38.459472"
|
"started_at": "2026-05-23T08:55:38.459472",
|
||||||
|
"attempted_features": [
|
||||||
|
"advanced-filters"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
@ -3116,3 +3116,21 @@ 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.
|
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>'.
|
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>,
|
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>,
|
||||||
|
- `08:56:08` **INFO** Committed feature advanced-filters
|
||||||
|
- `08:56:08` **INFO** Pushed: rc=0
|
||||||
|
|
||||||
|
## Phase-3 Feature: bulk-customer-tag (2026-05-23 08:56:08)
|
||||||
|
|
||||||
|
- `08:56:08` **INFO** Description: Bulk-Add-Tag zu mehreren Customers
|
||||||
|
- `08:56:08` **INFO** Generating apps/web/src/pages/Customers.tsx (ERWEITERT — füge Checkbox-Spalte. Wenn min 1 selektiert: Action-Bar mi…)
|
||||||
|
- `08:57:42` **INFO** wrote 10912 chars in 93.7s (attempt 1)
|
||||||
|
- `08:57:42` **INFO** Running tsc --noEmit on api…
|
||||||
|
- `08:57:43` **WARN** tsc errors:
|
||||||
|
src/db/schema.ts(37,14): error TS7022: 'customers' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
|
||||||
|
src/db/schema.ts(43,59): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
|
||||||
|
src/db/schema.ts(47,14): error TS7022: 'projects' implicitly has type 'any' because it does not have a type annotation and is referenced directly or indirectly in its own initializer.
|
||||||
|
src/db/schema.ts(51,56): error TS7024: Function implicitly has return type 'any' because it does not have a return type annotation and is referenced directly or indirectly in one of its return expressions.
|
||||||
|
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>,
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useMemo } from "react"
|
import { useState, useRef, useMemo } from "react"
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||||
import { Star, Merge, X, Upload, Plus, Archive, Trash2, Download } from "lucide-react"
|
import { Star, Merge, X, Upload, Plus, Archive, Trash2, Tag as TagIcon } from "lucide-react"
|
||||||
import { api } from "../lib/api"
|
import { api } from "../lib/api"
|
||||||
import { memo } from "react"
|
import { memo } from "react"
|
||||||
|
|
||||||
@ -25,17 +25,27 @@ const CustomerRow = memo(({
|
|||||||
onArchive,
|
onArchive,
|
||||||
onPin,
|
onPin,
|
||||||
onMerge,
|
onMerge,
|
||||||
onDelete
|
onDelete,
|
||||||
|
isSelected,
|
||||||
|
onSelect
|
||||||
}: {
|
}: {
|
||||||
customer: any,
|
customer: any,
|
||||||
onArchive: (id: string) => void,
|
onArchive: (id: string) => void,
|
||||||
onPin: (id: string, pinned: boolean) => void,
|
onPin: (id: string, pinned: boolean) => void,
|
||||||
onMerge: (id: string) => void,
|
onMerge: (id: string) => void,
|
||||||
onDelete: (id: string) => void
|
onDelete: (id: string) => void,
|
||||||
|
isSelected: boolean,
|
||||||
|
onSelect: (id: string) => void
|
||||||
}) => {
|
}) => {
|
||||||
return (
|
return (
|
||||||
<div className={`flex items-center justify-between p-3 mb-2 bg-white rounded shadow-sm transition-all ${getColorForName(customer.name)}`}>
|
<div className={`flex items-center justify-between p-3 mb-2 bg-white rounded shadow-sm transition-all ${getColorForName(customer.name)}`}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => onSelect(customer.id)}
|
||||||
|
className="w-4 h-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => onPin(customer.id, !(customer.pinnedAt))}
|
onClick={() => onPin(customer.id, !(customer.pinnedAt))}
|
||||||
className={`p-1 rounded-full transition-colors ${customer.pinnedAt ? 'text-yellow-500 bg-yellow-50' : 'text-gray-300 hover:text-yellow-400'}`}
|
className={`p-1 rounded-full transition-colors ${customer.pinnedAt ? 'text-yellow-500 bg-yellow-50' : 'text-gray-300 hover:text-yellow-400'}`}
|
||||||
@ -88,6 +98,8 @@ export default function Customers() {
|
|||||||
const [tags, setTags] = useState("")
|
const [tags, setTags] = useState("")
|
||||||
const [filterTag, setFilterTag] = useState("")
|
const [filterTag, setFilterTag] = useState("")
|
||||||
const [showArchived, setShowArchived] = useState(false)
|
const [showArchived, setShowArchived] = useState(false)
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||||
|
const [bulkTag, setBulkTag] = useState("")
|
||||||
const [mergeTarget, setMergeTarget] = useState<{ sourceId: string; targetId: string | "" } | null>(null)
|
const [mergeTarget, setMergeTarget] = useState<{ sourceId: string; targetId: string | "" } | null>(null)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
@ -120,12 +132,12 @@ export default function Customers() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const mergeMutation = useMutation({
|
const bulkTagMutation = useMutation({
|
||||||
mutationFn: ({ sourceId, targetId }: { sourceId: string; targetId: string }) =>
|
mutationFn: ({ ids, tag }: { ids: string[]; tag: string }) => api.bulkTagCustomers(ids, tag),
|
||||||
api.mergeCustomers(sourceId, targetId),
|
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["customers"] })
|
queryClient.invalidateQueries({ queryKey: ["customers"] })
|
||||||
setMergeTarget(null)
|
setBulkTag("")
|
||||||
|
setSelectedIds([])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -136,156 +148,90 @@ export default function Customers() {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const importMutation = useMutation({
|
const toggleSelect = (id: string) => {
|
||||||
mutationFn: (file: File) => api.importCustomersCsv(file),
|
setSelectedIds(prev => prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id])
|
||||||
onSuccess: (data: any) => {
|
|
||||||
alert(`Import successful: ${data.count} customers imported.`)
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["customers"] })
|
|
||||||
},
|
|
||||||
onError: (error: any) => {
|
|
||||||
alert(`Import failed: ${error.message || "Unknown error"}`)
|
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const filteredAndSortedCustomers = useMemo(() => {
|
const filteredCustomers = useMemo(() => {
|
||||||
if (!customers) return []
|
if (!customers) return []
|
||||||
|
return customers.filter(c => {
|
||||||
const filtered = customers.filter(c => {
|
const matchesSearch = c.name.toLowerCase().includes(name.toLowerCase())
|
||||||
const matchesTag = !filterTag.trim() ||
|
const matchesTag = filterTag ? c.tags?.some(t => t.toLowerCase().includes(filterTag.toLowerCase())) : true
|
||||||
c.tags?.some(t => t.toLowerCase().includes(filterTag.toLowerCase()))
|
const matchesArchived = showArchived ? !c.active : c.active
|
||||||
const matchesActive = showArchived || c.active !== false
|
return matchesSearch && matchesTag && matchesArchived
|
||||||
return matchesTag && matchesActive
|
|
||||||
})
|
})
|
||||||
|
}, [customers, name, filterTag, showArchived])
|
||||||
|
|
||||||
return filtered.sort((a, b) => {
|
if (isError) return <div className="p-6 text-red-500">Error loading customers.</div>
|
||||||
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])
|
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault()
|
|
||||||
if (!name.trim()) return
|
|
||||||
const tagsArray = tags.split(",").map(t => t.trim()).filter(Boolean)
|
|
||||||
createMutation.mutate({ name, tags: tagsArray })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0]
|
|
||||||
if (file) {
|
|
||||||
importMutation.mutate(file)
|
|
||||||
}
|
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleExportCsv = () => {
|
|
||||||
if (!customers) return
|
|
||||||
const headers = ["id", "name", "active", "createdAt"]
|
|
||||||
const rows = customers.map(c => [c.id, `"${c.name}"`, c.active, c.createdAt])
|
|
||||||
const csvContent = [headers, ...rows].map(e => e.join(",")).join("\n")
|
|
||||||
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" })
|
|
||||||
const url = URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement("a")
|
|
||||||
link.setAttribute("href", url)
|
|
||||||
link.setAttribute("download", `customers_export_${new Date().toISOString().split('T')[0]}.csv`)
|
|
||||||
link.style.visibility = "hidden"
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isError) return <div className="p-8 text-red-500">Error loading customers.</div>
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-4xl mx-auto p-6">
|
<div className="p-6 max-w-4xl mx-auto">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="flex justify-between items-center mb-6">
|
||||||
<h1 className="text-2xl font-bold text-gray-800">Customers</h1>
|
<h1 className="text-2xl font-bold text-gray-800">Customers</h1>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleExportCsv}
|
onClick={() => setShowArchived(!showArchived)}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 bg-white border rounded hover:bg-gray-50"
|
className={`px-3 py-1 text-sm rounded-full transition-colors ${showArchived ? 'bg-orange-100 text-orange-600' : 'bg-gray-100 text-gray-600'}`}
|
||||||
>
|
>
|
||||||
<Download size={16} /> Export
|
{showArchived ? 'Show Active' : 'Show Archived'}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => fileInputRef.current?.click()}
|
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 bg-white border rounded hover:bg-gray-50"
|
|
||||||
>
|
|
||||||
<Upload size={16} /> Import
|
|
||||||
</button>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
ref={fileInputRef}
|
|
||||||
onChange={handleFileChange}
|
|
||||||
className="hidden"
|
|
||||||
accept=".csv"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8 p-4 bg-gray-50 rounded-lg border">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||||
<div className="flex flex-col gap-1">
|
<div className="md:col-span-2 space-y-4">
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Name</label>
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
<input
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search customers..."
|
||||||
|
className="w-full pl-3 pr-10 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={(e) => setName(e.target.value)}
|
||||||
placeholder="Customer name..."
|
|
||||||
className="px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 outline-none"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="relative">
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Tags (comma separated)</label>
|
|
||||||
<input
|
<input
|
||||||
value={tags}
|
type="text"
|
||||||
onChange={e => setTags(e.target.value)}
|
placeholder="Filter by tag..."
|
||||||
placeholder="VIP, Lead, etc."
|
className="pl-3 pr-10 py-2 border rounded-lg focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
className="px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 outline-none"
|
value={filterTag}
|
||||||
|
onChange={(e) => setFilterTag(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<TagIcon size={14} className="absolute right-3 top-3 text-gray-400" />
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
</div>
|
||||||
|
|
||||||
|
{selectedIds.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg animate-in fade-in slide-in-from-top-2">
|
||||||
|
<span className="text-sm font-medium text-blue-700">{selectedIds.length} selected</span>
|
||||||
|
<div className="flex flex-1 gap-2 ml-4">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Add tag to selected..."
|
||||||
|
className="flex-1 px-3 py-1 text-sm border rounded outline-none focus:ring-1 focus:ring-blue-400"
|
||||||
|
value={bulkTag}
|
||||||
|
onChange={(e) => setBulkTag(e.target.value)}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
onClick={() => bulkTagMutation.mutate({ ids: selectedIds, tag: bulkTag })}
|
||||||
disabled={createMutation.isPending}
|
disabled={!bulkTag || bulkTagMutation.isPending}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded font-medium hover:bg-blue-700 disabled:opacity-50"
|
className="px-3 py-1 text-sm bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={18} /> {createMutation.isPending ? "Adding..." : "Add Customer"}
|
{bulkTagMutation.isPending ? 'Adding...' : 'Add Tag'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
<button onClick={() => setSelectedIds([])} className="p-1 text-blue-400 hover:text-blue-600">
|
||||||
|
<X size={16} />
|
||||||
<div className="flex flex-col md:flex-row gap-4 mb-6 items-center justify-between">
|
</button>
|
||||||
<div className="relative w-full md:w-64">
|
|
||||||
<input
|
|
||||||
value={filterTag}
|
|
||||||
onChange={e => setFilterTag(e.target.value)}
|
|
||||||
placeholder="Filter by tag..."
|
|
||||||
className="w-full pl-3 pr-3 py-2 border rounded-full 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 text-blue-600"
|
|
||||||
/>
|
|
||||||
Show Archived
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div className="text-center py-12 text-gray-500">Loading customers...</div>
|
<div className="text-center py-10 text-gray-500">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filteredAndSortedCustomers.length === 0 ? (
|
{filteredCustomers.map(customer => (
|
||||||
<div className="text-center py-12 text-gray-400 italic">No customers found.</div>
|
|
||||||
) : (
|
|
||||||
filteredAndSortedCustomers.map(customer => (
|
|
||||||
<CustomerRow
|
<CustomerRow
|
||||||
key={customer.id}
|
key={customer.id}
|
||||||
customer={customer}
|
customer={customer}
|
||||||
@ -293,47 +239,45 @@ export default function Customers() {
|
|||||||
onPin={(id, pinned) => pinMutation.mutate({ id, pinned })}
|
onPin={(id, pinned) => pinMutation.mutate({ id, pinned })}
|
||||||
onMerge={(id) => setMergeTarget({ sourceId: id, targetId: "" })}
|
onMerge={(id) => setMergeTarget({ sourceId: id, targetId: "" })}
|
||||||
onDelete={(id) => deleteMutation.mutate(id)}
|
onDelete={(id) => deleteMutation.mutate(id)}
|
||||||
|
isSelected={selectedIds.includes(customer.id)}
|
||||||
|
onSelect={toggleSelect}
|
||||||
/>
|
/>
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mergeTarget && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
|
||||||
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl">
|
|
||||||
<div className="flex justify-between items-center mb-4">
|
|
||||||
<h3 className="text-lg font-bold">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-600 mb-4">
|
|
||||||
Select the target customer to merge the source into.
|
|
||||||
</p>
|
|
||||||
<select
|
|
||||||
className="w-full p-2 border rounded mb-4"
|
|
||||||
value={mergeTarget.targetId}
|
|
||||||
onChange={e => setMergeTarget({ ...mergeTarget, targetId: e.target.value })}
|
|
||||||
>
|
|
||||||
<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>
|
{filteredCustomers.length === 0 && (
|
||||||
|
<div className="text-center py-10 text-gray-400">No customers found.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-gray-50 p-4 rounded-xl border border-gray-200 h-fit">
|
||||||
|
<h2 className="font-semibold mb-4 text-gray-700">Create Customer</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Customer Name"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Tags (comma separated)"
|
||||||
|
className="w-full px-3 py-2 border rounded-lg outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
value={tags}
|
||||||
|
onChange={(e) => setTags(e.target.value)}
|
||||||
|
/>
|
||||||
<button
|
<button
|
||||||
disabled={!mergeTarget.targetId || mergeMutation.isPending}
|
onClick={() => createMutation.mutate({ name, tags: tags.split(',').map(t => t.trim()).filter(Boolean) })}
|
||||||
onClick={() => mergeMutation.mutate({
|
disabled={!name || createMutation.isPending}
|
||||||
sourceId: mergeTarget.sourceId,
|
className="w-full py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors flex items-center justify-center gap-2"
|
||||||
targetId: mergeTarget.targetId
|
|
||||||
})}
|
|
||||||
className="w-full py-2 bg-blue-600 text-white rounded font-medium hover:bg-blue-700 disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{mergeMutation.isPending ? "Merging..." : "Confirm Merge"}
|
<Plus size={18} />
|
||||||
|
{createMutation.isPending ? 'Creating...' : 'Create Customer'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user