feat: Add real-time IO/interlock updates, HW status display, and history page
- 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>
This commit is contained in:
320
FrontEnd/pages/HistoryPage.tsx
Normal file
320
FrontEnd/pages/HistoryPage.tsx
Normal file
@@ -0,0 +1,320 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -51,7 +51,8 @@ export const IOMonitorPage: React.FC<IOMonitorPageProps> = ({ onToggle }) => {
|
||||
|
||||
// Subscribe to real-time IO updates
|
||||
const unsubscribe = comms.subscribe((msg: any) => {
|
||||
if (msg?.type === 'STATUS_UPDATE' && msg.ioState) {
|
||||
// STATUS_UPDATE - 주기적인 상태 업데이트 (변경된 IO만 포함)
|
||||
if (msg?.type === 'STATUS_UPDATE' && msg.ioState && msg.ioState.length > 0) {
|
||||
setIoPoints(prev => {
|
||||
const newIO = [...prev];
|
||||
msg.ioState.forEach((update: { id: number, type: string, state: boolean }) => {
|
||||
@@ -61,6 +62,39 @@ export const IOMonitorPage: React.FC<IOMonitorPageProps> = ({ onToggle }) => {
|
||||
return newIO;
|
||||
});
|
||||
}
|
||||
|
||||
// IO_CHANGED - 개별 IO 값 변경 이벤트 (실시간)
|
||||
if (msg?.type === 'IO_CHANGED' && msg.data) {
|
||||
const { id, ioType, state } = msg.data;
|
||||
setIoPoints(prev => {
|
||||
const newIO = [...prev];
|
||||
const idx = newIO.findIndex(p => p.id === id && p.type === ioType);
|
||||
if (idx >= 0) {
|
||||
newIO[idx] = { ...newIO[idx], state: state };
|
||||
}
|
||||
return newIO;
|
||||
});
|
||||
}
|
||||
|
||||
// INTERLOCK_CHANGED - 인터락 값 변경 이벤트 (실시간)
|
||||
if (msg?.type === 'INTERLOCK_CHANGED' && msg.data) {
|
||||
const { axisIndex, lockIndex, state, hexValue } = msg.data;
|
||||
setInterlocks(prev => {
|
||||
const newInterlocks = [...prev];
|
||||
const axisIdx = newInterlocks.findIndex(a => a.axisIndex === axisIndex);
|
||||
if (axisIdx >= 0) {
|
||||
const lockIdx = newInterlocks[axisIdx].locks.findIndex(l => l.id === lockIndex);
|
||||
if (lockIdx >= 0) {
|
||||
newInterlocks[axisIdx].locks[lockIdx] = {
|
||||
...newInterlocks[axisIdx].locks[lockIdx],
|
||||
state: state
|
||||
};
|
||||
newInterlocks[axisIdx].hexValue = hexValue;
|
||||
}
|
||||
}
|
||||
return newInterlocks;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -72,6 +106,19 @@ export const IOMonitorPage: React.FC<IOMonitorPageProps> = ({ onToggle }) => {
|
||||
? []
|
||||
: ioPoints.filter(p => p.type === (activeIOTab === 'in' ? 'input' : 'output'));
|
||||
|
||||
// 인터락 토글 핸들러 (DIOMonitor.cs의 gvILXF_ItemClick과 동일)
|
||||
const handleInterlockToggle = async (axisIndex: number, lockIndex: number) => {
|
||||
try {
|
||||
const result = await comms.toggleInterlock(axisIndex, lockIndex);
|
||||
if (!result.success) {
|
||||
console.error('[IOMonitor] Interlock toggle failed:', result.message);
|
||||
}
|
||||
// 성공 시 INTERLOCK_CHANGED 이벤트로 UI가 자동 업데이트됨
|
||||
} catch (error) {
|
||||
console.error('[IOMonitor] Interlock toggle error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="relative w-full h-full px-6 pt-6 pb-20">
|
||||
<div className="glass-holo p-6 h-full flex flex-col gap-4">
|
||||
@@ -130,11 +177,13 @@ export const IOMonitorPage: React.FC<IOMonitorPageProps> = ({ onToggle }) => {
|
||||
{axis.locks.map(lock => (
|
||||
<div
|
||||
key={lock.id}
|
||||
onClick={() => handleInterlockToggle(axis.axisIndex, lock.id)}
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-2 transition-all border rounded
|
||||
flex items-center gap-2 px-3 py-2 transition-all border rounded cursor-pointer
|
||||
hover:translate-x-0.5 hover:brightness-110
|
||||
${lock.state
|
||||
? 'bg-red-500/20 border-red-500 text-red-400 shadow-[0_0_10px_rgba(239,68,68,0.3)]'
|
||||
: 'bg-slate-800/40 border-slate-700 text-slate-500'}
|
||||
: 'bg-slate-800/40 border-slate-700 text-slate-500 hover:border-slate-600'}
|
||||
`}
|
||||
>
|
||||
<div className={`w-2 h-2 rounded-full shrink-0 ${lock.state ? 'bg-red-500 shadow-[0_0_6px_#ef4444]' : 'bg-slate-700'}`}></div>
|
||||
|
||||
Reference in New Issue
Block a user