- Implement real-time IO value updates via IOValueChanged event - Add interlock toggle and real-time interlock change events - Fix ToggleLight to check return value of DIO.SetRoomLight - Add HW status display in Footer matching WinForms HWState - Implement GetHWStatus API and 250ms broadcast interval - Create HistoryPage React component for work history viewing - Add GetHistoryData API for database queries - Add date range selection, search, filter, and CSV export - Add History button in Header navigation - Add PickerMoveDialog component for manage operations - Fix DataSet column names (idx, PRNATTACH, PRNVALID, qtymax) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
321 lines
14 KiB
TypeScript
321 lines
14 KiB
TypeScript
import React, { useState, useEffect } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import { ArrowLeft, Search, Download, ChevronLeft, ChevronRight, Filter, X } from 'lucide-react';
|
|
import { comms } from '../communication';
|
|
|
|
interface HistoryRow {
|
|
idx: number;
|
|
jtype: string;
|
|
rid: string;
|
|
sid: string;
|
|
qty: number;
|
|
vname: string;
|
|
vlot: string;
|
|
loc: string;
|
|
qr: string;
|
|
stime: string;
|
|
etime: string;
|
|
prnattach: boolean;
|
|
prnvalid: boolean;
|
|
rid0: string;
|
|
sid0: string;
|
|
qtymax: number;
|
|
}
|
|
|
|
export const HistoryPage: React.FC = () => {
|
|
const navigate = useNavigate();
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
const [data, setData] = useState<HistoryRow[]>([]);
|
|
const [filteredData, setFilteredData] = useState<HistoryRow[]>([]);
|
|
const [mcName, setMcName] = useState('');
|
|
|
|
// Date range state
|
|
const today = new Date();
|
|
const [startDate, setStartDate] = useState(today.toISOString().split('T')[0]);
|
|
const [endDate, setEndDate] = useState(today.toISOString().split('T')[0]);
|
|
|
|
// Search state
|
|
const [dbSearch, setDbSearch] = useState('');
|
|
const [localFilter, setLocalFilter] = useState('');
|
|
|
|
// Load data on mount
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, []);
|
|
|
|
// Apply local filter when it changes
|
|
useEffect(() => {
|
|
if (!localFilter.trim()) {
|
|
setFilteredData(data);
|
|
} else {
|
|
const keyword = localFilter.toLowerCase();
|
|
setFilteredData(data.filter(row =>
|
|
row.rid0?.toLowerCase().includes(keyword) ||
|
|
row.sid0?.toLowerCase().includes(keyword) ||
|
|
row.qr?.toLowerCase().includes(keyword) ||
|
|
row.vlot?.toLowerCase().includes(keyword) ||
|
|
row.vname?.toLowerCase().includes(keyword) ||
|
|
row.rid?.toLowerCase().includes(keyword) ||
|
|
row.sid?.toLowerCase().includes(keyword)
|
|
));
|
|
}
|
|
}, [localFilter, data]);
|
|
|
|
const fetchData = async () => {
|
|
setIsLoading(true);
|
|
try {
|
|
const result = await comms.getHistoryData(startDate, endDate, dbSearch);
|
|
if (result.success) {
|
|
setData(result.data || []);
|
|
setFilteredData(result.data || []);
|
|
if (result.mcName) {
|
|
setMcName(result.mcName);
|
|
}
|
|
} else {
|
|
console.error('Failed to fetch history:', result.message);
|
|
setData([]);
|
|
setFilteredData([]);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching history:', error);
|
|
setData([]);
|
|
setFilteredData([]);
|
|
}
|
|
setIsLoading(false);
|
|
};
|
|
|
|
const handleSearch = () => {
|
|
// Validate dates
|
|
if (new Date(endDate) < new Date(startDate)) {
|
|
alert('End date is earlier than start date');
|
|
return;
|
|
}
|
|
fetchData();
|
|
};
|
|
|
|
const handlePrevDay = () => {
|
|
const sd = new Date(startDate);
|
|
sd.setDate(sd.getDate() - 1);
|
|
const newDate = sd.toISOString().split('T')[0];
|
|
setStartDate(newDate);
|
|
setEndDate(newDate);
|
|
};
|
|
|
|
const handleNextDay = () => {
|
|
const ed = new Date(endDate);
|
|
ed.setDate(ed.getDate() + 1);
|
|
const newDate = ed.toISOString().split('T')[0];
|
|
setStartDate(newDate);
|
|
setEndDate(newDate);
|
|
};
|
|
|
|
const handleExport = () => {
|
|
if (filteredData.length === 0) {
|
|
alert('No data to export');
|
|
return;
|
|
}
|
|
|
|
// Create CSV content
|
|
const headers = ['IDX', 'JTYPE', 'RID', 'SID', 'QTY', 'QTY_MAX', 'VENDOR', 'V.LOT', 'LOC', 'QR', 'START', 'END', 'ATTACH', 'VALID', 'RID0', 'SID0'];
|
|
const csvContent = [
|
|
headers.join(','),
|
|
...filteredData.map(row => [
|
|
row.idx,
|
|
`"${row.jtype || ''}"`,
|
|
`"${row.rid || ''}"`,
|
|
`"${row.sid || ''}"`,
|
|
row.qty || 0,
|
|
row.qtymax || 0,
|
|
`"${row.vname || ''}"`,
|
|
`"${row.vlot || ''}"`,
|
|
`"${row.loc || ''}"`,
|
|
`"${row.qr || ''}"`,
|
|
`"${row.stime || ''}"`,
|
|
`"${row.etime || ''}"`,
|
|
row.prnattach ? 'Y' : 'N',
|
|
row.prnvalid ? 'Y' : 'N',
|
|
`"${row.rid0 || ''}"`,
|
|
`"${row.sid0 || ''}"`,
|
|
].join(','))
|
|
].join('\n');
|
|
|
|
// Download file
|
|
const blob = new Blob(['\uFEFF' + csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
link.href = URL.createObjectURL(blob);
|
|
link.download = `export_${startDate.replace(/-/g, '')}~${endDate.replace(/-/g, '')}.csv`;
|
|
link.click();
|
|
};
|
|
|
|
return (
|
|
<div className="min-h-screen bg-gray-900 text-white p-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="flex items-center gap-4">
|
|
<button
|
|
onClick={() => navigate('/')}
|
|
className="p-2 rounded-lg bg-gray-800 hover:bg-gray-700 transition-colors"
|
|
>
|
|
<ArrowLeft className="w-5 h-5" />
|
|
</button>
|
|
<h1 className="text-2xl font-tech font-bold text-neon-blue">
|
|
WORK HISTORY {mcName && <span className="text-gray-400 text-lg">({mcName})</span>}
|
|
</h1>
|
|
</div>
|
|
<div className="text-sm text-gray-400">
|
|
Total: <span className="text-white font-bold">{filteredData.length}</span> records
|
|
{localFilter && <span className="text-yellow-400 ml-2">(filtered from {data.length})</span>}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Controls */}
|
|
<div className="flex flex-wrap gap-4 mb-4 p-4 bg-gray-800/50 rounded-xl border border-gray-700">
|
|
{/* Date Range */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={handlePrevDay}
|
|
className="p-2 rounded-lg bg-gray-700 hover:bg-gray-600 transition-colors"
|
|
title="Previous Day"
|
|
>
|
|
<ChevronLeft className="w-4 h-4" />
|
|
</button>
|
|
<input
|
|
type="date"
|
|
value={startDate}
|
|
onChange={(e) => setStartDate(e.target.value)}
|
|
className="px-3 py-2 rounded-lg bg-gray-700 border border-gray-600 text-white text-sm focus:outline-none focus:border-neon-blue"
|
|
/>
|
|
<span className="text-gray-500">~</span>
|
|
<input
|
|
type="date"
|
|
value={endDate}
|
|
onChange={(e) => setEndDate(e.target.value)}
|
|
className="px-3 py-2 rounded-lg bg-gray-700 border border-gray-600 text-white text-sm focus:outline-none focus:border-neon-blue"
|
|
/>
|
|
<button
|
|
onClick={handleNextDay}
|
|
className="p-2 rounded-lg bg-gray-700 hover:bg-gray-600 transition-colors"
|
|
title="Next Day"
|
|
>
|
|
<ChevronRight className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* DB Search */}
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="text"
|
|
value={dbSearch}
|
|
onChange={(e) => setDbSearch(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
placeholder="Search in DB..."
|
|
className="px-3 py-2 rounded-lg bg-gray-700 border border-gray-600 text-white text-sm focus:outline-none focus:border-neon-blue w-48"
|
|
/>
|
|
<button
|
|
onClick={handleSearch}
|
|
disabled={isLoading}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-neon-blue/20 text-neon-blue border border-neon-blue hover:bg-neon-blue/30 transition-colors disabled:opacity-50"
|
|
>
|
|
<Search className="w-4 h-4" />
|
|
<span>Search</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Local Filter */}
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<Filter className="w-4 h-4 text-gray-500" />
|
|
<input
|
|
type="text"
|
|
value={localFilter}
|
|
onChange={(e) => setLocalFilter(e.target.value)}
|
|
placeholder="Filter results..."
|
|
className={`px-3 py-2 rounded-lg border text-white text-sm focus:outline-none focus:border-neon-blue w-48 ${localFilter ? 'bg-lime-900/30 border-lime-500' : 'bg-gray-700 border-gray-600'
|
|
}`}
|
|
/>
|
|
{localFilter && (
|
|
<button
|
|
onClick={() => setLocalFilter('')}
|
|
className="p-1 rounded hover:bg-gray-700"
|
|
>
|
|
<X className="w-4 h-4 text-gray-400" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Export */}
|
|
<button
|
|
onClick={handleExport}
|
|
disabled={filteredData.length === 0}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600/20 text-green-400 border border-green-600 hover:bg-green-600/30 transition-colors disabled:opacity-50"
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
<span>Export CSV</span>
|
|
</button>
|
|
</div>
|
|
|
|
{/* Data Table */}
|
|
<div className="bg-gray-800/50 rounded-xl border border-gray-700 overflow-hidden">
|
|
<div className="overflow-x-auto max-h-[calc(100vh-280px)]">
|
|
<table className="w-full text-sm">
|
|
<thead className="sticky top-0 bg-gray-800 border-b border-gray-700">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">IDX</th>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">TYPE</th>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">RID</th>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">SID</th>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">QTY</th>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">VENDOR</th>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">V.LOT</th>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">LOC</th>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">START</th>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">END</th>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">ATTACH</th>
|
|
<th className="px-3 py-2 text-left text-gray-400 font-medium">VALID</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{isLoading ? (
|
|
<tr>
|
|
<td colSpan={12} className="px-3 py-8 text-center text-gray-500">
|
|
Loading...
|
|
</td>
|
|
</tr>
|
|
) : filteredData.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={12} className="px-3 py-8 text-center text-gray-500">
|
|
No data found
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
filteredData.map((row, index) => (
|
|
<tr
|
|
key={row.idx || index}
|
|
className="border-b border-gray-700/50 hover:bg-gray-700/30 transition-colors"
|
|
>
|
|
<td className="px-3 py-2 text-gray-500">{row.idx}</td>
|
|
<td className="px-3 py-2 text-xs">{row.jtype}</td>
|
|
<td className="px-3 py-2 font-mono text-xs">{row.rid}</td>
|
|
<td className="px-3 py-2 font-mono text-xs">{row.sid}</td>
|
|
<td className="px-3 py-2 text-right">{row.qty}/{row.qtymax}</td>
|
|
<td className="px-3 py-2">{row.vname}</td>
|
|
<td className="px-3 py-2 font-mono text-xs">{row.vlot}</td>
|
|
<td className="px-3 py-2 text-center">{row.loc}</td>
|
|
<td className="px-3 py-2 text-xs text-gray-400">{row.stime}</td>
|
|
<td className="px-3 py-2 text-xs text-gray-400">{row.etime}</td>
|
|
<td className={`px-3 py-2 text-center font-bold ${row.prnattach ? 'text-green-400' : 'text-red-400'}`}>
|
|
{row.prnattach ? 'Y' : 'N'}
|
|
</td>
|
|
<td className={`px-3 py-2 text-center font-bold ${row.prnvalid ? 'text-green-400' : 'text-red-400'}`}>
|
|
{row.prnvalid ? 'Y' : 'N'}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|