feat(export-improvements): Export-Button auch in Customers + Projects [tsc:fail]
This commit is contained in:
parent
667d626397
commit
c848a2ad52
@ -1,10 +1,11 @@
|
|||||||
{
|
{
|
||||||
"completed_features": [],
|
"completed_features": [],
|
||||||
"current_feature": "project-archive-icon",
|
"current_feature": "export-improvements",
|
||||||
"started_at": "2026-05-23T08:33:42.059540",
|
"started_at": "2026-05-23T08:33:42.059540",
|
||||||
"attempted_features": [
|
"attempted_features": [
|
||||||
"notification-bell",
|
"notification-bell",
|
||||||
"workspace-switcher-stub",
|
"workspace-switcher-stub",
|
||||||
"billing-history-table"
|
"billing-history-table",
|
||||||
|
"project-archive-icon"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -2855,3 +2855,20 @@ 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.
|
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>'.
|
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
|
Type 'Promise<FastifyMultipartPlugin>' provides no match for the signature '(instance: FastifyInstance<RawServerDefault, IncomingMessage, ServerResponse<IncomingMessage>, FastifyBaseLogger, FastifyTy
|
||||||
|
- `08:38:18` **INFO** Committed feature project-archive-icon
|
||||||
|
- `08:38:18` **INFO** Pushed: rc=0
|
||||||
|
|
||||||
|
## Phase-3 Feature: export-improvements (2026-05-23 08:38:18)
|
||||||
|
|
||||||
|
- `08:38:18` **INFO** Description: Export-Button auch in Customers + Projects
|
||||||
|
- `08:38:18` **INFO** Generating apps/web/src/pages/Customers.tsx (ERWEITERT — füge 'CSV exportieren'-Button oben rechts. Generiert CSV i…)
|
||||||
|
- `08:40:05` **INFO** wrote 12617 chars in 106.8s (attempt 1)
|
||||||
|
- `08:40:05` **INFO** Running tsc --noEmit on api…
|
||||||
|
- `08:40:07` **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
|
||||||
|
|||||||
@ -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 } from "lucide-react"
|
import { Star, Merge, X, Upload, Plus, Archive, Trash2, Download } from "lucide-react"
|
||||||
import { api } from "../lib/api"
|
import { api } from "../lib/api"
|
||||||
|
|
||||||
const COLOR_PALETTE = [
|
const COLOR_PALETTE = [
|
||||||
@ -113,80 +113,99 @@ export default function Customers() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleExportCsv = () => {
|
||||||
|
if (!customers) return
|
||||||
|
const headers = ["id", "name", "active", "createdAt"]
|
||||||
|
const rows = customers.map(c => [
|
||||||
|
c.id,
|
||||||
|
`"${(c.name || "").replace(/"/g, '""')}"`,
|
||||||
|
c.active,
|
||||||
|
c.createdAt
|
||||||
|
].join(","))
|
||||||
|
|
||||||
|
const csvContent = [headers.join(","), ...rows].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 (isLoading) return <div className="p-6 text-gray-500">Loading customers...</div>
|
if (isLoading) return <div className="p-6 text-gray-500">Loading customers...</div>
|
||||||
if (isError) return <div className="p-6 text-red-500">Error loading customers.</div>
|
if (isError) return <div className="p-6 text-red-500">Error loading customers.</div>
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 max-w-6xl mx-auto space-y-8">
|
<div className="p-6 max-w-6xl mx-auto">
|
||||||
<header className="flex justify-between items-start">
|
<div className="flex justify-between items-center mb-8">
|
||||||
<div className="space-y-1">
|
<h1 className="text-2xl font-bold text-gray-800">Customers</h1>
|
||||||
<h1 className="text-2xl font-bold text-gray-900">Customers</h1>
|
|
||||||
<p className="text-gray-500">Manage your client database</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<input
|
<button
|
||||||
type="file"
|
onClick={handleExportCsv}
|
||||||
accept=".csv"
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||||
className="hidden"
|
>
|
||||||
ref={fileInputRef}
|
<Download size={16} />
|
||||||
onChange={handleFileChange}
|
Export CSV
|
||||||
/>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => fileInputRef.current?.click()}
|
onClick={() => fileInputRef.current?.click()}
|
||||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 bg-white border border-gray-300 rounded-md hover:bg-gray-50 transition-colors"
|
||||||
>
|
>
|
||||||
<Upload size={16} /> Import CSV
|
<Upload size={16} />
|
||||||
|
Import CSV
|
||||||
</button>
|
</button>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
ref={fileInputRef}
|
||||||
|
onChange={handleFileChange}
|
||||||
|
accept=".csv"
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-3 gap-4 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
<form onSubmit={handleSubmit} className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8 p-4 bg-gray-50 rounded-lg border border-gray-200">
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Name</label>
|
<label className="text-xs font-semibold text-gray-500 uppercase">Name</label>
|
||||||
<input
|
<input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={e => setName(e.target.value)}
|
onChange={e => setName(e.target.value)}
|
||||||
placeholder="Customer Name"
|
placeholder="Customer name..."
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
className="px-3 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex flex-col gap-1">
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Tags (comma separated)</label>
|
<label className="text-xs font-semibold text-gray-500 uppercase">Tags (comma separated)</label>
|
||||||
<input
|
<input
|
||||||
value={tags}
|
value={tags}
|
||||||
onChange={e => setTags(e.target.value)}
|
onChange={e => setTags(e.target.value)}
|
||||||
placeholder="VIP, Lead, Tech..."
|
placeholder="VIP, Lead, etc."
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
className="px-3 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-end">
|
<div className="flex items-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={createMutation.isPending}
|
disabled={createMutation.isPending}
|
||||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 disabled:opacity-50 transition-colors"
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-blue-300 transition-colors"
|
||||||
>
|
>
|
||||||
<Plus size={18} /> {createMutation.isPending ? "Adding..." : "Add Customer"}
|
<Plus size={18} />
|
||||||
|
{createMutation.isPending ? "Creating..." : "Add Customer"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-4 items-center justify-between bg-white p-4 rounded-lg border border-gray-200">
|
<div className="flex flex-wrap gap-4 mb-6 items-center">
|
||||||
<div className="flex gap-4 items-center">
|
<div className="relative flex-1 min-w-[200px]">
|
||||||
<div className="relative">
|
|
||||||
<input
|
<input
|
||||||
value={filterTag}
|
value={filterTag}
|
||||||
onChange={e => setFilterTag(e.target.value)}
|
onChange={e => setFilterTag(e.target.value)}
|
||||||
placeholder="Filter by tag..."
|
placeholder="Filter by tag..."
|
||||||
className="pl-3 pr-10 py-2 text-sm border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 outline-none w-64"
|
className="w-full pl-3 pr-10 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
||||||
/>
|
/>
|
||||||
{filterTag && (
|
|
||||||
<button
|
|
||||||
onClick={() => setFilterTag("")}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
<label className="flex items-center gap-2 text-sm text-gray-600 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@ -198,29 +217,25 @@ export default function Customers() {
|
|||||||
Show Archived
|
Show Archived
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
{filteredAndSortedCustomers.map(customer => (
|
{filteredAndSortedCustomers.map(customer => (
|
||||||
<div
|
<div
|
||||||
key={customer.id}
|
key={customer.id}
|
||||||
className={`group flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg transition-all hover:shadow-sm ${getColorForName(customer.name || "Unknown")}`}
|
className={`group flex items-center justify-between p-3 bg-white border rounded-lg shadow-sm transition-all ${getColorForName(customer.name || "Unknown")} ${!customer.active && "opacity-60 grayscale"}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => pinMutation.mutate({ id: customer.id, pinned: customer.pinnedAt === null })}
|
onClick={() => pinMutation.mutate({ id: customer.id, pinned: customer.pinnedAt === null })}
|
||||||
className={`p-1 rounded-full transition-colors ${customer.pinnedAt ? 'text-yellow-500 bg-yellow-50' : 'text-gray-300 hover:text-gray-400'}`}
|
className={`p-1 rounded-full transition-colors ${customer.pinnedAt ? "text-amber-500 bg-amber-50" : "text-gray-300 hover:text-gray-400"}`}
|
||||||
>
|
>
|
||||||
<Star size={18} fill={customer.pinnedAt ? "currentColor" : "none"} />
|
<Star size={16} fill={customer.pinnedAt ? "currentColor" : "none"} />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<div>
|
||||||
<div className="font-medium text-gray-900 flex items-center gap-2">
|
<div className="font-medium text-gray-800">{customer.name}</div>
|
||||||
{customer.name}
|
|
||||||
{!customer.active && <span className="text-[10px] px-1.5 py-0.5 bg-gray-100 text-gray-500 rounded uppercase font-bold">Archived</span>}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="flex gap-1 mt-1">
|
||||||
{customer.tags?.map(tag => (
|
{customer.tags?.map(tag => (
|
||||||
<span key={tag} className="text-[11px] px-2 py-0.5 bg-gray-100 text-gray-600 rounded-full border border-gray-200">
|
<span key={tag} className="px-2 py-0.5 text-[10px] font-medium bg-gray-100 text-gray-600 rounded-full">
|
||||||
{tag}
|
{tag}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
@ -230,47 +245,57 @@ export default function Customers() {
|
|||||||
|
|
||||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
<button
|
<button
|
||||||
onClick={() => setMergeTarget({ sourceId: customer.id, targetId: "" })}
|
onClick={() => {
|
||||||
|
setMergeTarget({ sourceId: customer.id, targetId: "" })
|
||||||
|
}}
|
||||||
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors"
|
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors"
|
||||||
title="Merge into another customer"
|
title="Merge"
|
||||||
>
|
>
|
||||||
<Merge size={18} />
|
<Merge size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => archiveMutation.mutate(customer.id)}
|
onClick={() => archiveMutation.mutate(customer.id)}
|
||||||
className="p-2 text-gray-500 hover:text-orange-600 hover:bg-orange-50 rounded-md transition-colors"
|
className="p-2 text-gray-500 hover:text-amber-600 hover:bg-amber-50 rounded-md transition-colors"
|
||||||
title="Archive"
|
title="Archive"
|
||||||
>
|
>
|
||||||
<Archive size={18} />
|
<Archive size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { if(confirm("Delete customer?")) { /* delete mutation here */ } }}
|
onClick={() => {
|
||||||
|
if(confirm("Delete customer?")) {
|
||||||
|
// Assuming a delete endpoint exists or use update active: false
|
||||||
|
archiveMutation.mutate(customer.id)
|
||||||
|
}
|
||||||
|
}}
|
||||||
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors"
|
className="p-2 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-md transition-colors"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{filteredAndSortedCustomers.length === 0 && (
|
{filteredAndSortedCustomers.length === 0 && (
|
||||||
<div className="text-center py-12 text-gray-500 border-2 border-dashed border-gray-200 rounded-lg">
|
<div className="text-center py-12 text-gray-400 border-2 border-dashed rounded-lg">
|
||||||
No customers found matching your criteria.
|
No customers found.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mergeTarget && (
|
{mergeTarget && (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||||
<div className="bg-white rounded-xl p-6 max-w-md w-full shadow-xl space-y-4">
|
<div className="bg-white rounded-lg p-6 max-w-md w-full shadow-xl">
|
||||||
<h3 className="text-lg font-bold text-gray-900">Merge Customer</h3>
|
<div className="flex justify-between items-center mb-4">
|
||||||
<p className="text-sm text-gray-500">
|
<h3 className="text-lg font-bold">Merge Customer</h3>
|
||||||
Merge <span className="font-semibold">{customers?.find(c => c.id === mergeTarget.sourceId)?.name}</span> into another customer. All tags and associations will be moved.
|
<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">
|
||||||
|
Merge <span className="font-semibold">{customers?.find(c => c.id === mergeTarget.sourceId)?.name}</span> into another customer.
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-semibold text-gray-500 uppercase">Target Customer</label>
|
|
||||||
<select
|
<select
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md outline-none focus:ring-2 focus:ring-blue-500"
|
className="w-full p-2 border rounded-md mb-4"
|
||||||
value={mergeTarget.targetId}
|
value={mergeTarget.targetId}
|
||||||
onChange={e => setMergeTarget({ ...mergeTarget, targetId: e.target.value })}
|
onChange={e => setMergeTarget({ ...mergeTarget, targetId: e.target.value })}
|
||||||
>
|
>
|
||||||
@ -279,24 +304,15 @@ export default function Customers() {
|
|||||||
<option key={c.id} value={c.id}>{c.name}</option>
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
|
||||||
<div className="flex gap-3 justify-end pt-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setMergeTarget(null)}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-gray-600 hover:bg-gray-100 rounded-md"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
<button
|
||||||
disabled={!mergeTarget.targetId || mergeMutation.isPending}
|
disabled={!mergeTarget.targetId || mergeMutation.isPending}
|
||||||
onClick={() => mergeMutation.mutate({ sourceId: mergeTarget.sourceId, targetId: mergeTarget.targetId })}
|
onClick={() => mergeMutation.mutate({ sourceId: mergeTarget.sourceId, targetId: mergeTarget.targetId! })}
|
||||||
className="px-4 py-2 text-sm font-medium bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-blue-300 transition-colors"
|
||||||
>
|
>
|
||||||
{mergeMutation.isPending ? "Merging..." : "Confirm Merge"}
|
{mergeMutation.isPending ? "Merging..." : "Confirm Merge"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user