Files
WebUITest-RealProjecT/FrontEnd/App.tsx
arDTDev 3bd35ad852 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>
2025-11-27 00:18:27 +09:00

340 lines
12 KiB
TypeScript

import React, { useState, useEffect, useRef, createContext, useContext } from 'react';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { Layout } from './components/layout/Layout';
import { HomePage } from './pages/HomePage';
import { IOMonitorPage } from './pages/IOMonitorPage';
import { RecipePage } from './pages/RecipePage';
import { HistoryPage } from './pages/HistoryPage';
import { SystemState, Recipe, IOPoint, LogEntry, RobotTarget, ConfigItem } from './types';
import { comms } from './communication';
import { AlertProvider, useAlert } from './contexts/AlertContext';
import { PickerMoveDialog } from './components/PickerMoveDialog';
// PickerMoveDialog 전역 상태 Context
interface PickerMoveContextType {
isPickerMoveOpen: boolean;
openPickerMove: () => Promise<boolean>;
closePickerMove: () => void;
}
const PickerMoveContext = createContext<PickerMoveContextType | null>(null);
export const usePickerMove = () => {
const context = useContext(PickerMoveContext);
if (!context) {
throw new Error('usePickerMove must be used within PickerMoveProvider');
}
return context;
};
// --- MOCK DATA ---
const INITIAL_IO: IOPoint[] = [
...Array.from({ length: 32 }, (_, i) => {
let name = `DOUT_${i.toString().padStart(2, '0')}`;
if (i === 0) name = "Tower Lamp Red";
if (i === 1) name = "Tower Lamp Yel";
if (i === 2) name = "Tower Lamp Grn";
return { id: i, name, type: 'output' as const, state: false };
}),
...Array.from({ length: 32 }, (_, i) => {
let name = `DIN_${i.toString().padStart(2, '0')}`;
let initialState = false;
if (i === 0) name = "Front Door Sensor";
if (i === 1) name = "Right Door Sensor";
if (i === 2) name = "Left Door Sensor";
if (i === 3) name = "Back Door Sensor";
if (i === 4) { name = "Main Air Pressure"; initialState = true; }
if (i === 5) { name = "Vacuum Generator"; initialState = true; }
if (i === 6) { name = "Emergency Stop Loop"; initialState = true; }
return { id: i, name, type: 'input' as const, state: initialState };
})
];
// --- MAIN APP ---
// 내부 App 컴포넌트 (AlertContext 내부에서 사용)
function AppContent() {
const [activeTab, setActiveTab] = useState<'recipe' | 'motion' | 'camera' | 'setting' | 'initialize' | null>(null);
const [systemState, setSystemState] = useState<SystemState>(SystemState.IDLE);
const [currentRecipe, setCurrentRecipe] = useState<Recipe>({ id: '0', name: 'No Recipe', lastModified: '-' });
const [robotTarget, setRobotTarget] = useState<RobotTarget>({ x: 0, y: 0, z: 0 });
const [logs, setLogs] = useState<LogEntry[]>([]);
const [ioPoints, setIoPoints] = useState<IOPoint[]>([]);
const [currentTime, setCurrentTime] = useState(new Date());
const [isLoading, setIsLoading] = useState(true);
const [isHostConnected, setIsHostConnected] = useState(false);
const [isPickerMoveOpen, setIsPickerMoveOpen] = useState(false);
const videoRef = useRef<HTMLVideoElement>(null);
const { showAlert } = useAlert();
// PickerMoveDialog 열기 함수 (백엔드에서 openManage 호출)
const openPickerMove = async (): Promise<boolean> => {
try {
const result = await comms.openManage();
if (!result.success) {
showAlert({
type: 'error',
title: 'Cannot Open',
message: result.message
});
return false;
}
setIsPickerMoveOpen(true);
return true;
} catch (error: any) {
showAlert({
type: 'error',
title: 'Error',
message: error.message || 'Failed to open manage dialog'
});
return false;
}
};
// PickerMoveDialog 닫기 함수
const closePickerMove = async () => {
setIsPickerMoveOpen(false);
try {
const result = await comms.closeManage();
if (result.shouldAutoInit) {
const initResult = await comms.initializeDevice();
if (!initResult.success) {
console.error('[App] Auto-init failed:', initResult.message);
}
}
} catch (error) {
console.error('[App] closeManage error:', error);
}
};
// -- COMMUNICATION LAYER --
useEffect(() => {
const unsubscribe = comms.subscribe((msg: any) => {
if (!msg) return;
if (msg.type === 'CONNECTION_STATE') {
setIsHostConnected(msg.connected);
addLog(msg.connected ? "HOST CONNECTED" : "HOST DISCONNECTED", msg.connected ? "info" : "warning");
}
// AUTO_OPEN_MANAGE 이벤트 처리 - 백엔드에서 IDLE 상태 진입 시 피커 이동 필요할 때 전송
if (msg.type === 'AUTO_OPEN_MANAGE') {
console.log('[App] AUTO_OPEN_MANAGE event received:', msg.data?.reason);
addLog(`AUTO MANAGE: ${msg.data?.reason || 'Picker move required'}`, 'warning');
// 이미 열려있지 않은 경우에만 열기
if (!isPickerMoveOpen) {
openPickerMove();
}
}
if (msg.type === 'STATUS_UPDATE') {
if (msg.position) {
setRobotTarget({ x: msg.position.x, y: msg.position.y, z: msg.position.z });
}
if (msg.ioState) {
setIoPoints(prev => {
const newIO = [...prev];
msg.ioState.forEach((update: { id: number, type: string, state: boolean }) => {
const idx = newIO.findIndex(p => p.id === update.id && p.type === update.type);
if (idx >= 0) newIO[idx] = { ...newIO[idx], state: update.state };
});
return newIO;
});
}
if (msg.sysState) {
setSystemState(msg.sysState as SystemState);
}
}
});
addLog("COMMUNICATION CHANNEL OPEN", "info");
setIsHostConnected(comms.getConnectionState());
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => {
clearInterval(timer);
unsubscribe();
};
}, [isPickerMoveOpen]);
// -- INITIALIZATION --
useEffect(() => {
const initSystem = async () => {
addLog("SYSTEM STARTED", "info");
// Initial IO data will be loaded by HomePage when it mounts
try {
const ioStr = await comms.getIOList();
const ioData = JSON.parse(ioStr);
// Handle new structured format: { inputs: [...], outputs: [...], interlocks: [...] }
let flatIoList: IOPoint[] = [];
if (ioData.inputs && ioData.outputs) {
// New format - flatten inputs and outputs
flatIoList = [
...ioData.outputs.map((io: any) => ({ id: io.id, name: io.name, type: 'output' as const, state: io.state })),
...ioData.inputs.map((io: any) => ({ id: io.id, name: io.name, type: 'input' as const, state: io.state }))
];
} else if (Array.isArray(ioData)) {
// Old format - already flat array
flatIoList = ioData;
}
setIoPoints(flatIoList);
addLog("IO LIST LOADED", "info");
} catch (e) {
addLog("FAILED TO LOAD IO DATA", "error");
}
setIsLoading(false);
};
initSystem();
}, []);
const addLog = (msg: string, type: 'info' | 'warning' | 'error' = 'info') => {
setLogs(prev => [{ id: Date.now() + Math.random(), timestamp: new Date().toLocaleTimeString(), message: msg, type }, ...prev].slice(0, 50));
};
// Logic Helpers
const doorStates = {
front: ioPoints.find(p => p.id === 0 && p.type === 'input')?.state || false,
right: ioPoints.find(p => p.id === 1 && p.type === 'input')?.state || false,
left: ioPoints.find(p => p.id === 2 && p.type === 'input')?.state || false,
back: ioPoints.find(p => p.id === 3 && p.type === 'input')?.state || false,
};
const isLowPressure = !(ioPoints.find(p => p.id === 4 && p.type === 'input')?.state ?? true);
const isEmergencyStop = !(ioPoints.find(p => p.id === 6 && p.type === 'input')?.state ?? true);
// -- COMMAND HANDLERS --
const handleControl = async (action: 'start' | 'stop' | 'reset') => {
if (isEmergencyStop && action === 'start') return addLog('EMERGENCY STOP ACTIVE', 'error');
try {
await comms.sendControl(action.toUpperCase());
addLog(`CMD SENT: ${action.toUpperCase()}`, 'info');
} catch (e) {
addLog('COMM ERROR', 'error');
}
};
const toggleIO = async (id: number, type: 'input' | 'output', forceState?: boolean) => {
if (type === 'output') {
const current = ioPoints.find(p => p.id === id && p.type === type)?.state;
const nextState = forceState !== undefined ? forceState : !current;
await comms.setIO(id, nextState);
}
};
const moveAxis = async (axis: 'X' | 'Y' | 'Z', value: number) => {
if (isEmergencyStop) return;
await comms.moveAxis(axis, value);
addLog(`CMD MOVE ${axis}: ${value}`, 'info');
};
const handleSaveConfig = async (newConfig: ConfigItem[]) => {
try {
await comms.saveConfig(JSON.stringify(newConfig));
addLog("CONFIGURATION SAVED", "info");
} catch (e) {
console.error(e);
addLog("FAILED TO SAVE CONFIG", "error");
}
};
const handleSelectRecipe = async (r: Recipe) => {
try {
addLog(`LOADING: ${r.name}`, 'info');
const result = await comms.selectRecipe(r.id);
if (result.success) {
setCurrentRecipe(r);
addLog(`RECIPE LOADED: ${r.name}`, 'info');
} else {
addLog(`RECIPE LOAD FAILED: ${result.message}`, 'error');
}
} catch (error: any) {
addLog(`RECIPE LOAD ERROR: ${error.message || 'Unknown error'}`, 'error');
console.error('Recipe selection error:', error);
}
};
const pickerMoveContextValue: PickerMoveContextType = {
isPickerMoveOpen,
openPickerMove,
closePickerMove
};
return (
<PickerMoveContext.Provider value={pickerMoveContextValue}>
<HashRouter>
<Layout
currentTime={currentTime}
isHostConnected={isHostConnected}
robotTarget={robotTarget}
onTabChange={setActiveTab}
activeTab={activeTab}
isLoading={isLoading}
>
<Routes>
<Route
path="/"
element={
<HomePage
systemState={systemState}
currentRecipe={currentRecipe}
robotTarget={robotTarget}
logs={logs}
ioPoints={ioPoints}
doorStates={doorStates}
isLowPressure={isLowPressure}
isEmergencyStop={isEmergencyStop}
isHostConnected={isHostConnected}
activeTab={activeTab}
onSelectRecipe={handleSelectRecipe}
onMove={moveAxis}
onControl={handleControl}
onSaveConfig={handleSaveConfig}
onCloseTab={() => setActiveTab(null)}
videoRef={videoRef}
/>
}
/>
<Route
path="/io-monitor"
element={
<IOMonitorPage
onToggle={toggleIO}
/>
}
/>
<Route
path="/recipe"
element={<RecipePage />}
/>
<Route
path="/history"
element={<HistoryPage />}
/>
</Routes>
</Layout>
</HashRouter>
{/* PickerMoveDialog - 전역에서 관리 */}
<PickerMoveDialog
isOpen={isPickerMoveOpen}
onClose={closePickerMove}
/>
</PickerMoveContext.Provider>
);
}
// 외부 App 컴포넌트 - AlertProvider로 감싸기
export default function App() {
return (
<AlertProvider>
<AppContent />
</AlertProvider>
);
}