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:
2025-11-27 00:14:47 +09:00
parent bb67d04d90
commit 3bd35ad852
19 changed files with 2917 additions and 81 deletions

View 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>
);
};

View File

@@ -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>