refactor(cleaner-history): extract BatchItem with React.memo and fix colSpan bug

- Fix colSpan mismatch: material detail row now correctly spans 9 columns
- Use lazy state initialization for Set/Map useState to avoid re-creation
- Remove data-duplicating refs (batchExecutionsRef, batchOrdersRef, orderMaterialsRef)
  and replace with lightweight tracking refs (detailsLoadedRef, loadedMaterialsRef)
- Extract per-batch rendering into BatchItem with React.memo to prevent
  sibling re-renders when expanding/collapsing one batch
- Reduce parent component state from 13 to 5 variables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-04-13 23:09:18 +08:00
parent 420f811488
commit 95eb44979a

View File

@@ -6,7 +6,7 @@
* Admin users see all users' records, regular users see only their own. * Admin users see all users' records, regular users see only their own.
*/ */
import React, { useState, useEffect, useCallback } from 'react' import React, { useState, useEffect, useCallback, useRef } from 'react'
import { Modal } from './ui/Modal' import { Modal } from './ui/Modal'
import { useLogger } from '../hooks/useLogger' import { useLogger } from '../hooks/useLogger'
import { import {
@@ -56,6 +56,12 @@ interface CleanerOperationHistoryModalProps {
user?: { username: string; userType: string } | null user?: { username: string; userType: string } | null
} }
interface BatchItemProps {
batch: CleanerHistoryBatchStats
isAdmin: boolean
onDelete: (batchId: string) => void
}
const statusStyles: Record<string, string> = { const statusStyles: Record<string, string> = {
success: 'bg-green-100 text-green-700', success: 'bg-green-100 text-green-700',
partial: 'bg-amber-100 text-amber-700', partial: 'bg-amber-100 text-amber-700',
@@ -118,106 +124,63 @@ const formatDuration = (startTime: string | Date | null, endTime: string | Date
return `${hours}${remainMinutes}` return `${hours}${remainMinutes}`
} }
export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModalProps> = ({ // ====== BatchItem Component ======
isOpen, // Extracted from the modal so that expanding one batch doesn't re-render siblings.
onClose, // Each BatchItem manages its own details, orders, and material state locally.
user const BatchItem = React.memo(({ batch, isAdmin, onDelete }: BatchItemProps) => {
}) => { const [isExpanded, setIsExpanded] = useState(false)
const [batches, setBatches] = useState<CleanerHistoryBatchStats[]>([]) const [executions, setExecutions] = useState<ExecutionRecord[]>([])
const [loading, setLoading] = useState(false) const [orders, setOrders] = useState<CleanerHistoryOrderRecord[]>([])
const [error, setError] = useState<string | null>(null) const [currentAttempt, setCurrentAttempt] = useState<number | undefined>()
const [expandedBatches, setExpandedBatches] = useState<Set<string>>(new Set()) const [expandedOrders, setExpandedOrders] = useState<Set<string>>(() => new Set())
const [expandedOrders, setExpandedOrders] = useState<Set<string>>(new Set())
const [batchExecutions, setBatchExecutions] = useState<Map<string, ExecutionRecord[]>>(new Map())
const [batchOrders, setBatchOrders] = useState<Map<string, CleanerHistoryOrderRecord[]>>(
new Map()
)
const [orderMaterials, setOrderMaterials] = useState<Map<string, CleanerHistoryMaterialRecord[]>>( const [orderMaterials, setOrderMaterials] = useState<Map<string, CleanerHistoryMaterialRecord[]>>(
new Map() () => new Map()
) )
const [selectedAttempt, setSelectedAttempt] = useState<Map<string, number>>(new Map()) const [loadingMaterials, setLoadingMaterials] = useState<Set<string>>(() => new Set())
const [deleting, setDeleting] = useState<Set<string>>(new Set()) const [isDeleting, setIsDeleting] = useState(false)
const [allUsers, setAllUsers] = useState<string[]>([])
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const [loadingMaterials, setLoadingMaterials] = useState<Set<string>>(new Set())
const logger = useLogger('CleanerOperationHistory')
const isAdmin = user?.userType === 'Admin' const detailsLoadedRef = useRef(false)
const loadedMaterialsRef = useRef<Set<string>>(new Set())
const logger = useLogger('BatchItem')
const fetchBatches = useCallback(async () => { // Fetch batch details when first expanded
setLoading(true) useEffect(() => {
setError(null) if (!isExpanded || detailsLoadedRef.current) return
detailsLoadedRef.current = true
const fetchDetails = async () => {
try { try {
const options = const result = await window.electron.cleaner.getHistoryBatchDetails(batch.batchId)
isAdmin && selectedUsers.length > 0
? { limit: 100, usernames: selectedUsers }
: { limit: 100 }
const result = await window.electron.cleaner.getHistoryBatches(options)
if (result.success && result.data) { if (result.success && result.data) {
setBatches(result.data) setExecutions(result.data.executions)
} else { setOrders(result.data.orders)
setError(result.error || '获取历史记录失败')
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取历史记录失败')
} finally {
setLoading(false)
}
}, [isAdmin, selectedUsers])
const fetchAllUsers = useCallback(async () => { const execs = result.data.executions
try { if (execs.length > 0) {
const result = await window.electron.auth.getAllUsers() setCurrentAttempt(Math.max(...execs.map((e) => e.attemptNumber)))
if (result.success && result.data) {
const usernames = result.data.map((u: UserInfo) => u.username)
setAllUsers(usernames)
}
} catch (err) {
logger.error('Failed to fetch users list', {
error: err instanceof Error ? err.message : String(err)
})
}
}, [logger])
const fetchBatchDetails = useCallback(
async (batchId: string) => {
if (batchExecutions.has(batchId) && batchOrders.has(batchId)) {
return
}
try {
const result = await window.electron.cleaner.getHistoryBatchDetails(batchId)
if (result.success && result.data) {
setBatchExecutions((prev) => new Map(prev).set(batchId, result.data!.executions))
setBatchOrders((prev) => new Map(prev).set(batchId, result.data!.orders))
// Set default selected attempt to the latest (max attempt number)
const executions = result.data.executions
if (executions.length > 0) {
const maxAttempt = Math.max(...executions.map((e) => e.attemptNumber))
setSelectedAttempt((prev) => new Map(prev).set(batchId, maxAttempt))
} }
} }
} catch (err) { } catch (err) {
logger.error('Failed to fetch batch details', { logger.error('Failed to fetch batch details', {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
batchId batchId: batch.batchId
}) })
} }
}, }
[batchExecutions, batchOrders, logger]
)
const fetchMaterialDetails = useCallback( void fetchDetails()
async (batchId: string, attemptNumber: number, orderNumber: string) => { }, [isExpanded, batch.batchId, logger])
const cacheKey = `${batchId}:${attemptNumber}:${orderNumber}`
if (orderMaterials.has(cacheKey)) return const fetchMaterials = useCallback(
async (attemptNumber: number, orderNumber: string) => {
const cacheKey = `${attemptNumber}:${orderNumber}`
if (loadedMaterialsRef.current.has(cacheKey)) return
loadedMaterialsRef.current.add(cacheKey)
setLoadingMaterials((prev) => new Set(prev).add(cacheKey)) setLoadingMaterials((prev) => new Set(prev).add(cacheKey))
try { try {
const result = await window.electron.cleaner.getHistoryMaterialDetails( const result = await window.electron.cleaner.getHistoryMaterialDetails(
batchId, batch.batchId,
attemptNumber, attemptNumber,
orderNumber orderNumber
) )
@@ -227,7 +190,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
} catch (err) { } catch (err) {
logger.error('Failed to fetch material details', { logger.error('Failed to fetch material details', {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
batchId, batchId: batch.batchId,
attemptNumber, attemptNumber,
orderNumber orderNumber
}) })
@@ -239,92 +202,52 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
}) })
} }
}, },
[orderMaterials, logger] [batch.batchId, logger]
) )
useEffect(() => { const toggleOrderExpansion = (attemptNumber: number, orderNumber: string) => {
if (isOpen) { const cacheKey = `${attemptNumber}:${orderNumber}`
void fetchBatches() const isCurrentlyExpanded = expandedOrders.has(cacheKey)
if (isAdmin) {
void fetchAllUsers()
}
}
}, [isOpen, fetchBatches, fetchAllUsers, isAdmin])
const toggleBatchExpansion = (batchId: string) => {
setExpandedBatches((prev) => {
const newSet = new Set(prev)
if (newSet.has(batchId)) {
newSet.delete(batchId)
} else {
newSet.add(batchId)
void fetchBatchDetails(batchId)
}
return newSet
})
}
const toggleOrderExpansion = (batchId: string, attemptNumber: number, orderNumber: string) => {
const cacheKey = `${batchId}:${attemptNumber}:${orderNumber}`
setExpandedOrders((prev) => { setExpandedOrders((prev) => {
const newSet = new Set(prev) const newSet = new Set(prev)
if (newSet.has(cacheKey)) { if (newSet.has(cacheKey)) {
newSet.delete(cacheKey) newSet.delete(cacheKey)
} else { } else {
newSet.add(cacheKey) newSet.add(cacheKey)
void fetchMaterialDetails(batchId, attemptNumber, orderNumber)
} }
return newSet return newSet
}) })
if (!isCurrentlyExpanded) {
void fetchMaterials(attemptNumber, orderNumber)
}
} }
const handleDeleteBatch = async (batchId: string) => { const handleDelete = async () => {
if (deleting.has(batchId)) return if (isDeleting) return
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。') const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
if (!confirmed) return if (!confirmed) return
setDeleting((prev) => new Set(prev).add(batchId)) setIsDeleting(true)
try { try {
const result = await window.electron.cleaner.deleteHistoryBatch(batchId) const result = await window.electron.cleaner.deleteHistoryBatch(batch.batchId)
if (result.success) { if (result.success) {
setBatches((prev) => prev.filter((b) => b.batchId !== batchId)) onDelete(batch.batchId)
setBatchExecutions((prev) => {
const newMap = new Map(prev)
newMap.delete(batchId)
return newMap
})
setBatchOrders((prev) => {
const newMap = new Map(prev)
newMap.delete(batchId)
return newMap
})
setExpandedBatches((prev) => {
const newSet = new Set(prev)
newSet.delete(batchId)
return newSet
})
} else { } else {
alert(result.error || '删除失败') alert(result.error || '删除失败')
} }
} catch (err) { } catch (err) {
alert(err instanceof Error ? err.message : '删除失败') alert(err instanceof Error ? err.message : '删除失败')
} finally { } finally {
setDeleting((prev) => { setIsDeleting(false)
const newSet = new Set(prev)
newSet.delete(batchId)
return newSet
})
} }
} }
const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord, batchId: string) => { const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord) => {
const attempt = selectedAttempt.get(batchId) const filtered =
const orders = batchOrders.get(batchId) || [] currentAttempt !== undefined
const filteredOrders = ? orders.filter((o) => o.attemptNumber === currentAttempt)
attempt !== undefined ? orders.filter((o) => o.attemptNumber === attempt) : orders : orders
const values = filteredOrders const values = filtered
.map((o) => String(o[field] ?? '')) .map((o) => String(o[field] ?? ''))
.filter((v) => v && v !== '-') .filter((v) => v && v !== '-')
.join('\n') .join('\n')
@@ -340,120 +263,19 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
.catch(() => showError('复制失败,请手动复制')) .catch(() => showError('复制失败,请手动复制'))
} }
const toggleUserFilter = (username: string) => {
setSelectedUsers((prev) =>
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
)
}
const clearUserFilters = () => {
setSelectedUsers([])
}
if (!isOpen) return null
return (
<Modal isOpen={isOpen} onClose={onClose} title="清理操作历史" size="3xl">
<div className="flex flex-col h-[70vh]">
{/* Toolbar */}
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
<div className="flex-1">
{isAdmin && allUsers.length > 0 && (
<div className="mb-3">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="flex flex-wrap gap-2">
{allUsers.map((username) => {
const isSelected = selectedUsers.includes(username)
return (
<button
key={username}
onClick={() => toggleUserFilter(username)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
isSelected
? 'bg-blue-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{username}
</button>
)
})}
{selectedUsers.length > 0 && (
<button
onClick={clearUserFilters}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 transition-all"
>
</button>
)}
</div>
</div>
)}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{isAdmin ? (
<span className="text-amber-600 font-medium">
{selectedUsers.length > 0
? `已选择 ${selectedUsers.length} 个用户`
: '显示所有用户记录'}
</span>
) : (
<span></span>
)}
</span>
{batches.length > 0 && (
<span className="text-sm text-gray-500"> {batches.length} </span>
)}
</div>
</div>
<button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50 flex-shrink-0"
onClick={() => void fetchBatches()}
disabled={loading}
title="刷新"
>
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Error message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{/* Batch list */}
<div className="flex-1 overflow-y-auto">
{loading && batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500">...</div>
) : batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500"></div>
) : (
<div className="flex flex-col gap-3">
{batches.map((batch) => {
const isExpanded = expandedBatches.has(batch.batchId)
const executions = batchExecutions.get(batch.batchId) || []
const orders = batchOrders.get(batch.batchId) || []
const isDeleting = deleting.has(batch.batchId)
const currentAttempt = selectedAttempt.get(batch.batchId)
const filteredOrders = const filteredOrders =
currentAttempt !== undefined currentAttempt !== undefined
? orders.filter((o) => o.attemptNumber === currentAttempt) ? orders.filter((o) => o.attemptNumber === currentAttempt)
: orders : orders
return ( return (
<div <div className="border border-gray-200 rounded-lg overflow-hidden">
key={batch.batchId}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* Batch summary */} {/* Batch summary */}
<div <div
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${ className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50' isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
}`} }`}
onClick={() => toggleBatchExpansion(batch.batchId)} onClick={() => setIsExpanded((prev) => !prev)}
> >
<div className="flex items-center gap-4 flex-1"> <div className="flex items-center gap-4 flex-1">
<button className="p-1 hover:bg-gray-200 rounded"> <button className="p-1 hover:bg-gray-200 rounded">
@@ -525,7 +347,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50" className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
void handleDeleteBatch(batch.batchId) void handleDelete()
}} }}
disabled={isDeleting} disabled={isDeleting}
title="删除批次" title="删除批次"
@@ -552,11 +374,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
? 'bg-blue-600 text-white' ? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`} }`}
onClick={() => onClick={() => setCurrentAttempt(exec.attemptNumber)}
setSelectedAttempt((prev) =>
new Map(prev).set(batch.batchId, exec.attemptNumber)
)
}
> >
{exec.attemptNumber} {exec.attemptNumber}
</button> </button>
@@ -616,9 +434,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
<button <button
className="p-1 hover:bg-gray-200 rounded transition-colors" className="p-1 hover:bg-gray-200 rounded transition-colors"
onClick={() => onClick={() => handleCopyColumn('orderNumber')}
handleCopyColumn('orderNumber', batch.batchId)
}
title="复制所有订单号" title="复制所有订单号"
> >
<Copy <Copy
@@ -653,7 +469,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
</thead> </thead>
<tbody className="divide-y divide-gray-100"> <tbody className="divide-y divide-gray-100">
{filteredOrders.map((order) => { {filteredOrders.map((order) => {
const orderKey = `${batch.batchId}:${currentAttempt ?? order.attemptNumber}:${order.orderNumber}` const orderKey = `${currentAttempt ?? order.attemptNumber}:${order.orderNumber}`
const isOrderExpanded = expandedOrders.has(orderKey) const isOrderExpanded = expandedOrders.has(orderKey)
const materials = orderMaterials.get(orderKey) || [] const materials = orderMaterials.get(orderKey) || []
const isLoadingMaterials = loadingMaterials.has(orderKey) const isLoadingMaterials = loadingMaterials.has(orderKey)
@@ -664,7 +480,6 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
className="hover:bg-gray-50 cursor-pointer" className="hover:bg-gray-50 cursor-pointer"
onClick={() => onClick={() =>
toggleOrderExpansion( toggleOrderExpansion(
batch.batchId,
currentAttempt ?? order.attemptNumber, currentAttempt ?? order.attemptNumber,
order.orderNumber order.orderNumber
) )
@@ -734,7 +549,7 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
{/* Material details */} {/* Material details */}
{isOrderExpanded && ( {isOrderExpanded && (
<tr> <tr>
<td colSpan={8} className="bg-gray-50/50 px-8 py-3"> <td colSpan={9} className="bg-gray-50/50 px-8 py-3">
{isLoadingMaterials ? ( {isLoadingMaterials ? (
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
... ...
@@ -839,7 +654,174 @@ export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModal
)} )}
</div> </div>
) )
})
BatchItem.displayName = 'BatchItem'
// ====== Main Modal Component ======
export const CleanerOperationHistoryModal: React.FC<CleanerOperationHistoryModalProps> = ({
isOpen,
onClose,
user
}) => {
const [batches, setBatches] = useState<CleanerHistoryBatchStats[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [allUsers, setAllUsers] = useState<string[]>([])
const [selectedUsers, setSelectedUsers] = useState<string[]>([])
const logger = useLogger('CleanerOperationHistory')
const isAdmin = user?.userType === 'Admin'
const fetchBatches = useCallback(async () => {
setLoading(true)
setError(null)
try {
const options =
isAdmin && selectedUsers.length > 0
? { limit: 100, usernames: selectedUsers }
: { limit: 100 }
const result = await window.electron.cleaner.getHistoryBatches(options)
if (result.success && result.data) {
setBatches(result.data)
} else {
setError(result.error || '获取历史记录失败')
}
} catch (err) {
setError(err instanceof Error ? err.message : '获取历史记录失败')
} finally {
setLoading(false)
}
}, [isAdmin, selectedUsers])
const fetchAllUsers = useCallback(async () => {
try {
const result = await window.electron.auth.getAllUsers()
if (result.success && result.data) {
const usernames = result.data.map((u: UserInfo) => u.username)
setAllUsers(usernames)
}
} catch (err) {
logger.error('Failed to fetch users list', {
error: err instanceof Error ? err.message : String(err)
})
}
}, [logger])
useEffect(() => {
if (isOpen) {
void fetchBatches()
if (isAdmin) {
void fetchAllUsers()
}
}
}, [isOpen, fetchBatches, fetchAllUsers, isAdmin])
const handleDeleteBatch = useCallback((batchId: string) => {
setBatches((prev) => prev.filter((b) => b.batchId !== batchId))
}, [])
const toggleUserFilter = (username: string) => {
setSelectedUsers((prev) =>
prev.includes(username) ? prev.filter((u) => u !== username) : [...prev, username]
)
}
const clearUserFilters = () => {
setSelectedUsers([])
}
if (!isOpen) return null
return (
<Modal isOpen={isOpen} onClose={onClose} title="清理操作历史" size="3xl">
<div className="flex flex-col h-[70vh]">
{/* Toolbar */}
<div className="flex items-start justify-between mb-4 pb-4 border-b border-gray-200">
<div className="flex-1">
{isAdmin && allUsers.length > 0 && (
<div className="mb-3">
<div className="text-xs text-gray-500 mb-2"></div>
<div className="flex flex-wrap gap-2">
{allUsers.map((username) => {
const isSelected = selectedUsers.includes(username)
return (
<button
key={username}
onClick={() => toggleUserFilter(username)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-all ${
isSelected
? 'bg-blue-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
}`}
>
{username}
</button>
)
})} })}
{selectedUsers.length > 0 && (
<button
onClick={clearUserFilters}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-full text-sm font-medium bg-red-50 text-red-600 hover:bg-red-100 transition-all"
>
</button>
)}
</div>
</div>
)}
<div className="flex items-center gap-4">
<span className="text-sm text-gray-600">
{isAdmin ? (
<span className="text-amber-600 font-medium">
{selectedUsers.length > 0
? `已选择 ${selectedUsers.length} 个用户`
: '显示所有用户记录'}
</span>
) : (
<span></span>
)}
</span>
{batches.length > 0 && (
<span className="text-sm text-gray-500"> {batches.length} </span>
)}
</div>
</div>
<button
className="p-2 hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-50 flex-shrink-0"
onClick={() => void fetchBatches()}
disabled={loading}
title="刷新"
>
<RefreshCw size={18} className={loading ? 'animate-spin' : ''} />
</button>
</div>
{/* Error message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}
{/* Batch list */}
<div className="flex-1 overflow-y-auto">
{loading && batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500">...</div>
) : batches.length === 0 ? (
<div className="flex items-center justify-center h-32 text-gray-500"></div>
) : (
<div className="flex flex-col gap-3">
{batches.map((batch) => (
<BatchItem
key={batch.batchId}
batch={batch}
isAdmin={isAdmin}
onDelete={handleDeleteBatch}
/>
))}
</div> </div>
)} )}
</div> </div>