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

@@ -1,12 +1,31 @@
import React, { useState, useEffect, useRef } from 'react';
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 } from './contexts/AlertContext';
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 ---
@@ -35,7 +54,8 @@ const INITIAL_IO: IOPoint[] = [
// --- MAIN APP ---
export default function 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: '-' });
@@ -45,7 +65,49 @@ export default function App() {
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(() => {
@@ -57,6 +119,16 @@ export default function App() {
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 });
@@ -85,7 +157,7 @@ export default function App() {
clearInterval(timer);
unsubscribe();
};
}, []);
}, [isPickerMoveOpen]);
// -- INITIALIZATION --
useEffect(() => {
@@ -187,8 +259,14 @@ export default function App() {
}
};
const pickerMoveContextValue: PickerMoveContextType = {
isPickerMoveOpen,
openPickerMove,
closePickerMove
};
return (
<AlertProvider>
<PickerMoveContext.Provider value={pickerMoveContextValue}>
<HashRouter>
<Layout
currentTime={currentTime}
@@ -234,9 +312,28 @@ export default function App() {
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>
);
}

View File

@@ -434,10 +434,10 @@ class CommunicationLayer {
// ===== VISION CONTROL METHODS =====
private async sendVisionCommand(command: string, responseType: string): Promise<{ success: boolean; message: string }> {
private async sendVisionCommand(command: string, responseType: string, methodName: string): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
// WebView2 mode - direct call to C# methods
return { success: false, message: 'Vision commands not yet implemented in WebView2 mode' };
const result = await (machine as any)[methodName]();
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
if (!this.isConnected) {
@@ -465,45 +465,46 @@ class CommunicationLayer {
}
public async cameraConnect(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('CAMERA_CONNECT', 'CAMERA_RESULT');
return this.sendVisionCommand('CAMERA_CONNECT', 'CAMERA_RESULT', 'CameraConnect');
}
public async cameraDisconnect(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('CAMERA_DISCONNECT', 'CAMERA_RESULT');
return this.sendVisionCommand('CAMERA_DISCONNECT', 'CAMERA_RESULT', 'CameraDisconnect');
}
public async cameraGetImage(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('CAMERA_GET_IMAGE', 'CAMERA_RESULT');
return this.sendVisionCommand('CAMERA_GET_IMAGE', 'CAMERA_RESULT', 'CameraGetImage');
}
public async cameraLiveView(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('CAMERA_LIVE_VIEW', 'CAMERA_RESULT');
return this.sendVisionCommand('CAMERA_LIVE_VIEW', 'CAMERA_RESULT', 'CameraLiveView');
}
public async cameraReadTest(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('CAMERA_READ_TEST', 'CAMERA_RESULT');
return this.sendVisionCommand('CAMERA_READ_TEST', 'CAMERA_RESULT', 'CameraReadTest');
}
public async keyenceTriggerOn(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('KEYENCE_TRIGGER_ON', 'KEYENCE_RESULT');
return this.sendVisionCommand('KEYENCE_TRIGGER_ON', 'KEYENCE_RESULT', 'KeyenceTriggerOn');
}
public async keyenceTriggerOff(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('KEYENCE_TRIGGER_OFF', 'KEYENCE_RESULT');
return this.sendVisionCommand('KEYENCE_TRIGGER_OFF', 'KEYENCE_RESULT', 'KeyenceTriggerOff');
}
public async keyenceGetImage(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('KEYENCE_GET_IMAGE', 'KEYENCE_RESULT');
return this.sendVisionCommand('KEYENCE_GET_IMAGE', 'KEYENCE_RESULT', 'KeyenceGetImage');
}
public async keyenceSaveImage(): Promise<{ success: boolean; message: string }> {
return this.sendVisionCommand('KEYENCE_SAVE_IMAGE', 'KEYENCE_RESULT');
return this.sendVisionCommand('KEYENCE_SAVE_IMAGE', 'KEYENCE_RESULT', 'KeyenceSaveImage');
}
// Light, Manual Print, Cancel Job commands
public async toggleLight(): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Light control not yet implemented in WebView2 mode' };
const result = await machine.ToggleLight();
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
@@ -537,7 +538,18 @@ class CommunicationLayer {
count: number;
}): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Manual print not yet implemented in WebView2 mode' };
const result = await machine.ExecuteManualPrint(
printData.sid,
printData.venderLot,
printData.qty,
printData.mfg,
printData.rid,
printData.spy,
printData.partNo,
printData.printer,
printData.count
);
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
@@ -564,7 +576,8 @@ class CommunicationLayer {
public async cancelJob(): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Cancel job not yet implemented in WebView2 mode' };
const result = await machine.CancelJob();
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
@@ -588,7 +601,8 @@ class CommunicationLayer {
public async openManage(): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Manage not yet implemented in WebView2 mode' };
const result = await machine.OpenManage();
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
@@ -612,7 +626,8 @@ class CommunicationLayer {
public async openManual(): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Manual not yet implemented in WebView2 mode' };
const result = await machine.OpenManual();
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
@@ -636,7 +651,8 @@ class CommunicationLayer {
public async openLogViewer(): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Log viewer not yet implemented in WebView2 mode' };
const result = await machine.OpenLogViewer();
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
@@ -658,9 +674,10 @@ class CommunicationLayer {
}
}
private async openFolder(command: string): Promise<{ success: boolean; message: string }> {
private async openFolder(command: string, methodName: string): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
return { success: false, message: 'Folder open not yet implemented in WebView2 mode' };
const result = await (machine as any)[methodName]();
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
@@ -683,19 +700,323 @@ class CommunicationLayer {
}
public async openProgramFolder(): Promise<{ success: boolean; message: string }> {
return this.openFolder('OPEN_PROGRAM_FOLDER');
return this.openFolder('OPEN_PROGRAM_FOLDER', 'OpenProgramFolder');
}
public async openLogFolder(): Promise<{ success: boolean; message: string }> {
return this.openFolder('OPEN_LOG_FOLDER');
return this.openFolder('OPEN_LOG_FOLDER', 'OpenLogFolder');
}
public async openScreenshotFolder(): Promise<{ success: boolean; message: string }> {
return this.openFolder('OPEN_SCREENSHOT_FOLDER');
return this.openFolder('OPEN_SCREENSHOT_FOLDER', 'OpenScreenshotFolder');
}
public async openSavedDataFolder(): Promise<{ success: boolean; message: string }> {
return this.openFolder('OPEN_SAVED_DATA_FOLDER');
return this.openFolder('OPEN_SAVED_DATA_FOLDER', 'OpenSavedDataFolder');
}
// ===== PICKER MOVE METHODS =====
public async getPickerStatus(): Promise<void> {
if (isWebView && machine) {
const result = await machine.GetPickerStatus();
this.notifyListeners({ type: 'PICKER_STATUS', data: JSON.parse(result) });
} else {
this.ws?.send(JSON.stringify({ type: 'GET_PICKER_STATUS' }));
}
}
private async sendPickerCommand(command: string, responseType: string, methodName: string): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
const result = await (machine as any)[methodName]();
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
if (!this.isConnected) {
setTimeout(() => {
if (!this.isConnected) reject({ success: false, message: "WebSocket connection timeout" });
}, 2000);
}
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject({ success: false, message: "Picker command timeout" });
}, 10000);
const handler = (data: any) => {
if (data.type === responseType) {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: command }));
});
}
}
public async pickerMoveLeft(): Promise<{ success: boolean; message: string }> {
return this.sendPickerCommand('PICKER_MOVE_LEFT', 'PICKER_RESULT', 'PickerMoveLeft');
}
public async pickerMoveLeftWait(): Promise<{ success: boolean; message: string }> {
return this.sendPickerCommand('PICKER_MOVE_LEFT_WAIT', 'PICKER_RESULT', 'PickerMoveLeftWait');
}
public async pickerMoveCenter(): Promise<{ success: boolean; message: string }> {
return this.sendPickerCommand('PICKER_MOVE_CENTER', 'PICKER_RESULT', 'PickerMoveCenter');
}
public async pickerMoveRightWait(): Promise<{ success: boolean; message: string }> {
return this.sendPickerCommand('PICKER_MOVE_RIGHT_WAIT', 'PICKER_RESULT', 'PickerMoveRightWait');
}
public async pickerMoveRight(): Promise<{ success: boolean; message: string }> {
return this.sendPickerCommand('PICKER_MOVE_RIGHT', 'PICKER_RESULT', 'PickerMoveRight');
}
public async pickerJogStart(direction: 'up' | 'down' | 'left' | 'right'): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
const result = await machine.PickerJogStart(direction);
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject({ success: false, message: "Jog command timeout" });
}, 5000);
const handler = (data: any) => {
if (data.type === 'PICKER_JOG_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'PICKER_JOG_START', direction }));
});
}
}
public async pickerJogStop(): Promise<{ success: boolean; message: string }> {
return this.sendPickerCommand('PICKER_JOG_STOP', 'PICKER_JOG_RESULT', 'PickerJogStop');
}
public async pickerStop(): Promise<{ success: boolean; message: string }> {
return this.sendPickerCommand('PICKER_STOP', 'PICKER_RESULT', 'PickerStop');
}
public async cancelVisionValidation(side: 'left' | 'right'): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
const result = await machine.CancelVisionValidation(side);
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject({ success: false, message: "Vision cancel timeout" });
}, 5000);
const handler = (data: any) => {
if (data.type === 'VISION_CANCEL_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'CANCEL_VISION_VALIDATION', side }));
});
}
}
public async pickerManagePosition(side: 'left' | 'right'): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
const result = await machine.PickerManagePosition(side);
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject({ success: false, message: "Manage position timeout" });
}, 30000); // Longer timeout for motion
const handler = (data: any) => {
if (data.type === 'PICKER_MANAGE_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'PICKER_MANAGE_POSITION', side }));
});
}
}
public async pickerManageReturn(): Promise<{ success: boolean; message: string }> {
return this.sendPickerCommand('PICKER_MANAGE_RETURN', 'PICKER_MANAGE_RESULT', 'PickerManageReturn');
}
public async pickerZHome(): Promise<{ success: boolean; message: string }> {
return this.sendPickerCommand('PICKER_Z_HOME', 'PICKER_RESULT', 'PickerZHome');
}
public async pickerZZero(): Promise<{ success: boolean; message: string }> {
return this.sendPickerCommand('PICKER_Z_ZERO', 'PICKER_RESULT', 'PickerZZero');
}
public async pickerTestPrint(side: 'left' | 'right'): Promise<{ success: boolean; message: string }> {
if (isWebView && machine) {
const result = await machine.PickerTestPrint(side);
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject({ success: false, message: "Test print timeout" });
}, 10000);
const handler = (data: any) => {
if (data.type === 'PICKER_PRINT_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'PICKER_TEST_PRINT', side }));
});
}
}
public async canCloseManage(): Promise<{ canClose: boolean; message: string }> {
if (isWebView && machine) {
const result = await machine.CanCloseManage();
return JSON.parse(result);
} else {
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve({ canClose: true, message: '' });
}, 5000);
const handler = (data: any) => {
if (data.type === 'CAN_CLOSE_MANAGE_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'CAN_CLOSE_MANAGE' }));
});
}
}
// Close manage dialog - backend handles flag clear and auto-init check
public async closeManage(): Promise<{ shouldAutoInit: boolean }> {
if (isWebView && machine) {
const result = await machine.CloseManage();
return JSON.parse(result);
} else {
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve({ shouldAutoInit: false });
}, 5000);
const handler = (data: any) => {
if (data.type === 'CLOSE_MANAGE_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'CLOSE_MANAGE' }));
});
}
}
// ===== HISTORY DATA =====
// Get history data from database (fHistory.cs의 refreshList와 동일)
public async getHistoryData(startDate: string, endDate: string, search: string): Promise<{ success: boolean; data: any[]; mcName?: string; message?: string }> {
if (isWebView && machine) {
const result = await machine.GetHistoryData(startDate, endDate, search);
return JSON.parse(result);
} else {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject({ success: false, message: 'History data fetch timeout', data: [] });
}, 30000); // 30 second timeout for potentially large data
const handler = (data: any) => {
if (data.type === 'HISTORY_DATA_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'GET_HISTORY_DATA', startDate, endDate, search }));
});
}
}
// ===== INTERLOCK METHODS =====
// Toggle interlock (DIOMonitor.cs의 gvILXF_ItemClick과 동일)
public async toggleInterlock(axisIndex: number, lockIndex: number): Promise<{ success: boolean; newState?: boolean; message?: string }> {
if (isWebView && machine) {
const result = await machine.ToggleInterlock(axisIndex, lockIndex);
return JSON.parse(result);
} else {
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve({ success: false, message: 'Timeout' });
}, 5000);
const handler = (data: any) => {
if (data.type === 'TOGGLE_INTERLOCK_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(data.data);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'TOGGLE_INTERLOCK', axisIndex, lockIndex }));
});
}
}
// Get interlock list
public async getInterlockList(): Promise<string> {
if (isWebView && machine) {
return await machine.GetInterlockList();
} else {
return new Promise((resolve) => {
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve('[]');
}, 5000);
const handler = (data: any) => {
if (data.type === 'INTERLOCK_LIST_RESULT') {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(JSON.stringify(data.data));
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ type: 'GET_INTERLOCK_LIST' }));
});
}
}
}

View File

@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect } from 'react';
import { Package, X, ChevronRight } from 'lucide-react';
import { comms } from '../communication';
import { useAlert } from '../contexts/AlertContext';
import { usePickerMove } from '../App';
interface FunctionMenuProps {
isOpen: boolean;
@@ -12,6 +13,7 @@ export const FunctionMenu: React.FC<FunctionMenuProps> = ({ isOpen, onClose }) =
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const { showAlert } = useAlert();
const { openPickerMove } = usePickerMove();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -29,8 +31,6 @@ export const FunctionMenu: React.FC<FunctionMenuProps> = ({ isOpen, onClose }) =
};
}, [isOpen, onClose]);
if (!isOpen) return null;
const handleCommand = async (commandFn: () => Promise<{ success: boolean; message: string }>, actionName: string) => {
try {
const result = await commandFn();
@@ -55,6 +55,8 @@ export const FunctionMenu: React.FC<FunctionMenuProps> = ({ isOpen, onClose }) =
};
return (
<>
{isOpen && (
<div
ref={menuRef}
className="absolute top-20 left-1/2 -translate-x-1/2 z-50 bg-black/95 backdrop-blur-md border border-neon-blue/50 rounded-lg shadow-glow-blue min-w-[300px]"
@@ -77,7 +79,12 @@ export const FunctionMenu: React.FC<FunctionMenuProps> = ({ isOpen, onClose }) =
<div className="p-2">
{/* Manage */}
<button
onClick={() => handleCommand(() => comms.openManage(), 'Manage')}
onClick={async () => {
const success = await openPickerMove();
if (success) {
onClose();
}
}}
className="w-full flex items-center justify-between px-4 py-3 text-left text-slate-300 hover:bg-neon-blue/10 hover:text-neon-blue rounded transition-colors font-tech"
>
<span>Manage</span>
@@ -136,5 +143,7 @@ export const FunctionMenu: React.FC<FunctionMenuProps> = ({ isOpen, onClose }) =
</div>
</div>
</div>
)}
</>
);
};

View File

@@ -28,7 +28,7 @@ export const InitializeModal: React.FC<InitializeModalProps> = ({ isOpen, onClos
]);
const [isInitializing, setIsInitializing] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [statusInterval, setStatusInterval] = useState<NodeJS.Timeout | null>(null);
const [statusInterval, setStatusInterval] = useState<ReturnType<typeof setInterval> | null>(null);
// Reset state when modal closes
useEffect(() => {

View File

@@ -0,0 +1,446 @@
import React, { useState, useEffect, useCallback } from 'react';
import { X, Move, Square, ChevronLeft, ChevronRight, ChevronUp, ChevronDown, Home, Printer, XCircle, Settings } from 'lucide-react';
import { comms } from '../communication';
import { useAlert } from '../contexts/AlertContext';
interface PickerMoveDialogProps {
isOpen: boolean;
onClose: () => void;
}
interface PickerStatus {
xEnabled: boolean;
zEnabled: boolean;
pickerSafe: boolean;
managementEnabled: boolean;
manPosL: boolean;
manPosR: boolean;
}
export const PickerMoveDialog: React.FC<PickerMoveDialogProps> = ({ isOpen, onClose }) => {
const { showAlert, showConfirm } = useAlert();
const [status, setStatus] = useState<PickerStatus>({
xEnabled: false,
zEnabled: false,
pickerSafe: false,
managementEnabled: false,
manPosL: false,
manPosR: false
});
const [isJogging, setIsJogging] = useState(false);
// Subscribe to picker status updates
useEffect(() => {
if (!isOpen) return;
const unsubscribe = comms.subscribe((data: any) => {
if (data.type === 'PICKER_STATUS') {
setStatus(data.data);
}
});
// Request initial status
comms.getPickerStatus();
// Set up polling interval
const intervalId = setInterval(() => {
comms.getPickerStatus();
}, 200);
return () => {
unsubscribe();
clearInterval(intervalId);
};
}, [isOpen]);
const handleClose = async () => {
try {
const result = await comms.canCloseManage();
if (!result.canClose) {
showAlert({
type: 'error',
title: 'Cannot Close',
message: result.message || 'Printer motion is in management position.\nReturn to position and try again.'
});
return;
}
onClose();
} catch (error) {
// If check fails, still allow close
onClose();
}
};
// === Position Move Buttons ===
const handleMoveLeft = async () => {
const result = await comms.pickerMoveLeft();
if (!result.success) {
showAlert({ type: 'error', title: 'Move Failed', message: result.message });
}
};
const handleMoveLeftWait = async () => {
const result = await comms.pickerMoveLeftWait();
if (!result.success) {
showAlert({ type: 'error', title: 'Move Failed', message: result.message });
}
};
const handleMoveCenter = async () => {
const result = await comms.pickerMoveCenter();
if (!result.success) {
showAlert({ type: 'error', title: 'Move Failed', message: result.message });
}
};
const handleMoveRightWait = async () => {
const result = await comms.pickerMoveRightWait();
if (!result.success) {
showAlert({ type: 'error', title: 'Move Failed', message: result.message });
}
};
const handleMoveRight = async () => {
const result = await comms.pickerMoveRight();
if (!result.success) {
showAlert({ type: 'error', title: 'Move Failed', message: result.message });
}
};
// === Jog Control ===
const handleJogStart = useCallback(async (direction: 'up' | 'down' | 'left' | 'right') => {
setIsJogging(true);
await comms.pickerJogStart(direction);
}, []);
const handleJogStop = useCallback(async () => {
setIsJogging(false);
await comms.pickerJogStop();
}, []);
const handleStop = async () => {
await comms.pickerStop();
};
// === Vision Validation Cancel ===
const handleCancelVisionL = async () => {
const confirmed = await showConfirm({
title: 'Cancel Vision Validation',
message: 'Do you want to cancel LEFT-QR code verification?'
});
if (confirmed) {
const result = await comms.cancelVisionValidation('left');
if (result.success) {
showAlert({ type: 'success', title: 'Cancelled', message: 'LEFT-QR verification cancelled' });
} else {
showAlert({ type: 'error', title: 'Cancel Failed', message: result.message });
}
}
};
const handleCancelVisionR = async () => {
const confirmed = await showConfirm({
title: 'Cancel Vision Validation',
message: 'Do you want to cancel RIGHT-QR code verification?'
});
if (confirmed) {
const result = await comms.cancelVisionValidation('right');
if (result.success) {
showAlert({ type: 'success', title: 'Cancelled', message: 'RIGHT-QR verification cancelled' });
} else {
showAlert({ type: 'error', title: 'Cancel Failed', message: result.message });
}
}
};
// === Management Position ===
const handleManagePosL = async () => {
const result = await comms.pickerManagePosition('left');
if (!result.success) {
showAlert({ type: 'error', title: 'Move Failed', message: result.message });
}
};
const handleManagePosR = async () => {
const result = await comms.pickerManagePosition('right');
if (!result.success) {
showAlert({ type: 'error', title: 'Move Failed', message: result.message });
}
};
const handleManagePosReturn = async () => {
const result = await comms.pickerManageReturn();
if (!result.success) {
showAlert({ type: 'error', title: 'Return Failed', message: result.message });
}
};
// === Z-Axis Control ===
const handleZHome = async () => {
const confirmed = await showConfirm({
title: 'Z-Axis Home',
message: 'Do you want to proceed with picker Z-axis home search?'
});
if (confirmed) {
const result = await comms.pickerZHome();
if (!result.success) {
showAlert({ type: 'error', title: 'Home Failed', message: result.message });
}
}
};
const handleZZero = async () => {
const confirmed = await showConfirm({
title: 'Z-Axis Zero',
message: 'Do you want to move picker Z-axis to coordinate:0?'
});
if (confirmed) {
const result = await comms.pickerZZero();
if (!result.success) {
showAlert({ type: 'error', title: 'Move Failed', message: result.message });
}
}
};
// === Print Test ===
const handlePrintL = async () => {
const result = await comms.pickerTestPrint('left');
if (!result.success) {
showAlert({ type: 'error', title: 'Print Failed', message: result.message });
}
};
const handlePrintR = async () => {
const result = await comms.pickerTestPrint('right');
if (!result.success) {
showAlert({ type: 'error', title: 'Print Failed', message: result.message });
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
onClick={handleClose}
/>
{/* Dialog */}
<div className="relative bg-black/95 backdrop-blur-md border-2 border-neon-blue rounded-lg shadow-2xl w-full max-w-4xl mx-4 animate-fadeIn">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<div className="flex items-center gap-3">
<Move className="w-6 h-6 text-neon-blue" />
<h2 className="text-xl font-tech font-bold text-white uppercase tracking-wider">
Picker(X) Movement and Management
</h2>
</div>
<button
onClick={handleClose}
className="text-slate-400 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="p-4">
{/* Row 1: Jog Controls */}
<div className="grid grid-cols-5 gap-2 mb-2">
{/* Z Up */}
<button
onMouseDown={() => handleJogStart('up')}
onMouseUp={handleJogStop}
onMouseLeave={handleJogStop}
disabled={!status.zEnabled}
className="h-24 flex items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed border border-white/10 rounded-lg text-purple-400 transition-colors"
>
<ChevronUp className="w-16 h-16" strokeWidth={3} />
</button>
{/* X Left Jog */}
<button
onMouseDown={() => handleJogStart('left')}
onMouseUp={handleJogStop}
onMouseLeave={handleJogStop}
disabled={!status.xEnabled}
className="h-24 flex items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed border border-white/10 rounded-lg text-blue-900 transition-colors"
>
<ChevronLeft className="w-16 h-16" strokeWidth={3} />
</button>
{/* Stop */}
<button
onClick={handleStop}
className={`h-24 flex items-center justify-center border border-white/10 rounded-lg transition-colors ${
status.pickerSafe
? 'bg-green-500/30 hover:bg-green-500/40 text-green-400'
: 'bg-red-500/30 hover:bg-red-500/40 text-red-400'
}`}
>
<Square className="w-16 h-16" fill="currentColor" />
</button>
{/* X Right Jog */}
<button
onMouseDown={() => handleJogStart('right')}
onMouseUp={handleJogStop}
onMouseLeave={handleJogStop}
disabled={!status.xEnabled}
className="h-24 flex items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed border border-white/10 rounded-lg text-blue-900 transition-colors"
>
<ChevronRight className="w-16 h-16" strokeWidth={3} />
</button>
{/* Z Down */}
<button
onMouseDown={() => handleJogStart('down')}
onMouseUp={handleJogStop}
onMouseLeave={handleJogStop}
disabled={!status.zEnabled}
className="h-24 flex items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed border border-white/10 rounded-lg text-purple-400 transition-colors"
>
<ChevronDown className="w-16 h-16" strokeWidth={3} />
</button>
</div>
{/* Row 2: Position Buttons */}
<div className="grid grid-cols-5 gap-2 mb-2">
<button
onClick={handleMoveLeft}
disabled={!status.managementEnabled}
className="h-24 flex items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed border border-white/10 rounded-lg text-white font-tech text-2xl font-bold transition-colors"
>
Left
</button>
<button
onClick={handleMoveLeftWait}
disabled={!status.managementEnabled}
className="h-24 flex items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed border border-white/10 rounded-lg text-white font-tech text-2xl font-bold transition-colors"
>
Wait
</button>
<button
onClick={handleMoveCenter}
disabled={!status.managementEnabled}
className="h-24 flex items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed border border-white/10 rounded-lg text-green-400 font-tech text-2xl font-bold transition-colors"
>
Center
</button>
<button
onClick={handleMoveRightWait}
disabled={!status.managementEnabled}
className="h-24 flex items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed border border-white/10 rounded-lg text-white font-tech text-2xl font-bold transition-colors"
>
Wait
</button>
<button
onClick={handleMoveRight}
disabled={!status.managementEnabled}
className="h-24 flex items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed border border-white/10 rounded-lg text-white font-tech text-2xl font-bold transition-colors"
>
Right
</button>
</div>
{/* Row 3: Vision Cancel & Management Position */}
<div className="grid grid-cols-5 gap-2 mb-2">
<button
onClick={handleCancelVisionL}
className="h-24 flex flex-col items-center justify-center bg-amber-900/30 hover:bg-amber-900/50 border border-amber-500/50 rounded-lg text-amber-300 font-tech font-bold transition-colors"
>
<XCircle className="w-6 h-6 mb-1" />
<span className="text-sm">Vision Validation</span>
<span className="text-sm">Cancel(L)</span>
</button>
<button
onClick={handleManagePosL}
disabled={!status.managementEnabled}
className="h-24 flex flex-col items-center justify-center bg-purple-900/30 hover:bg-purple-900/50 disabled:opacity-50 disabled:cursor-not-allowed border border-purple-500/50 rounded-lg text-purple-300 font-tech font-bold transition-colors"
>
<Settings className="w-6 h-6 mb-1" />
<span className="text-sm">Print Management</span>
<span className="text-sm">Position(L)</span>
</button>
<button
onClick={handleManagePosReturn}
disabled={!status.managementEnabled}
className="h-24 flex flex-col items-center justify-center bg-purple-900/30 hover:bg-purple-900/50 disabled:opacity-50 disabled:cursor-not-allowed border border-purple-500/50 rounded-lg text-purple-300 font-tech font-bold transition-colors"
>
<Home className="w-6 h-6 mb-1" />
<span className="text-sm">Management Position</span>
<span className="text-sm">Return</span>
</button>
<button
onClick={handleManagePosR}
disabled={!status.managementEnabled}
className="h-24 flex flex-col items-center justify-center bg-purple-900/30 hover:bg-purple-900/50 disabled:opacity-50 disabled:cursor-not-allowed border border-purple-500/50 rounded-lg text-purple-300 font-tech font-bold transition-colors"
>
<Settings className="w-6 h-6 mb-1" />
<span className="text-sm">Print Management</span>
<span className="text-sm">Position(R)</span>
</button>
<button
onClick={handleCancelVisionR}
className="h-24 flex flex-col items-center justify-center bg-amber-900/30 hover:bg-amber-900/50 border border-amber-500/50 rounded-lg text-amber-300 font-tech font-bold transition-colors"
>
<XCircle className="w-6 h-6 mb-1" />
<span className="text-sm">Vision Validation</span>
<span className="text-sm">Cancel(R)</span>
</button>
</div>
{/* Row 4: Z-Home, Print, Z-Zero */}
<div className="grid grid-cols-5 gap-2">
<button
onClick={handleZHome}
className="h-24 flex flex-col items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 border border-white/10 rounded-lg text-white font-tech text-lg font-bold transition-colors"
>
<Home className="w-6 h-6 mb-1" />
<span>Z-HOME</span>
</button>
<button
onClick={handlePrintL}
className="h-24 flex flex-col items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 border border-white/10 rounded-lg text-white font-tech text-lg font-bold transition-colors"
>
<Printer className="w-6 h-6 mb-1" />
<span>PRINT(L)</span>
</button>
<button
disabled
className="h-24 flex items-center justify-center bg-slate-800/30 border border-white/10 rounded-lg text-slate-500 font-tech text-lg font-bold cursor-not-allowed"
>
--
</button>
<button
onClick={handlePrintR}
className="h-24 flex flex-col items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 border border-white/10 rounded-lg text-white font-tech text-lg font-bold transition-colors"
>
<Printer className="w-6 h-6 mb-1" />
<span>PRINT(R)</span>
</button>
<button
onClick={handleZZero}
className="h-24 flex flex-col items-center justify-center bg-slate-800/50 hover:bg-slate-700/50 border border-white/10 rounded-lg text-white font-tech text-lg font-bold transition-colors"
>
<span className="text-2xl mb-1">0</span>
<span>Z-ZERO</span>
</button>
</div>
</div>
{/* Footer */}
<div className="flex justify-end p-4 border-t border-white/10">
<button
onClick={handleClose}
className="px-6 py-2 bg-slate-700 hover:bg-slate-600 border border-white/20 text-white font-tech font-bold rounded transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
};

View File

@@ -1,13 +1,15 @@
import React from 'react';
import React, { ReactNode } from 'react';
import { LucideIcon } from 'lucide-react';
interface PanelHeaderProps {
title: string;
icon: LucideIcon;
className?: string;
children?: ReactNode;
}
export const PanelHeader: React.FC<PanelHeaderProps> = ({ title, icon: Icon }) => (
<div className="flex items-center gap-3 mb-6 border-b border-white/10 pb-2">
export const PanelHeader: React.FC<PanelHeaderProps> = ({ title, icon: Icon, className, children }) => (
<div className={`flex items-center gap-3 mb-6 border-b border-white/10 pb-2 ${className || ''}`}>
<div className="text-neon-blue animate-pulse">
<Icon className="w-5 h-5" />
</div>
@@ -15,5 +17,6 @@ export const PanelHeader: React.FC<PanelHeaderProps> = ({ title, icon: Icon }) =
{title}
</h2>
<div className="flex-1 h-px bg-gradient-to-r from-neon-blue/50 to-transparent"></div>
{children}
</div>
);

View File

@@ -1,24 +1,68 @@
import React from 'react';
import React, { useState, useEffect } from 'react';
import { RobotTarget } from '../../types';
import { comms } from '../../communication';
// HW 상태 타입 (윈폼 HWState와 동일)
// status: 0=SET(미설정/회색), 1=ON(연결/녹색), 2=TRIG(트리거/노란색), 3=OFF(연결안됨/빨간색)
interface HWItem {
name: string;
title: string;
status: number;
}
interface FooterProps {
isHostConnected: boolean;
robotTarget: RobotTarget;
}
// 상태에 따른 LED 색상 반환
const getStatusColor = (status: number): { bg: string; shadow: string; text: string } => {
switch (status) {
case 1: // ON - 녹색
return { bg: 'bg-neon-green', shadow: 'shadow-[0_0_5px_#0aff00]', text: 'text-green-400' };
case 2: // TRIG - 노란색
return { bg: 'bg-yellow-400', shadow: 'shadow-[0_0_5px_#facc15]', text: 'text-yellow-400' };
case 3: // OFF - 빨간색
return { bg: 'bg-red-500', shadow: 'shadow-[0_0_5px_#ef4444]', text: 'text-red-400' };
default: // SET - 회색 (미설정)
return { bg: 'bg-gray-500', shadow: '', text: 'text-gray-400' };
}
};
export const Footer: React.FC<FooterProps> = ({ isHostConnected, robotTarget }) => {
const [hwStatus, setHwStatus] = useState<HWItem[]>([]);
useEffect(() => {
// HW_STATUS_UPDATE 이벤트 구독
const unsubscribe = comms.subscribe((msg: any) => {
if (msg?.type === 'HW_STATUS_UPDATE' && msg.data) {
setHwStatus(msg.data);
}
});
return () => {
unsubscribe();
};
}, []);
return (
<footer className="absolute bottom-0 left-0 right-0 h-10 bg-black/80 border-t border-neon-blue/30 flex items-center px-6 justify-between z-40 backdrop-blur text-xs font-mono text-slate-400">
<div className="flex gap-6">
{['PLC', 'MOTION', 'VISION', 'LIGHT'].map(hw => (
<div key={hw} className="flex items-center gap-2">
<div className="w-2 h-2 bg-neon-green rounded-full shadow-[0_0_5px_#0aff00]"></div>
<span className="font-bold text-slate-300">{hw}</span>
</div>
))}
<div className="flex items-center gap-2">
<div className="flex gap-4">
{/* H/W 상태 표시 (윈폼 HWState와 동일) */}
{hwStatus.map((hw) => {
const colors = getStatusColor(hw.status);
return (
<div key={hw.name} className="flex items-center gap-1.5" title={`${hw.name}: ${hw.title}`}>
<div className={`w-2 h-2 rounded-full transition-all ${colors.bg} ${colors.shadow}`}></div>
<span className={`font-bold ${colors.text}`}>{hw.name}</span>
<span className="text-[10px] text-slate-500">{hw.title}</span>
</div>
);
})}
{/* HOST 연결 상태 */}
<div className="flex items-center gap-1.5 ml-2 pl-2 border-l border-slate-700">
<div className={`w-2 h-2 rounded-full transition-all ${isHostConnected ? 'bg-neon-green shadow-[0_0_5px_#0aff00]' : 'bg-red-500 shadow-[0_0_5px_#ff0000] animate-pulse'}`}></div>
<span className={`font-bold ${isHostConnected ? 'text-slate-300' : 'text-red-400'}`}>HOST</span>
<span className={`font-bold ${isHostConnected ? 'text-green-400' : 'text-red-400'}`}>HOST</span>
</div>
</div>
<div className="flex gap-8 text-neon-blue">

View File

@@ -1,6 +1,6 @@
import React, { useState } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { Activity, Settings, Move, Camera, Layers, Cpu, Target, Lightbulb, Printer, XCircle, Package, BookOpen } from 'lucide-react';
import { Activity, Settings, Move, Camera, Layers, Cpu, Target, Lightbulb, Printer, XCircle, Package, BookOpen, History, Clock } from 'lucide-react';
import { VisionMenu } from '../VisionMenu';
import { FunctionMenu } from '../FunctionMenu';
import { ManualPrintDialog, PrintData } from '../ManualPrintDialog';
@@ -127,6 +127,20 @@ export const Header: React.FC<HeaderProps> = ({ currentTime, onTabChange, active
<XCircle className="w-5 h-5" />
<span className="leading-tight">CANCEL</span>
</button>
<button
onClick={() => {
navigate('/history');
onTabChange(null);
}}
className={`flex flex-col items-center justify-center gap-1 px-3 py-2 rounded-xl font-tech font-bold text-[10px] transition-all border border-transparent min-w-[70px] ${location.pathname === '/history'
? 'bg-neon-blue/10 text-neon-blue border-neon-blue shadow-glow-blue'
: 'text-slate-400 hover:text-purple-400 hover:bg-white/5'
}`}
title="View Work History"
>
<Clock className="w-5 h-5" />
<span className="leading-tight">HISTORY</span>
</button>
</div>
{/* Main Navigation */}

View File

@@ -1,4 +1,4 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import React, { createContext, useContext, useState, ReactNode, useCallback, useRef } from 'react';
interface AlertConfig {
type: 'success' | 'error' | 'warning' | 'info';
@@ -6,8 +6,14 @@ interface AlertConfig {
message: string;
}
interface ConfirmConfig {
title: string;
message: string;
}
interface AlertContextType {
showAlert: (config: AlertConfig) => void;
showConfirm: (config: ConfirmConfig) => Promise<boolean>;
}
const AlertContext = createContext<AlertContextType | undefined>(undefined);
@@ -26,6 +32,8 @@ interface AlertProviderProps {
export const AlertProvider: React.FC<AlertProviderProps> = ({ children }) => {
const [alertConfig, setAlertConfig] = useState<(AlertConfig & { isOpen: boolean }) | null>(null);
const [confirmConfig, setConfirmConfig] = useState<(ConfirmConfig & { isOpen: boolean }) | null>(null);
const confirmResolveRef = useRef<((value: boolean) => void) | null>(null);
const showAlert = (config: AlertConfig) => {
setAlertConfig({ ...config, isOpen: true });
@@ -35,9 +43,25 @@ export const AlertProvider: React.FC<AlertProviderProps> = ({ children }) => {
setAlertConfig(null);
};
const showConfirm = useCallback((config: ConfirmConfig): Promise<boolean> => {
return new Promise((resolve) => {
confirmResolveRef.current = resolve;
setConfirmConfig({ ...config, isOpen: true });
});
}, []);
const handleConfirm = (result: boolean) => {
setConfirmConfig(null);
if (confirmResolveRef.current) {
confirmResolveRef.current(result);
confirmResolveRef.current = null;
}
};
return (
<AlertContext.Provider value={{ showAlert }}>
<AlertContext.Provider value={{ showAlert, showConfirm }}>
{children}
{/* Alert Dialog */}
{alertConfig && (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
@@ -77,6 +101,53 @@ export const AlertProvider: React.FC<AlertProviderProps> = ({ children }) => {
</div>
</div>
)}
{/* Confirm Dialog */}
{confirmConfig && (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/80 backdrop-blur-sm"
onClick={() => handleConfirm(false)}
/>
{/* Dialog */}
<div className="relative bg-black/95 backdrop-blur-md border-2 border-amber-500 rounded-lg shadow-2xl max-w-md w-full mx-4 animate-fadeIn">
{/* Icon and Title */}
<div className="flex items-center gap-4 p-6 border-b border-white/10">
<svg className="w-12 h-12 text-amber-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<h2 className="text-xl font-tech font-bold text-white uppercase tracking-wider">
{confirmConfig.title}
</h2>
</div>
{/* Message */}
<div className="p-6 max-w-full overflow-hidden">
<p className="text-slate-300 font-mono text-sm whitespace-pre-wrap leading-relaxed break-words max-w-full">
{confirmConfig.message}
</p>
</div>
{/* Buttons */}
<div className="flex justify-end gap-3 p-6 border-t border-white/10">
<button
onClick={() => handleConfirm(false)}
className="px-6 py-2 bg-slate-700 hover:bg-slate-600 border border-white/20 text-white font-tech font-bold rounded transition-colors"
>
No
</button>
<button
onClick={() => handleConfirm(true)}
className="px-6 py-2 bg-amber-500/20 hover:bg-amber-500/30 border border-amber-500 text-amber-400 font-tech font-bold rounded transition-colors"
>
Yes
</button>
</div>
</div>
</div>
)}
</AlertContext.Provider>
);
};

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>

View File

@@ -99,6 +99,56 @@ declare global {
InitializeDevice(): Promise<string>;
GetInitializeStatus(): Promise<string>;
GetProcessedData(): Promise<string>;
// Vision control methods
CameraConnect(): Promise<string>;
CameraDisconnect(): Promise<string>;
CameraGetImage(): Promise<string>;
CameraLiveView(): Promise<string>;
CameraReadTest(): Promise<string>;
KeyenceTriggerOn(): Promise<string>;
KeyenceTriggerOff(): Promise<string>;
KeyenceGetImage(): Promise<string>;
KeyenceSaveImage(): Promise<string>;
// Light, Manual Print, Cancel Job
ToggleLight(): Promise<string>;
ExecuteManualPrint(sid: string, venderLot: string, qty: string, mfg: string, rid: string, spy: string, partNo: string, printer: string, count: number): Promise<string>;
CancelJob(): Promise<string>;
// Manage, Manual, Log Viewer
OpenManage(): Promise<string>;
CloseManage(): Promise<string>;
OpenManual(): Promise<string>;
OpenLogViewer(): Promise<string>;
// Folder operations
OpenProgramFolder(): Promise<string>;
OpenLogFolder(): Promise<string>;
OpenScreenshotFolder(): Promise<string>;
OpenSavedDataFolder(): Promise<string>;
// Picker Move methods
GetPickerStatus(): Promise<string>;
PickerMoveLeft(): Promise<string>;
PickerMoveLeftWait(): Promise<string>;
PickerMoveCenter(): Promise<string>;
PickerMoveRightWait(): Promise<string>;
PickerMoveRight(): Promise<string>;
PickerJogStart(direction: string): Promise<string>;
PickerJogStop(): Promise<string>;
PickerStop(): Promise<string>;
CancelVisionValidation(side: string): Promise<string>;
PickerManagePosition(side: string): Promise<string>;
PickerManageReturn(): Promise<string>;
PickerZHome(): Promise<string>;
PickerZZero(): Promise<string>;
PickerTestPrint(side: string): Promise<string>;
CanCloseManage(): Promise<string>;
// Interlock methods
ToggleInterlock(axisIndex: number, lockIndex: number): Promise<string>;
GetInterlockList(): Promise<string>;
// HW Status methods
GetHWStatus(): Promise<string>;
// History dialog
OpenHistory(): Promise<string>;
// History data
GetHistoryData(startDate: string, endDate: string, search: string): Promise<string>;
}
};
addEventListener(type: string, listener: (event: any) => void): void;