feat(cleaner): add operation history modal with database-backed records

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka_Company
2026-04-13 12:23:57 +08:00
parent bd68444a74
commit 4c40457c71
3 changed files with 866 additions and 0 deletions

View File

@@ -0,0 +1,840 @@
/**
* Cleaner Operation History Modal
*
* Displays cleaner operation history with batch statistics, execution records,
* order details, and material-level details.
* Admin users see all users' records, regular users see only their own.
*/
import React, { useState, useEffect, useCallback } from 'react'
import { Modal } from './ui/Modal'
import { useLogger } from '../hooks/useLogger'
import {
RefreshCw,
Trash2,
ChevronDown,
ChevronRight,
CheckCircle,
XCircle,
Clock,
Copy,
FlaskConical
} from 'lucide-react'
import type { UserInfo } from './UserSelectionDialog'
import type {
CleanerHistoryBatchStats,
CleanerHistoryOrderRecord,
CleanerHistoryMaterialRecord
} from '../hooks/cleaner/types'
// The preload API returns Date for time fields, but IPC serialization converts them to strings.
// Use a local type that accommodates both to satisfy TypeScript.
interface ExecutionRecord {
id?: number
batchId: string
attemptNumber: number
userId: number
username: string
operationTime: string | Date
endTime: string | Date | null
status: string
isDryRun: boolean
totalOrders: number
ordersProcessed: number
totalMaterialsDeleted: number
totalMaterialsSkipped: number
totalMaterialsFailed: number
totalUncertainDeletions: number
errorMessage: string | null
appVersion: string | null
}
import { showSuccess, showError, showWarning } from '../stores/useAppStore'
interface CleanerOperationHistoryModalProps {
isOpen: boolean
onClose: () => void
user?: { username: string; userType: string } | null
}
const statusStyles: Record<string, string> = {
success: 'bg-green-100 text-green-700',
partial: 'bg-amber-100 text-amber-700',
failed: 'bg-red-100 text-red-700',
crashed: 'bg-red-100 text-red-700',
pending: 'bg-gray-100 text-gray-700'
}
const statusLabels: Record<string, string> = {
success: '成功',
partial: '部分成功',
failed: '失败',
crashed: '崩溃',
pending: '进行中'
}
const statusIcons: Record<string, React.ReactNode> = {
success: <CheckCircle size={16} className="text-green-600" />,
partial: <Clock size={16} className="text-amber-600" />,
failed: <XCircle size={16} className="text-red-600" />,
crashed: <XCircle size={16} className="text-red-600" />,
pending: <Clock size={16} className="text-gray-500" />
}
const formatDateTime = (dateStr: string | Date | null | undefined): string => {
if (!dateStr) return '-'
const date = typeof dateStr === 'string' ? new Date(dateStr) : dateStr
if (isNaN(date.getTime())) {
return String(dateStr)
}
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, '0')
const day = String(date.getUTCDate()).padStart(2, '0')
const hours = String(date.getUTCHours()).padStart(2, '0')
const minutes = String(date.getUTCMinutes()).padStart(2, '0')
const seconds = String(date.getUTCSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
const formatDuration = (
startTime: string | Date | null,
endTime: string | Date | null
) => {
if (!startTime || !endTime) return '-'
const start = typeof startTime === 'string' ? new Date(startTime) : startTime
const end = typeof endTime === 'string' ? new Date(endTime) : endTime
if (isNaN(start.getTime()) || isNaN(end.getTime())) return '-'
const diffMs = end.getTime() - start.getTime()
const seconds = Math.floor(diffMs / 1000)
if (seconds < 60) return `${seconds}`
const minutes = Math.floor(seconds / 60)
const remainSeconds = seconds % 60
if (minutes < 60) return `${minutes}${remainSeconds}`
const hours = Math.floor(minutes / 60)
const remainMinutes = minutes % 60
return `${hours}${remainMinutes}`
}
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 [expandedBatches, setExpandedBatches] = 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[]>
>(new Map())
const [selectedAttempt, setSelectedAttempt] = useState<Map<string, number>>(new Map())
const [deleting, setDeleting] = useState<Set<string>>(new Set())
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 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])
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) {
logger.error('Failed to fetch batch details', {
error: err instanceof Error ? err.message : String(err),
batchId
})
}
},
[batchExecutions, batchOrders, logger]
)
const fetchMaterialDetails = useCallback(
async (batchId: string, attemptNumber: number, orderNumber: string) => {
const cacheKey = `${batchId}:${attemptNumber}:${orderNumber}`
if (orderMaterials.has(cacheKey)) return
setLoadingMaterials((prev) => new Set(prev).add(cacheKey))
try {
const result = await window.electron.cleaner.getHistoryMaterialDetails(
batchId,
attemptNumber,
orderNumber
)
if (result.success && result.data) {
setOrderMaterials((prev) => new Map(prev).set(cacheKey, result.data!))
}
} catch (err) {
logger.error('Failed to fetch material details', {
error: err instanceof Error ? err.message : String(err),
batchId,
attemptNumber,
orderNumber
})
} finally {
setLoadingMaterials((prev) => {
const newSet = new Set(prev)
newSet.delete(cacheKey)
return newSet
})
}
},
[orderMaterials, logger]
)
useEffect(() => {
if (isOpen) {
void fetchBatches()
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) => {
const newSet = new Set(prev)
if (newSet.has(cacheKey)) {
newSet.delete(cacheKey)
} else {
newSet.add(cacheKey)
void fetchMaterialDetails(batchId, attemptNumber, orderNumber)
}
return newSet
})
}
const handleDeleteBatch = async (batchId: string) => {
if (deleting.has(batchId)) return
const confirmed = confirm('确定要删除此批次记录吗?此操作不可撤销。')
if (!confirmed) return
setDeleting((prev) => new Set(prev).add(batchId))
try {
const result = await window.electron.cleaner.deleteHistoryBatch(batchId)
if (result.success) {
setBatches((prev) => prev.filter((b) => b.batchId !== 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 {
alert(result.error || '删除失败')
}
} catch (err) {
alert(err instanceof Error ? err.message : '删除失败')
} finally {
setDeleting((prev) => {
const newSet = new Set(prev)
newSet.delete(batchId)
return newSet
})
}
}
const handleCopyColumn = (field: keyof CleanerHistoryOrderRecord, batchId: string) => {
const attempt = selectedAttempt.get(batchId)
const orders = batchOrders.get(batchId) || []
const filteredOrders =
attempt !== undefined ? orders.filter((o) => o.attemptNumber === attempt) : orders
const values = filteredOrders
.map((o) => String(o[field] ?? ''))
.filter((v) => v && v !== '-')
.join('\n')
if (!values) {
showWarning('没有可复制的数据')
return
}
navigator.clipboard
.writeText(values)
.then(() => showSuccess(`已复制 ${values.split('\n').length} 条数据`))
.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 =
currentAttempt !== undefined
? orders.filter((o) => o.attemptNumber === currentAttempt)
: orders
return (
<div
key={batch.batchId}
className="border border-gray-200 rounded-lg overflow-hidden"
>
{/* Batch summary */}
<div
className={`flex items-center justify-between p-4 cursor-pointer transition-colors ${
isExpanded ? 'bg-gray-50' : 'hover:bg-gray-50'
}`}
onClick={() => toggleBatchExpansion(batch.batchId)}
>
<div className="flex items-center gap-4 flex-1">
<button className="p-1 hover:bg-gray-200 rounded">
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
</button>
<div className="flex-1 grid grid-cols-7 gap-3 text-sm">
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">
{formatDateTime(batch.operationTime)}
</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.username}</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="flex items-center gap-1">
{statusIcons[batch.status] || statusIcons.pending}
<span
className={`px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[batch.status] || statusStyles.pending
}`}
>
{statusLabels[batch.status] || batch.status}
</span>
</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">{batch.totalOrders}</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-green-600">
{batch.totalMaterialsDeleted}
</div>
</div>
<div>
<div className="text-gray-500 text-xs"></div>
<div className="font-medium text-gray-900">
{batch.totalMaterialsFailed > 0 ? (
<span className="text-red-600">{batch.totalMaterialsFailed}</span>
) : (
'0'
)}
</div>
</div>
<div className="flex items-center gap-2">
{batch.isDryRun && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium bg-purple-100 text-purple-700">
<FlaskConical size={12} />
</span>
)}
{batch.totalAttempts > 1 && (
<span className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-700">
{batch.totalAttempts}
</span>
)}
</div>
</div>
</div>
{isAdmin && (
<button
className="p-2 hover:bg-red-50 text-gray-400 hover:text-red-600 rounded transition-colors disabled:opacity-50"
onClick={(e) => {
e.stopPropagation()
void handleDeleteBatch(batch.batchId)
}}
disabled={isDeleting}
title="删除批次"
>
<Trash2 size={16} className={isDeleting ? 'animate-pulse' : ''} />
</button>
)}
</div>
{/* Batch details */}
{isExpanded && (
<div className="border-t border-gray-200 bg-white">
{/* Execution records */}
{executions.length > 0 && (
<div className="px-4 py-3 bg-gray-50/50 border-b border-gray-100">
<div className="text-xs font-medium text-gray-500 mb-2">
</div>
{executions.length > 1 && (
<div className="flex gap-1 mb-2">
{executions.map((exec) => (
<button
key={exec.attemptNumber}
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
currentAttempt === exec.attemptNumber
? 'bg-blue-600 text-white'
: 'bg-gray-200 text-gray-700 hover:bg-gray-300'
}`}
onClick={() =>
setSelectedAttempt((prev) =>
new Map(prev).set(batch.batchId, exec.attemptNumber)
)
}
>
{exec.attemptNumber}
</button>
))}
</div>
)}
<div className="flex flex-wrap gap-4 text-xs text-gray-600">
{executions
.filter(
(e) =>
currentAttempt === undefined ||
e.attemptNumber === currentAttempt
)
.map((exec) => (
<React.Fragment key={exec.attemptNumber}>
<span>
{formatDuration(exec.operationTime, exec.endTime)}
</span>
<span>
{exec.ordersProcessed}/{exec.totalOrders}
</span>
<span>{exec.totalMaterialsDeleted}</span>
{exec.totalMaterialsFailed > 0 && (
<span className="text-red-600">
{exec.totalMaterialsFailed}
</span>
)}
{exec.totalUncertainDeletions > 0 && (
<span className="text-amber-600">
{exec.totalUncertainDeletions}
</span>
)}
{exec.errorMessage && (
<span className="text-red-600" title={exec.errorMessage}>
{exec.errorMessage.substring(0, 80)}
{exec.errorMessage.length > 80 ? '...' : ''}
</span>
)}
{exec.appVersion && (
<span className="text-gray-400">v{exec.appVersion}</span>
)}
</React.Fragment>
))}
</div>
</div>
)}
{/* Order table */}
{filteredOrders.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-2 text-left font-medium text-gray-600 w-8" />
<th className="px-4 py-2 text-left font-medium text-gray-600">
<div className="flex items-center gap-2">
<button
className="p-1 hover:bg-gray-200 rounded transition-colors"
onClick={() =>
handleCopyColumn('orderNumber', batch.batchId)
}
title="复制所有订单号"
>
<Copy
size={14}
className="text-gray-500 hover:text-gray-700"
/>
</button>
</div>
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
<th className="px-4 py-2 text-left font-medium text-gray-600">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{filteredOrders.map((order) => {
const orderKey = `${batch.batchId}:${currentAttempt ?? order.attemptNumber}:${order.orderNumber}`
const isOrderExpanded = expandedOrders.has(orderKey)
const materials = orderMaterials.get(orderKey) || []
const isLoadingMaterials =
loadingMaterials.has(orderKey)
return (
<React.Fragment key={orderKey}>
<tr
className="hover:bg-gray-50 cursor-pointer"
onClick={() =>
toggleOrderExpansion(
batch.batchId,
currentAttempt ?? order.attemptNumber,
order.orderNumber
)
}
>
<td className="px-4 py-2">
{isOrderExpanded ? (
<ChevronDown size={14} className="text-gray-400" />
) : (
<ChevronRight size={14} className="text-gray-400" />
)}
</td>
<td className="px-4 py-2 text-gray-900 font-mono text-xs">
{order.orderNumber}
</td>
<td className="px-4 py-2">
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium ${
statusStyles[order.status] || statusStyles.pending
}`}
>
{statusIcons[order.status]}
{statusLabels[order.status] || order.status}
</span>
</td>
<td className="px-4 py-2 text-green-600">
{order.materialsDeleted}
</td>
<td className="px-4 py-2 text-gray-500">
{order.materialsSkipped}
</td>
<td className="px-4 py-2 text-red-600">
{order.materialsFailed || '-'}
</td>
<td className="px-4 py-2 text-amber-600">
{order.uncertainDeletions || '-'}
</td>
<td className="px-4 py-2 text-red-600 text-xs max-w-xs truncate">
{order.errorMessage || '-'}
</td>
</tr>
{/* Material details */}
{isOrderExpanded && (
<tr>
<td colSpan={8} className="bg-gray-50/50 px-8 py-3">
{isLoadingMaterials ? (
<div className="text-xs text-gray-500">
...
</div>
) : materials.length > 0 ? (
<table className="w-full text-xs">
<thead>
<tr className="text-gray-500">
<th className="px-3 py-1.5 text-left font-medium">
</th>
<th className="px-3 py-1.5 text-left font-medium">
</th>
<th className="px-3 py-1.5 text-left font-medium">
</th>
<th className="px-3 py-1.5 text-left font-medium">
</th>
<th className="px-3 py-1.5 text-left font-medium">
</th>
<th className="px-3 py-1.5 text-left font-medium">
</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{materials.map((mat, idx) => (
<tr key={idx} className="hover:bg-gray-50">
<td className="px-3 py-1.5 font-mono text-gray-700">
{mat.materialCode}
</td>
<td className="px-3 py-1.5 text-gray-700">
{mat.materialName}
</td>
<td className="px-3 py-1.5 text-gray-600">
{mat.rowNumber}
</td>
<td className="px-3 py-1.5">
<span
className={`px-1.5 py-0.5 rounded text-xs font-medium ${
mat.result === 'deleted'
? 'bg-green-100 text-green-700'
: mat.result === 'skipped'
? 'bg-gray-100 text-gray-700'
: mat.result === 'failed'
? 'bg-red-100 text-red-700'
: mat.result === 'uncertain'
? 'bg-amber-100 text-amber-700'
: 'bg-gray-100 text-gray-700'
}`}
>
{mat.result === 'deleted'
? '已删除'
: mat.result === 'skipped'
? '已跳过'
: mat.result === 'failed'
? '失败'
: mat.result === 'uncertain'
? '不确定'
: mat.result}
</span>
</td>
<td className="px-3 py-1.5 text-gray-600 max-w-xs truncate">
{mat.reason || '-'}
</td>
<td className="px-3 py-1.5 text-gray-600">
{mat.attemptCount > 1 ? (
<span className="text-amber-600">
{mat.attemptCount}
</span>
) : (
'1'
)}
</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="text-xs text-gray-500">
</div>
)}
</td>
</tr>
)}
</React.Fragment>
)})}
</tbody>
</table>
</div>
) : (
<div className="px-4 py-6 text-center text-sm text-gray-500">
</div>
)}
</div>
)}
</div>
)
})}
</div>
)}
</div>
{/* Footer */}
<div className="pt-4 border-t border-gray-200 flex justify-end">
<button
className="px-6 py-2 rounded-md bg-gray-100 hover:bg-gray-200 text-gray-700 font-medium transition-colors"
onClick={onClose}
>
</button>
</div>
</div>
</Modal>
)
}
export default CleanerOperationHistoryModal

View File

@@ -6,6 +6,7 @@ import {
FileSpreadsheet, FileSpreadsheet,
FileText, FileText,
HardDrive, HardDrive,
History,
Search, Search,
Settings2, Settings2,
Square Square
@@ -27,6 +28,7 @@ interface CleanerToolbarProps {
handleConfirmDeletion: () => Promise<void> handleConfirmDeletion: () => Promise<void>
handleExportResults: () => Promise<void> handleExportResults: () => Promise<void>
setIsReportViewerOpen: (open: boolean) => void setIsReportViewerOpen: (open: boolean) => void
setShowHistoryModal: (open: boolean) => void
typeManagementButtonRef: React.RefObject<HTMLButtonElement | null> typeManagementButtonRef: React.RefObject<HTMLButtonElement | null>
} }
@@ -44,6 +46,7 @@ export function CleanerToolbar({
handleConfirmDeletion, handleConfirmDeletion,
handleExportResults, handleExportResults,
setIsReportViewerOpen, setIsReportViewerOpen,
setShowHistoryModal,
typeManagementButtonRef typeManagementButtonRef
}: CleanerToolbarProps): React.JSX.Element { }: CleanerToolbarProps): React.JSX.Element {
return ( return (
@@ -123,6 +126,12 @@ export function CleanerToolbar({
> >
<Settings2 size={14} /> <Settings2 size={14} />
</button> </button>
<button
onClick={() => setShowHistoryModal(true)}
className="text-xs bg-violet-50 border border-violet-200 text-violet-700 px-3 py-1.5 rounded shadow-sm hover:bg-violet-100 flex items-center gap-1.5 font-medium transition-colors"
>
<History size={14} />
</button>
<button <button
onClick={() => void handleExportResults()} onClick={() => void handleExportResults()}
disabled={isExporting || filteredResults.length === 0} disabled={isExporting || filteredResults.length === 0}

View File

@@ -12,6 +12,9 @@ const MaterialTypeManagementDialog = React.lazy(
const ExecutionReportDialog = React.lazy(() => import('../components/ExecutionReportDialog')) const ExecutionReportDialog = React.lazy(() => import('../components/ExecutionReportDialog'))
const ReportViewerDialog = React.lazy(() => import('../components/ReportViewerDialog')) const ReportViewerDialog = React.lazy(() => import('../components/ReportViewerDialog'))
const ReportAnalysisDialog = React.lazy(() => import('../components/ReportAnalysisDialog')) const ReportAnalysisDialog = React.lazy(() => import('../components/ReportAnalysisDialog'))
const CleanerOperationHistoryModal = React.lazy(
() => import('../components/CleanerOperationHistoryModal')
)
const CleanerPage: React.FC = () => { const CleanerPage: React.FC = () => {
const typeManagementButtonRef = React.useRef<HTMLButtonElement>(null) const typeManagementButtonRef = React.useRef<HTMLButtonElement>(null)
@@ -68,6 +71,7 @@ const CleanerPage: React.FC = () => {
const [isReportViewerOpen, setIsReportViewerOpen] = React.useState(false) const [isReportViewerOpen, setIsReportViewerOpen] = React.useState(false)
const [isReportAnalysisOpen, setIsReportAnalysisOpen] = React.useState(false) const [isReportAnalysisOpen, setIsReportAnalysisOpen] = React.useState(false)
const [showHistoryModal, setShowHistoryModal] = React.useState(false)
return ( return (
<div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500"> <div className="h-full flex flex-col xl:flex-row gap-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
@@ -97,6 +101,7 @@ const CleanerPage: React.FC = () => {
handleConfirmDeletion={handleConfirmDeletion} handleConfirmDeletion={handleConfirmDeletion}
handleExportResults={handleExportResults} handleExportResults={handleExportResults}
setIsReportViewerOpen={setIsReportViewerOpen} setIsReportViewerOpen={setIsReportViewerOpen}
setShowHistoryModal={setShowHistoryModal}
typeManagementButtonRef={typeManagementButtonRef} typeManagementButtonRef={typeManagementButtonRef}
/> />
@@ -185,6 +190,18 @@ const CleanerPage: React.FC = () => {
/> />
</Suspense> </Suspense>
<Suspense fallback={null}>
<CleanerOperationHistoryModal
isOpen={showHistoryModal}
onClose={() => setShowHistoryModal(false)}
user={
currentUsername
? { username: currentUsername, userType: isAdmin ? 'Admin' : 'User' }
: null
}
/>
</Suspense>
{/* Confirmation Dialog */} {/* Confirmation Dialog */}
{confirmDialog && <ConfirmDialog {...confirmDialog} />} {confirmDialog && <ConfirmDialog {...confirmDialog} />}
</div> </div>