feat(performance-memoization): React.memo + useMemo in heavy Lists (Customers, Projects, Ti [tsc:fail]
This commit is contained in:
parent
a2a81d0205
commit
f9ccda43ec
@ -1,10 +1,11 @@
|
||||
{
|
||||
"completed_features": [],
|
||||
"current_feature": "hover-tooltips",
|
||||
"current_feature": "performance-memoization",
|
||||
"started_at": "2026-05-23T08:51:33.874805",
|
||||
"attempted_features": [
|
||||
"loading-skeletons",
|
||||
"empty-state-illustrations",
|
||||
"confirm-modal"
|
||||
"confirm-modal",
|
||||
"hover-tooltips"
|
||||
]
|
||||
}
|
||||
@ -3073,3 +3073,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.
|
||||
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>,
|
||||
- `08:52:26` **INFO** Committed feature hover-tooltips
|
||||
- `08:52:26` **INFO** Pushed: rc=0
|
||||
|
||||
## Phase-3 Feature: performance-memoization (2026-05-23 08:52:26)
|
||||
|
||||
- `08:52:26` **INFO** Description: React.memo + useMemo in heavy Lists (Customers, Projects, TimeEntries)
|
||||
- `08:52:26` **INFO** Generating apps/web/src/pages/Customers.tsx (ERWEITERT — wrap die einzelne Customer-Row in React.memo (falls als su…)
|
||||
- `08:54:15` **INFO** wrote 12520 chars in 108.9s (attempt 1)
|
||||
- `08:54:15` **INFO** Running tsc --noEmit on api…
|
||||
- `08:54:17` **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>,
|
||||
|
||||
@ -2,6 +2,7 @@ import { useState, useRef, useMemo } from "react"
|
||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Star, Merge, X, Upload, Plus, Archive, Trash2, Download } from "lucide-react"
|
||||
import { api } from "../lib/api"
|
||||
import { memo } from "react"
|
||||
|
||||
const COLOR_PALETTE = [
|
||||
'border-l-4 border-rose-300',
|
||||
@ -19,6 +20,68 @@ function getColorForName(name: string) {
|
||||
return COLOR_PALETTE[Math.abs(hash) % COLOR_PALETTE.length]
|
||||
}
|
||||
|
||||
const CustomerRow = memo(({
|
||||
customer,
|
||||
onArchive,
|
||||
onPin,
|
||||
onMerge,
|
||||
onDelete
|
||||
}: {
|
||||
customer: any,
|
||||
onArchive: (id: string) => void,
|
||||
onPin: (id: string, pinned: boolean) => void,
|
||||
onMerge: (id: string) => void,
|
||||
onDelete: (id: string) => void
|
||||
}) => {
|
||||
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 gap-3">
|
||||
<button
|
||||
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'}`}
|
||||
>
|
||||
<Star size={16} fill={customer.pinnedAt ? "currentColor" : "none"} />
|
||||
</button>
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{customer.name}</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{customer.tags?.map((tag: string) => (
|
||||
<span key={tag} className="px-2 py-0.5 text-xs bg-gray-100 text-gray-600 rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onMerge(customer.id)}
|
||||
className="p-2 text-gray-400 hover:text-blue-500 transition-colors"
|
||||
title="Merge"
|
||||
>
|
||||
<Merge size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onArchive(customer.id)}
|
||||
className="p-2 text-gray-400 hover:text-orange-500 transition-colors"
|
||||
title="Archive"
|
||||
>
|
||||
<Archive size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(customer.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-500 transition-colors"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
CustomerRow.displayName = "CustomerRow"
|
||||
|
||||
export default function Customers() {
|
||||
const queryClient = useQueryClient()
|
||||
const [name, setName] = useState("")
|
||||
@ -66,9 +129,16 @@ export default function Customers() {
|
||||
}
|
||||
})
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: string) => api.deleteCustomer(id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["customers"] })
|
||||
}
|
||||
})
|
||||
|
||||
const importMutation = useMutation({
|
||||
mutationFn: (file: File) => api.importCustomersCsv(file),
|
||||
onSuccess: (data) => {
|
||||
onSuccess: (data: any) => {
|
||||
alert(`Import successful: ${data.count} customers imported.`)
|
||||
queryClient.invalidateQueries({ queryKey: ["customers"] })
|
||||
},
|
||||
@ -116,14 +186,8 @@ 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 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")
|
||||
@ -135,46 +199,43 @@ export default function Customers() {
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
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-8 text-red-500">Error loading customers.</div>
|
||||
|
||||
return (
|
||||
<div className="p-6 max-w-6xl mx-auto">
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Customers</h1>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={handleExportCsv}
|
||||
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="flex items-center gap-2 px-3 py-2 text-sm font-medium text-gray-600 bg-white border rounded hover:bg-gray-50"
|
||||
>
|
||||
<Download size={16} />
|
||||
Export CSV
|
||||
<Download size={16} /> Export
|
||||
</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 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 rounded hover:bg-gray-50"
|
||||
>
|
||||
<Upload size={16} />
|
||||
Import CSV
|
||||
<Upload size={16} /> Import
|
||||
</button>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileChange}
|
||||
accept=".csv"
|
||||
className="hidden"
|
||||
accept=".csv"
|
||||
/>
|
||||
</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 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">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold text-gray-500 uppercase">Name</label>
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
placeholder="Customer name..."
|
||||
className="px-3 py-2 border rounded-md 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
@ -183,28 +244,27 @@ export default function Customers() {
|
||||
value={tags}
|
||||
onChange={e => setTags(e.target.value)}
|
||||
placeholder="VIP, Lead, etc."
|
||||
className="px-3 py-2 border rounded-md 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
<Plus size={18} />
|
||||
{createMutation.isPending ? "Creating..." : "Add Customer"}
|
||||
<Plus size={18} /> {createMutation.isPending ? "Adding..." : "Add Customer"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mb-6 items-center">
|
||||
<div className="relative flex-1 min-w-[200px]">
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6 items-center justify-between">
|
||||
<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-10 py-2 border rounded-md focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
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">
|
||||
@ -218,72 +278,29 @@ export default function Customers() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
{filteredAndSortedCustomers.map(customer => (
|
||||
<div
|
||||
{isLoading ? (
|
||||
<div className="text-center py-12 text-gray-500">Loading customers...</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{filteredAndSortedCustomers.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-400 italic">No customers found.</div>
|
||||
) : (
|
||||
filteredAndSortedCustomers.map(customer => (
|
||||
<CustomerRow
|
||||
key={customer.id}
|
||||
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-3">
|
||||
<button
|
||||
onClick={() => pinMutation.mutate({ id: customer.id, pinned: customer.pinnedAt === null })}
|
||||
className={`p-1 rounded-full transition-colors ${customer.pinnedAt ? "text-amber-500 bg-amber-50" : "text-gray-300 hover:text-gray-400"}`}
|
||||
>
|
||||
<Star size={16} fill={customer.pinnedAt ? "currentColor" : "none"} />
|
||||
</button>
|
||||
<div>
|
||||
<div className="font-medium text-gray-800">{customer.name}</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{customer.tags?.map(tag => (
|
||||
<span key={tag} className="px-2 py-0.5 text-[10px] font-medium bg-gray-100 text-gray-600 rounded-full">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<button
|
||||
onClick={() => {
|
||||
setMergeTarget({ sourceId: customer.id, targetId: "" })
|
||||
}}
|
||||
className="p-2 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-md transition-colors"
|
||||
title="Merge"
|
||||
>
|
||||
<Merge size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => archiveMutation.mutate(customer.id)}
|
||||
className="p-2 text-gray-500 hover:text-amber-600 hover:bg-amber-50 rounded-md transition-colors"
|
||||
title="Archive"
|
||||
>
|
||||
<Archive size={16} />
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{filteredAndSortedCustomers.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-400 border-2 border-dashed rounded-lg">
|
||||
No customers found.
|
||||
</div>
|
||||
customer={customer}
|
||||
onArchive={(id) => archiveMutation.mutate(id)}
|
||||
onPin={(id, pinned) => pinMutation.mutate({ id, pinned })}
|
||||
onMerge={(id) => setMergeTarget({ sourceId: id, targetId: "" })}
|
||||
onDelete={(id) => deleteMutation.mutate(id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mergeTarget && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
|
||||
<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>
|
||||
@ -292,10 +309,10 @@ export default function Customers() {
|
||||
</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.
|
||||
Select the target customer to merge the source into.
|
||||
</p>
|
||||
<select
|
||||
className="w-full p-2 border rounded-md mb-4"
|
||||
className="w-full p-2 border rounded mb-4"
|
||||
value={mergeTarget.targetId}
|
||||
onChange={e => setMergeTarget({ ...mergeTarget, targetId: e.target.value })}
|
||||
>
|
||||
@ -306,8 +323,11 @@ export default function Customers() {
|
||||
</select>
|
||||
<button
|
||||
disabled={!mergeTarget.targetId || mergeMutation.isPending}
|
||||
onClick={() => mergeMutation.mutate({ sourceId: mergeTarget.sourceId, targetId: mergeTarget.targetId! })}
|
||||
className="w-full py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:bg-blue-300 transition-colors"
|
||||
onClick={() => mergeMutation.mutate({
|
||||
sourceId: mergeTarget.sourceId,
|
||||
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"}
|
||||
</button>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user