From 63b0e94ec2d6bf97c080bcd8c5711175be358785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=8B=9C=EA=B3=A8=EC=95=BD=EC=82=AC?= Date: Sat, 2 Aug 2025 00:52:48 +0000 Subject: [PATCH] Initial commit for dev-front repository --- DEV8_MIGRATION_MASTER_PLAN.md | 384 ++++++++ src/App.tsx | 15 +- src/api/client.ts | 175 ++++ src/api/endpoints.ts | 116 +++ src/api/index.ts | 131 +++ src/api/services.ts | 311 +++++++ src/api/types.ts | 318 +++++++ src/components/NewControlScreen.tsx | 1191 ++++++++++++++---------- src/components/SignageTabletScreen.tsx | 651 +++++++------ src/components/SlideManager.tsx | 249 ++--- src/utils/realtime-socket.ts | 71 +- vite.config.ts | 4 +- 12 files changed, 2639 insertions(+), 977 deletions(-) create mode 100644 DEV8_MIGRATION_MASTER_PLAN.md create mode 100644 src/api/client.ts create mode 100644 src/api/endpoints.ts create mode 100644 src/api/index.ts create mode 100644 src/api/services.ts create mode 100644 src/api/types.ts diff --git a/DEV8_MIGRATION_MASTER_PLAN.md b/DEV8_MIGRATION_MASTER_PLAN.md new file mode 100644 index 0000000..677cb40 --- /dev/null +++ b/DEV8_MIGRATION_MASTER_PLAN.md @@ -0,0 +1,384 @@ +# Dev8.py 듀얼 타이머 시스템 마이그레이션 계획서 + +## 🎯 프로젝트 개요 + +**목적**: 기존 Front 프로젝트를 dev8.py 백엔드의 듀얼 타이머 시스템에 맞춰 업그레이드 + +**주요 변경사항**: +- 단일 타이머 → 듀얼 타이머 (물리치료 15분 + 레이저치료 5분) +- 새로운 API 엔드포인트 및 Socket.IO 이벤트 구조 +- 향상된 의료진 호출 시스템 +- 파일 관리 및 디지털 사이니지 기능 + +--- + +## 📊 백엔드 분석 요약 + +### Dev8.py 핵심 특징 + +1. **듀얼 타이머 시스템** + - 물리치료: 900초 (15분) + - 레이저치료: 300초 (5분) + - 동시 실행 가능, 독립적 제어 + +2. **실시간 통신** + - Socket.IO 기반 100ms 정밀도 업데이트 + - 8개 태블릿 + 관제센터 지원 + - 역할 기반 권한 시스템 + +3. **의료진 호출 시스템** + - 호출 생성, 확인, 취소 플로우 + - 실시간 상태 동기화 + +4. **파일 관리 & 디지털 사이니지** + - 이미지/비디오 업로드 지원 + - 슬라이드 CRUD 관리 + - 시퀀스 기반 재생 + +--- + +## 🗂️ 마이그레이션 작업 계획 + +### Phase 1: 인프라 구조 업데이트 + +#### 1.1 Socket 연결 모듈 업그레이드 (`src/utils/realtime-socket.ts`) + +**현재 (v7) → 목표 (v8)**: + +```typescript +// ❌ 기존: 단일 타이머 +interface TimerState { + tablet_id: string; + countdown: number; + status: TimerStatus; +} + +// ✅ 새로운: 듀얼 타이머 +interface TimerState { + tablet_id: string; + timer_type: TimerType; // 🆕 physical | laser + countdown: number; + status: TimerStatus; + start_time?: number; + pause_time?: number; + completion_time?: number; // 🆕 완료 시간 +} + +enum TimerType { // 🆕 타이머 유형 + PHYSICAL = "physical", + LASER = "laser" +} +``` + +**새로운 Socket 이벤트 지원**: +- `timer_tick`: 100ms 실시간 업데이트 +- `timer_finished`: 타이머 완료 알림 +- `treatment_completed`: 치료 완료 알림 +- `reset_timer`: 완료된 타이머 리셋 + +#### 1.2 서버 URL 및 포트 변경 + +```typescript +// 변경: dev8.py 로컬 서버 +const SERVER_URL = 'http://localhost:5005'; +``` + +### Phase 2: UI 컴포넌트 업데이트 + +#### 2.1 SignageTabletScreen 듀얼 타이머 UI + +**현재 구조**: +``` +[단일 타이머 표시] +[시작/일시정지/정지 버튼] +``` + +**새로운 구조**: +``` +[물리치료 타이머 (15분)] [레이저치료 타이머 (5분)] +[독립적 제어 버튼들] [독립적 제어 버튼들] +[동시 실행 상태 표시] +``` + +**구현 계획**: +1. 두 개의 독립적인 타이머 상태 관리 +2. 타이머별 UI 컴포넌트 분리 +3. 시각적 구분 (색상, 아이콘) +4. 완료 상태 및 리셋 기능 + +#### 2.2 NewControlScreen 관제센터 업그레이드 + +**새로운 기능**: +- 8개 태블릿의 듀얼 타이머 모니터링 +- 모든 타이머 일괄 제어 (`stop_all_timers`) +- 의료진 호출 관리 (확인/취소) +- 실시간 통계 및 상태 표시 + +#### 2.3 NewTabletScreen 개발자 도구 확장 + +**추가 기능**: +- 듀얼 타이머 디버깅 정보 +- Socket.IO 이벤트 실시간 로그 +- API 테스트 도구 +- 타이머 상태 상세 정보 + +### Phase 3: 새로운 기능 구현 + +#### 3.1 파일 관리 시스템 + +**새로운 컴포넌트**: `FileManager.tsx` +- 드래그앤드롭 업로드 +- 이미지/비디오 미리보기 +- UUID 기반 파일 관리 +- 파일 타입 검증 + +#### 3.2 슬라이드 관리 강화 + +**SlideManager.tsx 업그레이드**: +- 새로운 API 엔드포인트 연동 +- 시퀀스 기반 정렬 +- 미디어 타입별 처리 +- 실시간 미리보기 + +### Phase 4: 상태 관리 최적화 + +#### 4.1 듀얼 타이머 상태 관리 + +```typescript +// 타이머별 상태 분리 +const [physicalTimer, setPhysicalTimer] = useState({ + tablet_id: '', + timer_type: TimerType.PHYSICAL, + countdown: 0, + status: TimerStatus.STOPPED +}); + +const [laserTimer, setLaserTimer] = useState({ + tablet_id: '', + timer_type: TimerType.LASER, + countdown: 0, + status: TimerStatus.STOPPED +}); +``` + +#### 4.2 실시간 이벤트 핸들러 + +```typescript +// 타이머별 이벤트 처리 +const handleTimerTick = (data: TimerTickEvent) => { + if (data.timer_type === TimerType.PHYSICAL) { + setPhysicalTimer(prev => ({ ...prev, countdown: data.countdown })); + } else { + setLaserTimer(prev => ({ ...prev, countdown: data.countdown })); + } +}; + +const handleTimerFinished = (data: TimerFinishedEvent) => { + // 완료 알림 및 UI 업데이트 + showCompletionNotification(data.timer_type); +}; +``` + +--- + +## 🎨 UI/UX 설계 변경사항 + +### 듀얼 타이머 시각적 설계 + +#### 색상 스키마 +- **물리치료**: 파란색 계열 (#3B82F6) +- **레이저치료**: 빨간색 계열 (#EF4444) +- **완료 상태**: 녹색 계열 (#10B981) + +#### 레이아웃 구조 +``` +┌─────────────────────────────────────────┐ +│ [헤더 - 연결상태, 설정버튼] │ +├─────────────────────────────────────────┤ +│ │ +│ [사이니지 백그라운드] │ +│ │ +│ ┌─────────────┐ ┌─────────────┐ │ +│ │ 물리치료 │ │ 레이저치료 │ │ +│ │ 15:00 │ │ 05:00 │ │ +│ │ [⏯][⏹] │ │ [⏯][⏹] │ │ +│ └─────────────┘ └─────────────┘ │ +│ │ +│ [의료진 호출 상태] │ +└─────────────────────────────────────────┘ +``` + +#### 반응형 설계 +- **태블릿 모드**: 듀얼 타이머 나란히 배치 +- **모바일 모드**: 세로 스택 배치 +- **터치 최적화**: 44px 이상 터치 영역 + +### 상태 표시 시스템 + +#### 타이머 상태별 시각적 피드백 +- **STOPPED**: 회색 테두리, 시작 버튼 활성화 +- **RUNNING**: 진행 중 애니메이션, 일시정지/정지 버튼 +- **PAUSED**: 주황색 표시, 재개/정지 버튼 +- **FINISHED**: 녹색 배경, 리셋 버튼 + +#### 의료진 호출 상태 +- **NONE**: 호출 버튼 표시 +- **CALLING**: 애니메이션 효과, 취소 버튼 +- **ACKNOWLEDGED**: 확인됨 표시, 완료 버튼 + +--- + +## 🔧 기술적 구현 세부사항 + +### API 통신 계층 + +#### HTTP Client 설정 +```typescript +const API_BASE_URL = 'http://localhost:5005/api'; + +// 새로운 API 엔드포인트 +const apiEndpoints = { + health: '/health', + status: '/status', + timers: '/timers', + calls: '/calls', + slides: '/slides', + files: '/files' +}; +``` + +#### Socket.IO 클라이언트 설정 +```typescript +const socket = io('http://localhost:5005', { + transports: ['websocket', 'polling'], + timeout: 5000, + reconnection: true, + reconnectionAttempts: 5, + reconnectionDelay: 1000 +}); +``` + +### 에러 처리 및 복구 + +#### 연결 실패 처리 +- 자동 재연결 시도 +- 오프라인 모드 지원 +- 사용자 알림 시스템 + +#### 데이터 검증 +- TypeScript 타입 체크 +- 런타임 데이터 유효성 검사 +- 에러 바운더리 구현 + +--- + +## 🧪 테스트 전략 + +### 단위 테스트 +- 타이머 로직 테스트 +- Socket.IO 이벤트 핸들러 테스트 +- UI 컴포넌트 렌더링 테스트 + +### 통합 테스트 +- 백엔드 API 연동 테스트 +- 실시간 통신 테스트 +- 듀얼 타이머 시나리오 테스트 + +### 사용자 테스트 +- 터치 인터페이스 테스트 +- 의료진 호출 플로우 테스트 +- 다중 태블릿 동시 사용 테스트 + +--- + +## 📅 마이그레이션 일정 + +### Week 1: 인프라 및 기본 구조 +- [ ] Socket 연결 모듈 업그레이드 +- [ ] 타이머 상태 인터페이스 정의 +- [ ] 기본 UI 컴포넌트 수정 + +### Week 2: 듀얼 타이머 구현 +- [ ] SignageTabletScreen 듀얼 타이머 UI +- [ ] 타이머 제어 로직 구현 +- [ ] 실시간 업데이트 처리 + +### Week 3: 관제센터 및 고급 기능 +- [ ] NewControlScreen 업그레이드 +- [ ] 의료진 호출 시스템 완성 +- [ ] 파일 관리 기능 구현 + +### Week 4: 최적화 및 테스트 +- [ ] 성능 최적화 +- [ ] 통합 테스트 +- [ ] 사용자 테스트 및 피드백 반영 + +--- + +## 🎯 성공 지표 + +### 기능적 요구사항 +- ✅ 듀얼 타이머 독립적 제어 +- ✅ 실시간 100ms 정밀도 업데이트 +- ✅ 8개 태블릿 동시 지원 +- ✅ 의료진 호출 완전 플로우 +- ✅ 디지털 사이니지 통합 + +### 비기능적 요구사항 +- ✅ 1초 이내 UI 반응성 +- ✅ 99% 연결 안정성 +- ✅ 터치 인터페이스 최적화 +- ✅ 크로스 브라우저 호환성 + +### 사용자 경험 +- ✅ 직관적인 듀얼 타이머 인터페이스 +- ✅ 명확한 상태 시각적 피드백 +- ✅ 실수 방지 UX 패턴 +- ✅ 접근성 표준 준수 + +--- + +## 🚀 배포 계획 + +### 개발 환경 +- **포트**: 5071 (dev-front) +- **백엔드**: localhost:5005 (dev8.py) +- **데이터베이스**: SQLite (개발용) + +### 스테이징 환경 +- **도메인**: dev.ysl.0bin.in +- **리버스 프록시**: Nginx +- **SSL**: Let's Encrypt + +### 프로덕션 환경 +- **도메인**: ysl.0bin.in +- **CDN**: 정적 자원 최적화 +- **모니터링**: 실시간 상태 감시 + +--- + +## 📋 위험 요소 및 대응 방안 + +### 기술적 위험 +1. **실시간 통신 안정성** + - 대응: 연결 상태 모니터링, 자동 재연결 +2. **듀얼 타이머 동기화** + - 대응: 서버 시간 기준 동기화, 로컬 보정 +3. **대용량 파일 업로드** + - 대응: 청크 업로드, 진행률 표시 + +### 사용자 경험 위험 +1. **복잡성 증가** + - 대응: 단계별 사용자 가이드, 직관적 UI +2. **터치 오작동** + - 대응: 확인 다이얼로그, 실행 취소 기능 + +### 운영 위험 +1. **기존 시스템과의 호환성** + - 대응: 점진적 마이그레이션, 롤백 계획 +2. **사용자 교육** + - 대응: 트레이닝 자료, 실시간 도움말 + +--- + +이 계획서는 dev8.py 백엔드 시스템의 모든 기능을 완전히 활용하는 현대적이고 직관적인 프론트엔드를 구축하기 위한 종합적인 로드맵을 제공합니다. \ No newline at end of file diff --git a/src/App.tsx b/src/App.tsx index ac8f603..6d0fab2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -68,9 +68,14 @@ function App() { }; const handleGlobalSelectStart = (e: Event) => { - // 입력 필드는 제외 + // 입력 필드와 버튼은 제외 const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.contentEditable === 'true') { + if (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'BUTTON' || + target.contentEditable === 'true' || + target.closest('button') || + target.closest('[role="button"]')) { return; } e.preventDefault(); @@ -79,7 +84,11 @@ function App() { const handleGlobalDragStart = (e: Event) => { const target = e.target as HTMLElement; - if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') { + if (target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.tagName === 'BUTTON' || + target.closest('button') || + target.closest('[role="button"]')) { return; } e.preventDefault(); diff --git a/src/api/client.ts b/src/api/client.ts new file mode 100644 index 0000000..1c3991b --- /dev/null +++ b/src/api/client.ts @@ -0,0 +1,175 @@ +/** + * Central API Client for dev8.py Backend + * Base URL: https://yapi.0bin.in + */ + +export interface ApiResponse { + success: boolean; + data?: T; + error?: string; + message?: string; +} + +export interface ApiError { + message: string; + status: number; + code?: string; +} + +export class ApiClient { + private baseURL: string; + private timeout: number; + + constructor(baseURL: string = 'https://yapi.0bin.in', timeout: number = 10000) { + this.baseURL = baseURL.replace(/\/$/, ''); // Remove trailing slash + this.timeout = timeout; + } + + private async request( + endpoint: string, + options: RequestInit = {} + ): Promise { + const url = `${this.baseURL}${endpoint}`; + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), this.timeout); + + try { + // 기본 헤더 설정 (파일 업로드가 아닌 경우만) + const defaultHeaders: Record = {}; + if (!(options.body instanceof FormData)) { + defaultHeaders['Content-Type'] = 'application/json'; + } + + const response = await fetch(url, { + ...options, + signal: controller.signal, + headers: { + ...defaultHeaders, + ...options.headers, + }, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + throw new ApiError({ + message: `HTTP ${response.status}: ${response.statusText}`, + status: response.status, + }); + } + + const data = await response.json(); + return data; + } catch (error) { + clearTimeout(timeoutId); + + if (error instanceof ApiError) { + throw error; + } + + if (error.name === 'AbortError') { + throw new ApiError({ + message: 'Request timeout', + status: 408, + code: 'TIMEOUT' + }); + } + + throw new ApiError({ + message: error.message || 'Network error', + status: 0, + code: 'NETWORK_ERROR' + }); + } + } + + // HTTP Methods + async get(endpoint: string, params?: Record): Promise { + const url = new URL(`${this.baseURL}${endpoint}`); + if (params) { + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.append(key, String(value)); + } + }); + } + + return this.request(url.pathname + url.search, { + method: 'GET', + }); + } + + async post(endpoint: string, data?: any): Promise { + return this.request(endpoint, { + method: 'POST', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async put(endpoint: string, data?: any): Promise { + return this.request(endpoint, { + method: 'PUT', + body: data ? JSON.stringify(data) : undefined, + }); + } + + async delete(endpoint: string): Promise { + return this.request(endpoint, { + method: 'DELETE', + }); + } + + // File upload with multipart/form-data + async uploadFile(endpoint: string, file: File, additionalData?: Record): Promise { + const formData = new FormData(); + formData.append('file', file); + + if (additionalData) { + Object.entries(additionalData).forEach(([key, value]) => { + formData.append(key, String(value)); + }); + } + + return this.request(endpoint, { + method: 'POST', + body: formData, + headers: { + // Don't set Content-Type, let browser set it with boundary + }, + }); + } + + // Health check + async healthCheck(): Promise<{ status: string; timestamp: number; version: string }> { + return this.get('/api/health'); + } + + // Update base URL (useful for switching between dev/prod) + setBaseURL(url: string): void { + this.baseURL = url.replace(/\/$/, ''); + } + + // Get current base URL + getBaseURL(): string { + return this.baseURL; + } +} + +// Create singleton instance +export const apiClient = new ApiClient(); + +// Custom error class +class ApiError extends Error { + status: number; + code?: string; + + constructor({ message, status, code }: { message: string; status: number; code?: string }) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + } +} + +export { ApiError }; \ No newline at end of file diff --git a/src/api/endpoints.ts b/src/api/endpoints.ts new file mode 100644 index 0000000..429aec3 --- /dev/null +++ b/src/api/endpoints.ts @@ -0,0 +1,116 @@ +/** + * API Endpoints for dev8.py Backend + * Centralized endpoint definitions + */ + +export const API_ENDPOINTS = { + // Health & Status + HEALTH: '/api/health', + STATUS: '/api/status', + + // Timer Management (Dual Timer System) + TIMERS: { + LIST: '/api/timers', + START: '/api/timers/start', + PAUSE: '/api/timers/pause', + RESUME: '/api/timers/resume', + STOP: '/api/timers/stop', + RESET: '/api/timers/reset', + STOP_ALL: '/api/timers/stop-all', + }, + + // Medical Calls + CALLS: { + LIST: '/api/calls', + CREATE: '/api/calls', + ACKNOWLEDGE: (callId: string) => `/api/calls/${callId}/acknowledge`, + CANCEL: (callId: string) => `/api/calls/${callId}/cancel`, + }, + + // Digital Signage & Slides + SLIDES: { + LIST: '/api/slides', + CREATE: '/api/slides', + UPDATE: (slideId: number) => `/api/slides/${slideId}`, + DELETE: (slideId: number) => `/api/slides/${slideId}`, + REORDER: '/api/slides/reorder', + }, + + // File Management + FILES: { + UPLOAD: '/api/files/upload', + LIST: '/api/files', + DELETE: (fileId: string) => `/api/files/${fileId}`, + DOWNLOAD: (fileId: string) => `/api/files/${fileId}/download`, + }, + + // Statistics & Monitoring + STATS: { + OVERVIEW: '/api/stats', + TIMERS: '/api/stats/timers', + CALLS: '/api/stats/calls', + }, +} as const; + +// Helper function to build endpoints with parameters +export const buildEndpoint = (template: string, params: Record): string => { + return Object.entries(params).reduce( + (endpoint, [key, value]) => endpoint.replace(`:${key}`, String(value)), + template + ); +}; + +// Socket.IO event names (matching dev8.py backend) +export const SOCKET_EVENTS = { + // Connection Events + CONNECT: 'connect', + DISCONNECT: 'disconnect', + CONNECT_ERROR: 'connect_error', + CONNECTION_ESTABLISHED: 'connection_established', + + // Registration Events + REGISTER_TABLET: 'register_tablet', + REGISTER_CONTROL: 'register_control', + REGISTRATION_SUCCESS: 'registration_success', + + // Timer Events (Dual Timer System) + START_TIMER: 'start_timer', + PAUSE_TIMER: 'pause_timer', + RESUME_TIMER: 'resume_timer', + STOP_TIMER: 'stop_timer', + RESET_TIMER: 'reset_timer', + STOP_ALL_TIMERS: 'stop_all_timers', + + // Timer Status Events + TIMER_STATE_CHANGED: 'timer_state_changed', + TIMER_TICK: 'timer_tick', + TIMER_FINISHED: 'timer_finished', + TREATMENT_COMPLETED: 'treatment_completed', + ALL_TIMERS_STATE: 'all_timers_state', + GET_ALL_TIMERS: 'get_all_timers', + + // Medical Call Events + CREATE_MEDICAL_CALL: 'create_medical_call', + ACKNOWLEDGE_MEDICAL_CALL: 'acknowledge_medical_call', + CANCEL_MEDICAL_CALL: 'cancel_medical_call', + MEDICAL_CALL_CREATED: 'medical_call_created', + MEDICAL_CALL_ACKNOWLEDGED: 'medical_call_acknowledged', + MEDICAL_CALL_CANCELLED: 'medical_call_cancelled', + ALL_CALLS_STATE: 'all_calls_state', + GET_ALL_CALLS: 'get_all_calls', + + // System Events + ACTION_SUCCESS: 'action_success', + ERROR: 'error', + RECONNECT: 'reconnect', + RECONNECT_ERROR: 'reconnect_error', + RECONNECT_FAILED: 'reconnect_failed', +} as const; + +// Server configuration +export const SERVER_CONFIG = { + PROD_URL: 'https://yapi.0bin.in', + DEV_URL: 'http://localhost:5005', + SOCKET_IO_PATH: '/socket.io/', + API_VERSION: 'v1', +} as const; \ No newline at end of file diff --git a/src/api/index.ts b/src/api/index.ts new file mode 100644 index 0000000..73a3b90 --- /dev/null +++ b/src/api/index.ts @@ -0,0 +1,131 @@ +/** + * API Module Entry Point + * Central exports for all API-related functionality + */ + +// Main API Client +export { apiClient, ApiClient, ApiError } from './client'; + +// API Endpoints and Configuration +export { API_ENDPOINTS, SOCKET_EVENTS, SERVER_CONFIG, buildEndpoint } from './endpoints'; + +// Type Definitions +export type { + // Core Interfaces + TimerState, + DualTimerState, + CallState, + AllTimersState, + AllCallsState, + + // Connection & Registration + ConnectionInfo, + RegistrationSuccess, + + // Timer Events + TimerStateChanged, + TimerTick, + TimerFinished, + TreatmentCompleted, + TimerReset, + + // Medical Call Events + MedicalCallCreated, + MedicalCallAcknowledged, + MedicalCallCancelled, + + // System Events + ActionSuccess, + ErrorResponse, + + // Digital Signage + SlideData, + SlideCreateData, + SlideUpdateData, + + // File Management + FileUploadResponse, + FileInfo, + + // API Request Types + TimerControlRequest, + MedicalCallRequest, + TabletRegistrationRequest, + + // Statistics + TimerStats, + CallStats, + SystemStats, + HealthCheckResponse, + + // Socket Listeners + SocketEventListeners, +} from './types'; + +// Export enums as values +export { TimerType, TimerStatus, CallStatus, ClientType } from './types'; + +// Service Functions +export { + healthService, + timerService, + callService, + slideService, + fileService, + statsService, + apiUtils, +} from './services'; + +// Development utilities +export const DevUtils = { + /** + * Switch to development server + */ + useDevelopmentServer: () => { + apiClient.setBaseURL('http://localhost:5005'); + }, + + /** + * Switch to production server + */ + useProductionServer: () => { + apiClient.setBaseURL('https://yapi.0bin.in'); + }, + + /** + * Get current server URL + */ + getCurrentServer: () => { + return apiClient.getBaseURL(); + }, + + /** + * Test both development and production servers + */ + testAllServers: async () => { + const results = { + development: false, + production: false, + }; + + // Test development server + try { + apiClient.setBaseURL('http://localhost:5005'); + await apiClient.healthCheck(); + results.development = true; + } catch (error: any) { + console.warn('Development server not available:', error.message); + } + + // Test production server + try { + apiClient.setBaseURL('https://yapi.0bin.in'); + await apiClient.healthCheck(); + results.production = true; + } catch (error: any) { + console.warn('Production server not available:', error.message); + } + + return results; + }, +}; \ No newline at end of file diff --git a/src/api/services.ts b/src/api/services.ts new file mode 100644 index 0000000..a628a07 --- /dev/null +++ b/src/api/services.ts @@ -0,0 +1,311 @@ +/** + * API Service Layer for dev8.py Backend + * High-level service functions using the API client + */ + +import { apiClient } from './client'; +import { API_ENDPOINTS } from './endpoints'; +import type { + HealthCheckResponse, + SystemStats, + TimerStats, + CallStats, + SlideData, + SlideCreateData, + SlideUpdateData, + FileUploadResponse, + FileInfo, + TimerControlRequest, + MedicalCallRequest, + AllTimersState, + AllCallsState, +} from './types'; +import { TimerType } from './types'; + +// Health & Status Services +export const healthService = { + /** + * Check server health status + */ + async checkHealth(): Promise { + return apiClient.get(API_ENDPOINTS.HEALTH); + }, + + /** + * Get system status and statistics + */ + async getStatus(): Promise { + return apiClient.get(API_ENDPOINTS.STATUS); + }, +}; + +// Timer Services (Dual Timer System) +export const timerService = { + /** + * Get all timer states + */ + async getAllTimers(): Promise { + return apiClient.get(API_ENDPOINTS.TIMERS.LIST); + }, + + /** + * Start a specific timer + */ + async startTimer(tabletId: string, timerType: TimerType): Promise { + const data: TimerControlRequest = { tablet_id: tabletId, timer_type: timerType }; + return apiClient.post(API_ENDPOINTS.TIMERS.START, data); + }, + + /** + * Pause a specific timer + */ + async pauseTimer(tabletId: string, timerType: TimerType): Promise { + const data: TimerControlRequest = { tablet_id: tabletId, timer_type: timerType }; + return apiClient.post(API_ENDPOINTS.TIMERS.PAUSE, data); + }, + + /** + * Resume a specific timer + */ + async resumeTimer(tabletId: string, timerType: TimerType): Promise { + const data: TimerControlRequest = { tablet_id: tabletId, timer_type: timerType }; + return apiClient.post(API_ENDPOINTS.TIMERS.RESUME, data); + }, + + /** + * Stop a specific timer + */ + async stopTimer(tabletId: string, timerType: TimerType): Promise { + const data: TimerControlRequest = { tablet_id: tabletId, timer_type: timerType }; + return apiClient.post(API_ENDPOINTS.TIMERS.STOP, data); + }, + + /** + * Reset a specific timer + */ + async resetTimer(tabletId: string, timerType: TimerType): Promise { + const data: TimerControlRequest = { tablet_id: tabletId, timer_type: timerType }; + return apiClient.post(API_ENDPOINTS.TIMERS.RESET, data); + }, + + /** + * Stop all timers (control center only) + */ + async stopAllTimers(): Promise { + return apiClient.post(API_ENDPOINTS.TIMERS.STOP_ALL); + }, + + /** + * Get timer statistics + */ + async getTimerStats(): Promise { + return apiClient.get(API_ENDPOINTS.STATS.TIMERS); + }, +}; + +// Medical Call Services +export const callService = { + /** + * Get all call states + */ + async getAllCalls(): Promise { + return apiClient.get(API_ENDPOINTS.CALLS.LIST); + }, + + /** + * Create a medical call + */ + async createCall(tabletId: string, message?: string): Promise { + const data: MedicalCallRequest = { + tablet_id: tabletId, + message: message || '의료진 호출' + }; + return apiClient.post(API_ENDPOINTS.CALLS.CREATE, data); + }, + + /** + * Acknowledge a medical call + */ + async acknowledgeCall(callId: string): Promise { + return apiClient.post(API_ENDPOINTS.CALLS.ACKNOWLEDGE(callId)); + }, + + /** + * Cancel a medical call + */ + async cancelCall(callId: string): Promise { + return apiClient.post(API_ENDPOINTS.CALLS.CANCEL(callId)); + }, + + /** + * Get call statistics + */ + async getCallStats(): Promise { + return apiClient.get(API_ENDPOINTS.STATS.CALLS); + }, +}; + +// Digital Signage Services +export const slideService = { + /** + * Get all slides + */ + async getAllSlides(): Promise { + const response = await apiClient.get<{data: SlideData[], success: boolean}>(API_ENDPOINTS.SLIDES.LIST); + return response.data || []; + }, + + /** + * Create a new slide + */ + async createSlide(slideData: SlideCreateData): Promise { + if (slideData.file) { + // Upload file first, then create slide with file URL + const fileResponse = await fileService.uploadFile(slideData.file); + const { file, ...slideInfo } = slideData; + + // 업로드된 파일의 타입에 따라 media_type 결정 + const mediaType = fileResponse.file_type === 'video' ? 'video' : 'image'; + + const response = await apiClient.post<{data: SlideData, success: boolean}>(API_ENDPOINTS.SLIDES.CREATE, { + ...slideInfo, + image_url: fileResponse.url, + media_type: mediaType, + }); + return response.data; + } else { + const { file, ...slideInfo } = slideData; + const response = await apiClient.post<{data: SlideData, success: boolean}>(API_ENDPOINTS.SLIDES.CREATE, { + ...slideInfo, + image_url: slideData.image_url || '', // Provide default empty string + media_type: slideData.media_type || 'image', // Default media type + }); + return response.data; + } + }, + + /** + * Update an existing slide + */ + async updateSlide(slideId: number, slideData: SlideUpdateData): Promise { + const response = await apiClient.put<{data: SlideData, success: boolean}>(API_ENDPOINTS.SLIDES.UPDATE(slideId), slideData); + return response.data; + }, + + /** + * Delete a slide + */ + async deleteSlide(slideId: number): Promise { + await apiClient.delete<{success: boolean}>(API_ENDPOINTS.SLIDES.DELETE(slideId)); + }, + + /** + * Reorder slides + */ + async reorderSlides(slideIds: number[]): Promise { + return apiClient.post(API_ENDPOINTS.SLIDES.REORDER, { slide_ids: slideIds }); + }, +}; + +// File Management Services +export const fileService = { + /** + * Upload a file + */ + async uploadFile(file: File, additionalData?: Record): Promise { + const response = await apiClient.uploadFile<{data: FileUploadResponse, success: boolean}>(API_ENDPOINTS.FILES.UPLOAD, file, additionalData); + return response.data; + }, + + /** + * Get all files + */ + async getAllFiles(): Promise { + const response = await apiClient.get<{data: FileInfo[], success: boolean}>(API_ENDPOINTS.FILES.LIST); + return response.data || []; + }, + + /** + * Delete a file + */ + async deleteFile(fileId: string): Promise { + return apiClient.delete(API_ENDPOINTS.FILES.DELETE(fileId)); + }, + + /** + * Get file download URL + */ + getDownloadUrl(fileId: string): string { + return `${apiClient.getBaseURL()}${API_ENDPOINTS.FILES.DOWNLOAD(fileId)}`; + }, +}; + +// Statistics Services +export const statsService = { + /** + * Get system overview statistics + */ + async getOverview(): Promise { + return apiClient.get(API_ENDPOINTS.STATS.OVERVIEW); + }, + + /** + * Get timer statistics + */ + async getTimerStats(): Promise { + return timerService.getTimerStats(); + }, + + /** + * Get call statistics + */ + async getCallStats(): Promise { + return callService.getCallStats(); + }, +}; + +// Utility functions for common operations +export const apiUtils = { + /** + * Test connection to the server + */ + async testConnection(): Promise { + try { + await healthService.checkHealth(); + return true; + } catch (error) { + console.error('Connection test failed:', error); + return false; + } + }, + + /** + * Format timer duration for display + */ + formatDuration(seconds: number): string { + const minutes = Math.floor(seconds / 60); + const remainingSeconds = Math.floor(seconds % 60); + return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`; + }, + + /** + * Get timer type display name + */ + getTimerTypeName(timerType: TimerType): string { + return timerType === TimerType.PHYSICAL ? '물리치료' : '레이저치료'; + }, + + /** + * Get timer type duration + */ + getTimerTypeDuration(timerType: TimerType): number { + return timerType === TimerType.PHYSICAL ? 900 : 300; // 15min / 5min + }, + + /** + * Validate tablet ID format + */ + isValidTabletId(tabletId: string): boolean { + return /^T[1-8]$/.test(tabletId); + }, +}; \ No newline at end of file diff --git a/src/api/types.ts b/src/api/types.ts new file mode 100644 index 0000000..34ef26f --- /dev/null +++ b/src/api/types.ts @@ -0,0 +1,318 @@ +/** + * TypeScript type definitions for dev8.py API + * Dual Timer System Types + */ + +// Enums (using const assertions for better TypeScript compatibility) +export const TimerType = { + PHYSICAL: 'physical', + LASER: 'laser' +} as const; + +export const TimerStatus = { + STOPPED: 'stopped', + RUNNING: 'running', + PAUSED: 'paused', + FINISHED: 'finished' +} as const; + +export const CallStatus = { + NONE: 'none', + CALLING: 'calling', + ACKNOWLEDGED: 'acknowledged' +} as const; + +export const ClientType = { + TABLET: 'tablet', + CONTROL: 'control' +} as const; + +// Type aliases for the enum values +export type TimerType = typeof TimerType[keyof typeof TimerType]; +export type TimerStatus = typeof TimerStatus[keyof typeof TimerStatus]; +export type CallStatus = typeof CallStatus[keyof typeof CallStatus]; +export type ClientType = typeof ClientType[keyof typeof ClientType]; + +// Core Timer Interfaces (Dual Timer System) +export interface TimerState { + tablet_id: string; + timer_type: TimerType; + countdown: number; + status: TimerStatus; + start_time?: number; + pause_time?: number; + last_tick?: number; + completion_time?: number; +} + +export interface DualTimerState { + physical: TimerState; + laser: TimerState; +} + +// Medical Call Interfaces +export interface CallState { + tablet_id: string; + status: CallStatus; + call_time?: number; + acknowledged_time?: number; + message?: string; +} + +// Collection States +export interface AllTimersState { + timers: Record; // tablet_id -> dual timer state + timestamp: number; +} + +export interface AllCallsState { + calls: Record; + timestamp: number; +} + +// Connection & Registration +export interface ConnectionInfo { + session_id: string; + server_time: number; + message: string; +} + +export interface RegistrationSuccess { + tablet_id?: string; + session_id: string; + timer_states?: DualTimerState; // For tablet registration + call_state?: CallState; + timers_state?: AllTimersState; // For control registration + calls_state?: AllCallsState; + slides?: SlideData[]; + message: string; +} + +// Timer Events +export interface TimerStateChanged { + tablet_id: string; + timer_type: TimerType; + countdown: number; + status: TimerStatus; + timestamp: number; +} + +export interface TimerTick { + tablet_id: string; + timer_type: TimerType; + countdown: number; + status: TimerStatus; + timestamp: number; +} + +export interface TimerFinished { + tablet_id: string; + timer_type: TimerType; + timestamp: number; +} + +export interface TreatmentCompleted { + tablet_id: string; + timer_type: TimerType; + completion_time: number; + timestamp: number; + status: string; +} + +export interface TimerReset { + tablet_id: string; + timer_type: TimerType; + countdown: number; + status: TimerStatus; + timestamp: number; + reset_source: 'control' | 'tablet'; +} + +// Medical Call Events +export interface MedicalCallCreated { + tablet_id: string; + message: string; + call_time: number; + status: CallStatus; +} + +export interface MedicalCallAcknowledged { + tablet_id: string; + acknowledged_time: number; + status: CallStatus; +} + +export interface MedicalCallCancelled { + tablet_id: string; + cancelled_time: number; + status: CallStatus; +} + +// System Events +export interface ActionSuccess { + action: string; + tablet_id?: string; + timer_type?: TimerType; + stopped_tablets?: string[]; + message?: string; +} + +export interface ErrorResponse { + code: string; + message: string; + details?: any; +} + +// Digital Signage +export interface SlideData { + id: number; + title: string; + subtitle?: string; + image_url: string; + duration: number; + sequence: number; + created_by: string; + updated_by: string; + created_at: string; + updated_at: string; + is_active: boolean; +} + +export interface SlideCreateData { + title: string; + subtitle?: string; + duration: number; + sequence: number; + file?: File; + image_url?: string; // Optional, will be set from uploaded file or provided + media_type?: 'image' | 'video'; // Optional, defaults to 'image' +} + +export interface SlideUpdateData { + title?: string; + subtitle?: string; + duration?: number; + sequence?: number; + is_active?: boolean; +} + +// File Management +export interface FileUploadResponse { + file_id?: string; + uuid?: string; + filename: string; + original_filename?: string; + file_url?: string; + url: string; + size: number; + file_size?: number; + mime_type: string; + file_type?: 'image' | 'video'; + created_at: string; + uploaded_at?: number; + thumbnail_url?: string; +} + +export interface FileInfo { + id: string; + filename: string; + original_name: string; + size: number; + mime_type: string; + url: string; + created_at: string; + created_by: string; +} + +// API Request/Response Types +export interface TimerControlRequest { + tablet_id: string; + timer_type: TimerType; +} + +export interface MedicalCallRequest { + tablet_id: string; + message?: string; +} + +export interface TabletRegistrationRequest { + tablet_id: string; +} + +// Statistics & Monitoring +export interface TimerStats { + total_sessions: number; + active_timers: number; + completed_treatments: number; + average_duration: number; + by_type: { + physical: { + active: number; + completed: number; + total_time: number; + }; + laser: { + active: number; + completed: number; + total_time: number; + }; + }; +} + +export interface CallStats { + total_calls: number; + active_calls: number; + acknowledged_calls: number; + average_response_time: number; +} + +export interface SystemStats { + server_uptime: number; + connected_clients: number; + connected_tablets: number; + connected_controls: number; + timer_stats: TimerStats; + call_stats: CallStats; +} + +// Health Check Response +export interface HealthCheckResponse { + status: 'healthy' | 'degraded' | 'unhealthy'; + timestamp: number; + version: string; + server_time: number; + uptime: number; + database_status: 'connected' | 'disconnected'; + active_connections: number; +} + +// Socket Event Listeners Interface +export interface SocketEventListeners { + // Connection events + onConnect?: () => void; + onDisconnect?: (reason: string) => void; + onConnectError?: (error: Error) => void; + onConnectionEstablished?: (data: ConnectionInfo) => void; + onRegistrationSuccess?: (data: RegistrationSuccess) => void; + + // Timer events + onTimerStateChanged?: (data: TimerStateChanged) => void; + onTimerTick?: (data: TimerTick) => void; + onTimerFinished?: (data: TimerFinished) => void; + onTreatmentCompleted?: (data: TreatmentCompleted) => void; + onTimerReset?: (data: TimerReset) => void; + onAllTimersState?: (data: AllTimersState) => void; + + // Medical call events + onMedicalCallCreated?: (data: MedicalCallCreated) => void; + onMedicalCallAcknowledged?: (data: MedicalCallAcknowledged) => void; + onMedicalCallCancelled?: (data: MedicalCallCancelled) => void; + onAllCallsState?: (data: AllCallsState) => void; + + // System events + onActionSuccess?: (data: ActionSuccess) => void; + onError?: (data: ErrorResponse) => void; + onReconnect?: (attemptNumber: number) => void; + onReconnectError?: (error: Error) => void; + onReconnectFailed?: () => void; +} \ No newline at end of file diff --git a/src/components/NewControlScreen.tsx b/src/components/NewControlScreen.tsx index f2a9b94..cd71a2d 100644 --- a/src/components/NewControlScreen.tsx +++ b/src/components/NewControlScreen.tsx @@ -3,13 +3,15 @@ import { Button } from "./ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "./ui/card"; import { Badge } from "./ui/badge"; import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible"; -import { ArrowLeft, Play, Pause, Square, Timer, Wifi, WifiOff, Activity, ChevronDown, ChevronUp, AlertTriangle, CheckCircle, Phone, PhoneCall, PhoneOff, Bell, BellRing, Users, Monitor } from "lucide-react"; +import { ArrowLeft, Play, Pause, Square, Timer, Wifi, WifiOff, Activity, ChevronDown, ChevronUp, AlertTriangle, CheckCircle, CheckCircle2, Phone, PhoneCall, PhoneOff, Bell, BellRing, Users, Monitor, Clock, RotateCcw } from "lucide-react"; import { RealtimeSocketManager, TimerStatus, CallStatus, + TimerType, type SocketEventListeners, - type TimerState, + type DualTimerState, + type SingleTimerState, type CallState, type AllTimersState, type AllCallsState, @@ -35,9 +37,327 @@ interface SystemLog { type: 'info' | 'success' | 'error' | 'warning'; } +// 태블릿 카드 컴포넌트 +interface TabletCardProps { + tabletId: string; + dualTimerState: DualTimerState; + callState?: CallState; + onTimerAction: (action: string, tabletId: string, timerType: TimerType) => void; + onCallAction: (action: string, tabletId: string) => void; + isConnected: boolean; + isLoading: boolean; +} + +function TabletCard({ tabletId, dualTimerState, callState, onTimerAction, onCallAction, isConnected, isLoading }: TabletCardProps) { + const formatTime = (seconds: number): string => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; + }; + + const getTimerStatusColor = (status: TimerStatus, countdown: number) => { + if (status === TimerStatus.FINISHED) return 'text-blue-400'; + if (status === TimerStatus.RUNNING) { + if (countdown <= 10) return 'text-red-400 animate-pulse'; + return 'text-green-400'; + } + if (status === TimerStatus.PAUSED) return 'text-amber-400'; + return 'text-gray-300'; + }; + + const getTimerStatusText = (status: TimerStatus) => { + switch (status) { + case TimerStatus.FINISHED: return '치료완료'; + case TimerStatus.RUNNING: return '작동 중'; + case TimerStatus.PAUSED: return '일시정지'; + case TimerStatus.STOPPED: return '정지'; + default: return '대기'; + } + }; + + const getTimerStatusBadgeColor = (status: TimerStatus) => { + switch (status) { + case TimerStatus.FINISHED: return 'bg-blue-500/20 text-blue-300 border-blue-500/30 animate-pulse'; + case TimerStatus.RUNNING: return 'bg-green-500/20 text-green-300 border-green-500/30'; + case TimerStatus.PAUSED: return 'bg-amber-500/20 text-amber-300 border-amber-500/30'; + default: return 'bg-gray-500/20 text-gray-400 border-gray-500/30'; + } + }; + + const getTimerBackgroundGlow = (status: TimerStatus) => { + switch (status) { + case TimerStatus.FINISHED: + return 'bg-gradient-to-br from-blue-500/15 to-cyan-600/15 border-blue-400/40 shadow-lg shadow-blue-500/20 animate-pulse'; + case TimerStatus.RUNNING: + return 'bg-gradient-to-br from-green-500/5 to-green-600/5 border-green-500/20'; + case TimerStatus.PAUSED: + return 'bg-gradient-to-br from-amber-500/5 to-amber-600/5 border-amber-500/20'; + default: + return 'bg-gray-700/30 border-gray-600/30'; + } + }; + + const TimerControls = ({ timer, timerType }: { timer: SingleTimerState, timerType: TimerType }) => { + const canOperate = isConnected && !isLoading; + + // 공통 버튼 스타일 (전환 효과 완전 제거) + const buttonStyle = { + position: 'relative' as const, + zIndex: 1000, + pointerEvents: 'auto' as const, + transition: 'none', + outline: 'none', + border: 'none', + boxShadow: 'none', + transform: 'none', + animation: 'none' + }; + + // 공통 이벤트 핸들러 (더 빠른 반응을 위해 onMouseDown 사용) + const createButtonHandler = (action: string) => ({ + onMouseDown: (e: React.MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (canOperate) { + onTimerAction(action, tabletId, timerType); + } + }, + onTouchStart: (e: React.TouchEvent) => { + e.stopPropagation(); + e.preventDefault(); + if (canOperate) { + onTimerAction(action, tabletId, timerType); + } + } + }); + + if (timer.status === TimerStatus.FINISHED) { + return ( +
+ +
+ ); + } + + if (timer.status === TimerStatus.STOPPED) { + return ( +
+ +
+ ); + } + + if (timer.status === TimerStatus.RUNNING) { + return ( +
+ + +
+ ); + } + + if (timer.status === TimerStatus.PAUSED) { + return ( +
+ + +
+ ); + } + + return null; + }; + + const hasTreatmentCompleted = dualTimerState.physical.status === TimerStatus.FINISHED || + dualTimerState.laser.status === TimerStatus.FINISHED; + + const hasActiveCall = callState && callState.status !== CallStatus.NONE; + + return ( + + {/* 배경 그라데이션 오버레이 */} +
+ + {/* 치료완료 시 특별한 글로우 효과 */} + {hasTreatmentCompleted && ( +
+ )} + + {/* 호출 상태 버튼 */} + {hasActiveCall && ( +
{ + console.log('🎯 호출 버튼 포인터 다운!', { tabletId, callState: callState.status }); + e.stopPropagation(); + e.preventDefault(); + if (callState.status === CallStatus.CALLING) { + onCallAction('acknowledge', tabletId); + } else { + onCallAction('cancel', tabletId); + } + }} + style={{ + userSelect: 'none', + touchAction: 'manipulation', + WebkitTouchCallout: 'none', + WebkitUserSelect: 'none', + pointerEvents: 'auto' + }} + > + {callState.status === CallStatus.CALLING ? ( + <> + + 호출확인 + + ) : ( + <> + + 호출취소 + + )} +
+ )} + +
+ {/* 태블릿 이름 */} +
+

{tabletId}

+
+
+ + {/* 물리치료 타이머 (15분) */} +
+
+
+ + 물리치료 15분 + {dualTimerState.physical.status === TimerStatus.FINISHED && ( + + )} +
+ + {getTimerStatusText(dualTimerState.physical.status)} + +
+ +
+ {formatTime(dualTimerState.physical.countdown)} +
+ + +
+ + {/* 레이저치료 타이머 (5분) */} +
+
+
+ + 레이저치료 5분 + {dualTimerState.laser.status === TimerStatus.FINISHED && ( + + )} +
+ + {getTimerStatusText(dualTimerState.laser.status)} + +
+ +
+ {formatTime(dualTimerState.laser.countdown)} +
+ + +
+
+ + {/* 치료완료 축하 효과 */} + {hasTreatmentCompleted && ( +
+
+ ✨ +
+
+ 🎉 +
+
+ ⭐ +
+
+ )} +
+ ); +} + export function NewControlScreen({ onBack }: NewControlScreenProps) { // ======================== 상태 관리 ======================== - const [timers, setTimers] = useState>({}); + const [timers, setTimers] = useState>({}); const [calls, setCalls] = useState>({}); // 연결 및 시스템 상태 @@ -73,53 +393,110 @@ export function NewControlScreen({ onBack }: NewControlScreenProps) { console.log(`🎛️ CONTROL [${type.toUpperCase()}]: ${message}`); }; - // ======================== 의료진 호출 관리 ======================== - const handleAcknowledgeCall = (tabletId: string) => { - if (!isConnected || !isRegistered) { - addLog('호출 인지 실패: 연결 또는 등록 상태 확인 필요', 'error'); + // ======================== 타이머 제어 ======================== + const handleTimerAction = (action: string, tabletId: string, timerType: TimerType) => { + console.log('🎛️ CONTROL - 타이머 액션 요청:', { action, tabletId, timerType, isConnected, isRegistered }); + + if (!isConnected) { + addLog(`${action} 실패: 서버에 연결되지 않음`, 'error'); return; } - - const callState = calls[tabletId]; - if (!callState || callState.status !== CallStatus.CALLING) { - addLog('인지할 호출이 없습니다', 'warning'); + + if (!isRegistered) { + addLog(`${action} 실패: 관제센터가 등록되지 않음`, 'error'); return; } setIsLoading(true); - addLog(`의료진 호출 인지: ${tabletId}`, 'info'); + const timerTypeName = timerType === TimerType.PHYSICAL ? '물리치료' : '레이저치료'; - const success = socketManager.current.acknowledgeMedicalCall(tabletId); - if (!success) { - addLog('호출 인지 요청 전송 실패', 'error'); - setIsLoading(false); + const timerTypeString = timerType === TimerType.PHYSICAL ? 'physical' : 'laser'; + + let success = false; + switch (action) { + case 'start': + addLog(`${timerTypeName} 타이머 시작: ${tabletId}`, 'info'); + success = socketManager.current.startTimer(tabletId, timerTypeString); + break; + case 'pause': + addLog(`${timerTypeName} 타이머 일시정지: ${tabletId}`, 'info'); + success = socketManager.current.pauseTimer(tabletId, timerTypeString); + break; + case 'resume': + addLog(`${timerTypeName} 타이머 재개: ${tabletId}`, 'info'); + success = socketManager.current.resumeTimer(tabletId, timerTypeString); + break; + case 'stop': + addLog(`${timerTypeName} 타이머 정지: ${tabletId}`, 'info'); + success = socketManager.current.stopTimer(tabletId, timerTypeString); + break; + case 'reset': + addLog(`${timerTypeName} 타이머 리셋: ${tabletId}`, 'info'); + success = socketManager.current.resetTimer(tabletId, 'control', timerTypeString); + break; } - - setTimeout(() => setIsLoading(false), 3000); + + if (!success) { + addLog(`${action} 요청 전송 실패`, 'error'); + } + + // 로딩 상태를 1초로 단축하고 실패시에도 동일하게 처리 + setTimeout(() => setIsLoading(false), 1000); }; - const handleCancelCall = (tabletId: string) => { + // ======================== 호출 관리 ======================== + const handleCallAction = (action: string, tabletId: string) => { + console.log('🎛️ CONTROL - 호출 액션 요청:', { action, tabletId, isConnected, isRegistered }); + if (!isConnected || !isRegistered) { - addLog('호출 취소 실패: 연결 또는 등록 상태 확인 필요', 'error'); - return; - } - - const callState = calls[tabletId]; - if (!callState || callState.status === CallStatus.NONE) { - addLog('취소할 호출이 없습니다', 'warning'); + addLog(`${action} 실패: 연결 또는 등록 상태 확인 필요`, 'error'); return; } setIsLoading(true); - addLog(`의료진 호출 취소: ${tabletId}`, 'info'); - const success = socketManager.current.cancelMedicalCall(tabletId); + let success = false; + switch (action) { + case 'create': + addLog(`의료진 호출 생성 요청: ${tabletId}`, 'info'); + success = socketManager.current.createMedicalCall(tabletId, '관제센터에서 요청한 호출'); + console.log('🎛️ CONTROL - create 요청 결과:', success); + break; + case 'acknowledge': + addLog(`의료진 호출 인지 요청: ${tabletId}`, 'info'); + success = socketManager.current.acknowledgeMedicalCall(tabletId); + console.log('🎛️ CONTROL - acknowledge 요청 결과:', success); + break; + case 'cancel': + addLog(`의료진 호출 취소 요청: ${tabletId}`, 'info'); + success = socketManager.current.cancelMedicalCall(tabletId); + console.log('🎛️ CONTROL - cancel 요청 결과:', success); + break; + } + if (!success) { - addLog('호출 취소 요청 전송 실패', 'error'); - setIsLoading(false); + addLog(`${action} 요청 전송 실패`, 'error'); + } + + setTimeout(() => setIsLoading(false), 1000); + }; + + // ======================== 전체 제어 ======================== + const handleStopAllTimers = () => { + if (!isConnected || !isRegistered) { + addLog('모든 타이머 정지 실패: 연결 또는 등록 상태 확인 필요', 'error'); + return; + } + + setIsLoading(true); + addLog('모든 타이머 정지 요청', 'warning'); + + const success = socketManager.current.stopAllTimers(); + if (!success) { + addLog('모든 타이머 정지 요청 전송 실패', 'error'); } - setTimeout(() => setIsLoading(false), 3000); + setTimeout(() => setIsLoading(false), 1000); }; // ======================== Socket 이벤트 핸들러 ======================== @@ -133,6 +510,8 @@ export function NewControlScreen({ onBack }: NewControlScreenProps) { addLog(`서버 연결 끊김: ${reason}`, 'error'); setIsConnected(false); setIsRegistered(false); + setTimers({}); + setCalls({}); }, onConnectError: (error) => { @@ -147,142 +526,170 @@ export function NewControlScreen({ onBack }: NewControlScreenProps) { }, onRegistrationSuccess: (data: RegistrationSuccess) => { - addLog('관제 등록 완료', 'success'); + addLog(`관제센터 등록 완료`, 'success'); setIsRegistered(true); - // 서버에서 받은 모든 타이머 상태로 초기화 - if (data.timers_state) { - setTimers(data.timers_state.timers); - addLog(`타이머 상태 동기화: ${Object.keys(data.timers_state.timers).length}개`, 'info'); - } - - // 서버에서 받은 모든 호출 상태로 초기화 - if (data.calls_state) { - setCalls(data.calls_state.calls); - addLog(`호출 상태 동기화: ${Object.keys(data.calls_state.calls).length}개`, 'info'); - } + // 초기 상태 요청 + setTimeout(() => { + addLog('초기 상태 동기화 요청', 'info'); + socketManager.current.getAllTimersState(); + socketManager.current.getAllCallsState(); + }, 1000); }, onTimerStateChanged: (data: TimerStateChanged) => { - addLog(`타이머 상태 변경: ${data.tablet_id} - ${data.status}`, 'info'); - setTimers(prev => ({ - ...prev, - [data.tablet_id]: { - tablet_id: data.tablet_id, - countdown: data.countdown, - status: data.status as TimerStatus - } - })); + const timerTypeName = data.timer_type === TimerType.PHYSICAL ? '물리치료' : '레이저치료'; + addLog(`${data.tablet_id} ${timerTypeName} 상태 변경: ${data.status}`, 'success'); + + // 로컬 상태 업데이트 + setTimers(prev => { + if (!prev[data.tablet_id]) return prev; + + return { + ...prev, + [data.tablet_id]: { + ...prev[data.tablet_id], + [data.timer_type]: { + ...prev[data.tablet_id][data.timer_type], + status: data.status, + countdown: data.countdown || prev[data.tablet_id][data.timer_type].countdown + } + } + }; + }); }, onTimerTick: (data: TimerTick) => { - setTimers(prev => ({ - ...prev, - [data.tablet_id]: { - ...prev[data.tablet_id], - countdown: data.countdown, - status: data.status as TimerStatus - } - })); + // 로컬 상태 업데이트 (틱은 로그하지 않음) + setTimers(prev => { + if (!prev[data.tablet_id]) return prev; + + return { + ...prev, + [data.tablet_id]: { + ...prev[data.tablet_id], + [data.timer_type]: { + ...prev[data.tablet_id][data.timer_type], + countdown: data.countdown, + status: data.status + } + } + }; + }); }, onAllTimersState: (data: AllTimersState) => { + console.log('🎛️ CONTROL - 받은 타이머 데이터:', data); setTimers(data.timers); - addLog('모든 타이머 상태 동기화 완료', 'info'); + const count = Object.keys(data.timers).length; + addLog(`타이머 상태 동기화: ${count}개 태블릿`, 'info'); + + // 각 타이머 상태 로그 + Object.entries(data.timers).forEach(([tabletId, timerState]) => { + addLog(`${tabletId}: 물리(${timerState.physical.status}) 레이저(${timerState.laser.status})`, 'info'); + }); }, onAllCallsState: (data: AllCallsState) => { setCalls(data.calls); - addLog('모든 호출 상태 동기화 완료', 'info'); - }, - - onMedicalCallCreated: (data: MedicalCallCreated) => { - addLog(`📞 의료진 호출 발생: ${data.tablet_id} - ${data.message}`, 'warning'); - setCalls(prev => ({ - ...prev, - [data.tablet_id]: { - tablet_id: data.tablet_id, - status: CallStatus.CALLING, - call_time: data.call_time, - message: data.message - } - })); - - // 브라우저 알림 (권한이 있는 경우) - if ('Notification' in window && Notification.permission === 'granted') { - new Notification(`의료진 호출: ${data.tablet_id}`, { - body: data.message, - icon: '/favicon.ico' - }); + const activeCount = Object.values(data.calls).filter(c => c.status !== CallStatus.NONE).length; + if (activeCount > 0) { + addLog(`활성 호출: ${activeCount}개`, 'warning'); } }, + onMedicalCallCreated: (data: MedicalCallCreated) => { + addLog(`📞 의료진 호출 생성: ${data.tablet_id} - ${data.message}`, 'warning'); + + // 로컬 호출 상태 업데이트 + setCalls(prev => ({ + ...prev, + [data.tablet_id]: { + status: CallStatus.CALLING, + message: data.message, + created_at: data.created_at, + updated_at: data.created_at, + is_active: true + } + })); + }, + onMedicalCallAcknowledged: (data: MedicalCallAcknowledged) => { - addLog(`✅ 호출 인지: ${data.tablet_id}`, 'success'); + addLog(`✅ 의료진 호출 인지됨: ${data.tablet_id}`, 'success'); + + // 로컬 호출 상태 업데이트 setCalls(prev => ({ ...prev, [data.tablet_id]: { ...prev[data.tablet_id], - tablet_id: data.tablet_id, - status: CallStatus.ACKNOWLEDGED, - acknowledged_time: data.acknowledged_time + status: CallStatus.ACKNOWLEDGED } })); }, onMedicalCallCancelled: (data: MedicalCallCancelled) => { - addLog(`❌ 호출 취소: ${data.tablet_id}`, 'info'); + addLog(`❌ 의료진 호출 취소됨: ${data.tablet_id}`, 'info'); + + // 로컬 호출 상태 업데이트 setCalls(prev => ({ ...prev, [data.tablet_id]: { - tablet_id: data.tablet_id, - status: CallStatus.NONE, - call_time: undefined, - acknowledged_time: undefined, - message: undefined + ...prev[data.tablet_id], + status: CallStatus.NONE } })); }, onTimerFinished: (data) => { - addLog(`🏁 타이머 완료: ${data.tablet_id}`, 'success'); - }, - - // ✨ 새로운 치료완료 이벤트 처리 - onTreatmentCompleted: (data) => { - addLog(`🎉 치료 완료: ${data.tablet_id}`, 'success'); - setTimers(prev => ({ - ...prev, - [data.tablet_id]: { - ...prev[data.tablet_id], - status: TimerStatus.FINISHED, - countdown: 0, - completion_time: data.completion_time - } - })); + const timerTypeName = data.timer_type === TimerType.PHYSICAL ? '물리치료' : '레이저치료'; + addLog(`🏁 ${data.tablet_id} ${timerTypeName} 타이머 완료!`, 'success'); - // ✨ 브라우저 알림 - if ('Notification' in window && Notification.permission === 'granted') { - new Notification('치료 완료', { - body: `${data.tablet_id}에서 물리치료가 완료되었습니다.`, - icon: '/favicon.ico' - }); - } + // 로컬 상태 업데이트 + setTimers(prev => { + if (!prev[data.tablet_id]) return prev; + + return { + ...prev, + [data.tablet_id]: { + ...prev[data.tablet_id], + [data.timer_type]: { + ...prev[data.tablet_id][data.timer_type], + status: TimerStatus.FINISHED, + countdown: 0 + } + } + }; + }); + }, + + onTreatmentCompleted: (data) => { + const timerTypeName = data.timer_type === TimerType.PHYSICAL ? '물리치료' : '레이저치료'; + addLog(`🎉 ${data.tablet_id} ${timerTypeName} 치료 완료!`, 'success'); }, - // ✨ 새로운 타이머 리셋 이벤트 처리 onTimerReset: (data) => { const source = data.reset_source === 'tablet' ? '태블릿' : '관제센터'; - addLog(`🔄 타이머 리셋: ${data.tablet_id} (${source})`, 'info'); - setTimers(prev => ({ - ...prev, - [data.tablet_id]: { - ...prev[data.tablet_id], - status: TimerStatus.STOPPED, - countdown: data.countdown, - completion_time: undefined - } - })); + const timerTypeName = data.timer_type === TimerType.PHYSICAL ? '물리치료' : '레이저치료'; + addLog(`🔄 ${data.tablet_id} ${timerTypeName} 리셋됨 (${source})`, 'success'); + + // 로컬 상태 업데이트 + setTimers(prev => { + if (!prev[data.tablet_id]) return prev; + + const initialDuration = data.timer_type === TimerType.PHYSICAL ? 900 : 300; + + return { + ...prev, + [data.tablet_id]: { + ...prev[data.tablet_id], + [data.timer_type]: { + ...prev[data.tablet_id][data.timer_type], + status: TimerStatus.STOPPED, + countdown: data.countdown || initialDuration + } + } + }; + }); }, onActionSuccess: (data: ActionSuccess) => { @@ -295,31 +702,45 @@ export function NewControlScreen({ onBack }: NewControlScreenProps) { setIsLoading(false); }, - onReconnect: () => { - addLog('재연결 성공!', 'success'); + onReconnect: (attemptNumber) => { + addLog(`재연결 성공! (${attemptNumber}번째 시도)`, 'success'); + + // 재연결 후 다시 등록 setTimeout(() => { socketManager.current.registerControl(); }, 1000); + }, + + onReconnectError: (error) => { + addLog(`재연결 실패: ${error.message}`, 'error'); + }, + + onReconnectFailed: () => { + addLog('재연결 완전 실패 💀', 'error'); } }; // ======================== Socket 초기화 ======================== useEffect(() => { - addLog('관제 시스템 초기화', 'info'); + addLog('관제센터 시스템 초기화', 'info'); - socketManager.current.connect(socketEventListeners); + // Socket 이벤트 리스너 등록 + socketManager.current.initialize(socketEventListeners); - // 관제 등록 + // 초기 연결 + socketManager.current.connect(); + + // Control로 등록 setTimeout(() => { if (socketManager.current.isConnected()) { - socketManager.current.registerControl(); + const success = socketManager.current.registerControl(); + if (success) { + addLog('관제센터 등록 요청 전송', 'info'); + } else { + addLog('관제센터 등록 실패 - 연결 확인 필요', 'error'); + } } }, 1000); - - // 브라우저 알림 권한 요청 - if ('Notification' in window && Notification.permission === 'default') { - Notification.requestPermission(); - } return () => { addLog('컴포넌트 언마운트 - Socket 정리', 'info'); @@ -327,72 +748,14 @@ export function NewControlScreen({ onBack }: NewControlScreenProps) { }; }, []); - // ======================== 주기적 상태 동기화 ======================== - useEffect(() => { - if (isConnected && isRegistered) { - const syncInterval = setInterval(() => { - socketManager.current.getAllTimersState(); - socketManager.current.getAllCallsState(); - }, 10000); // 10초마다 동기화 - - return () => clearInterval(syncInterval); - } - }, [isConnected, isRegistered]); - - // ======================== 타이머 제어 ======================== - const handleTimerAction = (action: string, tabletId: string, actionFn: () => boolean) => { - if (!isConnected || !isRegistered) { - addLog(`${action} 실패: 연결 또는 등록 상태 확인 필요`, 'error'); - return; - } - - setIsLoading(true); - addLog(`${action} 요청: ${tabletId}`, 'info'); - - const success = actionFn(); - if (!success) { - addLog(`${action} 요청 전송 실패`, 'error'); - setIsLoading(false); - } - - setTimeout(() => setIsLoading(false), 3000); - }; - - // ✨ 새로운 기능: 타이머 리셋 - const handleResetTimer = (tabletId: string) => { - handleTimerAction('타이머 리셋', tabletId, () => - socketManager.current.resetTimer(tabletId, 'control') - ); - }; - // ======================== UI 헬퍼 함수 ======================== - const formatTime = (seconds: number): string => { - const mins = Math.floor(seconds / 60); - const secs = Math.floor(seconds % 60); - const ms = Math.floor((seconds % 1) * 10); - return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms}`; - }; - - const getTimerColor = (timer: TimerState): string => { - if (timer.countdown <= 10 && timer.status === TimerStatus.RUNNING) { - return 'text-red-500 animate-pulse'; + const getLogIcon = (type: SystemLog['type']): string => { + switch (type) { + case 'success': return '✅'; + case 'error': return '❌'; + case 'warning': return '⚠️'; + default: return 'ℹ️'; } - if (timer.status === TimerStatus.RUNNING) return 'text-green-500'; - if (timer.status === TimerStatus.PAUSED) return 'text-yellow-500'; - if (timer.status === TimerStatus.FINISHED) return 'text-blue-500 font-bold'; // ✨ 치료완료 색상 - return 'text-gray-500'; - }; - - const getCallStatusColor = (status: CallStatus): string => { - switch (status) { - case CallStatus.CALLING: return 'text-red-500 animate-pulse'; - case CallStatus.ACKNOWLEDGED: return 'text-green-500'; - default: return 'text-gray-500'; - } - }; - - const getActiveCalls = () => { - return Object.values(calls).filter(call => call.status === CallStatus.CALLING); }; const getLogColor = (type: SystemLog['type']): string => { @@ -404,338 +767,162 @@ export function NewControlScreen({ onBack }: NewControlScreenProps) { } }; + const activeCalls = Object.values(calls).filter(c => c.status !== CallStatus.NONE).length; + const totalTablets = Object.keys(timers).length; + const activeTimers = Object.values(timers).reduce((count, timer) => { + if (timer.physical.status === TimerStatus.RUNNING) count++; + if (timer.laser.status === TimerStatus.RUNNING) count++; + return count; + }, 0); + const canOperate = isConnected && isRegistered && !isLoading; - const activeCalls = getActiveCalls(); // ======================== 렌더링 ======================== return ( -
-
- - {/* 헤더 */} -
+
+ {/* 헤더 */} +
+
-

- - 실시간 관제 센터 -

+

듀얼 타이머 관제 센터

-
- - {isConnected ? : } - {isConnected ? '연결됨' : '연결 안됨'} - - - {activeCalls.length > 0 && ( - - - 긴급 호출 {activeCalls.length}건 + {/* 연결 상태 */} +
+ {/* 상태 뱃지들 */} +
+ {activeCalls > 0 && ( + + + 호출 {activeCalls} + + )} + + + 태블릿 {totalTablets} - )} + + + 활성 {activeTimers} + +
+ + {/* Socket 연결 상태 */} +
+ {isConnected ? ( + <> + + 연결됨 + + ) : ( + <> + + 연결 끊김 + + )} +
+
- {/* 의료진 호출 현황 */} - {activeCalls.length > 0 && ( - - - - - 긴급 의료진 호출 - - - -
- {activeCalls.map(call => ( -
-
- -
-
{call.tablet_id}
-
{call.message}
-
- {call.call_time && new Date(call.call_time * 1000).toLocaleTimeString()} -
-
-
-
- - -
-
- ))} + {/* 메인 컨텐츠 */} +
+ {/* 전체 제어 패널 */} + + +
+
+ + 전체 제어 +
+
+
- - - )} - - {/* 태블릿 상태 그리드 */} -
- {Object.entries(timers) - .sort(([tabletIdA], [tabletIdB]) => { - // ✨ 태블릿 ID 순서로 고정 정렬 (위치 변경 방지) - return tabletIdA.localeCompare(tabletIdB); - }) - .map(([tabletId, timer]) => { - const call = calls[tabletId]; - return ( - - -
- {tabletId} -
- {/* ✨ 치료완료 배지 */} - {timer.status === TimerStatus.FINISHED && ( - - 🎉 치료완료 - - )} - {call && call.status !== CallStatus.NONE && ( - - {call.status === CallStatus.CALLING ? : } - {call.status === CallStatus.CALLING ? '호출 중' : '확인됨'} - - )} -
-
-
- -
-
- {formatTime(timer.countdown)} -
-
- {timer.status === TimerStatus.RUNNING ? '실행 중' : - timer.status === TimerStatus.PAUSED ? '일시정지' : - timer.status === TimerStatus.FINISHED ? '치료완료' : '정지'} -
- {/* ✨ 치료 완료 시간 표시 */} - {timer.status === TimerStatus.FINISHED && timer.completion_time && ( -
- 완료: {new Date(timer.completion_time * 1000).toLocaleTimeString()} -
- )} -
- -
- {timer.status === TimerStatus.FINISHED ? ( - // ✨ 치료완료 상태 - 리셋 버튼만 표시 - - ) : ( - // 기존 제어 버튼들 - <> - {timer.status === TimerStatus.STOPPED && ( - - )} - - {timer.status === TimerStatus.RUNNING && ( - <> - - - - )} - - {timer.status === TimerStatus.PAUSED && ( - <> - - - - )} - - )} -
- - {/* 호출 관리 버튼 */} - {call && call.status !== CallStatus.NONE && ( -
- {call.status === CallStatus.CALLING && ( - - )} - -
- )} -
-
- ); - })} -
- - {/* 전체 제어 */} - - - - - 전체 제어 - - - -
- - - - -
+ {/* 태블릿 그리드 */} +
+ {Object.entries(timers).map(([tabletId, timerState]) => ( + + ))} +
+ + {/* 빈 상태 */} + {totalTablets === 0 && isConnected && ( +
+ +

연결된 태블릿이 없습니다

+

태블릿이 연결되면 여기에 표시됩니다

+
+ )} + {/* 시스템 로그 */} - - - - - - -
+ + + + + + 시스템 로그 + +
+ {showLogs ? : } +
+
+
+ + +
{systemLogs.length === 0 ? ( -
- 로그가 없습니다... +
+ 시스템 로그가 없습니다...
) : ( - systemLogs.slice(-20).reverse().map((log) => ( -
- [{log.timestamp}] - {log.message} -
- )) +
+ {systemLogs.map((log) => ( +
+ [{log.timestamp}] + {getLogIcon(log.type)} + {log.message} +
+ ))} +
)}
- - + + - - {/* 연결 상태 */} - {!canOperate && ( -
-
- - {!isConnected ? "Socket 서버에 연결되지 않았습니다" : - !isRegistered ? "관제 등록을 기다리는 중..." : - isLoading ? "요청 처리 중..." : "알 수 없는 상태"} -
-
- )}
); diff --git a/src/components/SignageTabletScreen.tsx b/src/components/SignageTabletScreen.tsx index a6e97ca..af8e36f 100644 --- a/src/components/SignageTabletScreen.tsx +++ b/src/components/SignageTabletScreen.tsx @@ -25,6 +25,8 @@ import { type MedicalCallAcknowledged, type MedicalCallCancelled } from "../utils/realtime-socket"; +import { TimerType, type DualTimerState } from '../api/types'; +import { timerService, slideService, apiClient } from '../api'; interface SignageTabletScreenProps { onBack: () => void; @@ -58,10 +60,20 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { // ======================== 상태 관리 ======================== const [tabletId, setTabletId] = useState(''); const [availableTablets, setAvailableTablets] = useState([]); // 동적 태블릿 목록 - const [timerState, setTimerState] = useState({ - tablet_id: '', - countdown: 30, - status: TimerStatus.STOPPED + // ✨ 듀얼 타이머 상태 관리 + const [dualTimerState, setDualTimerState] = useState({ + physical: { + tablet_id: '', + timer_type: TimerType.PHYSICAL, + countdown: 900, // 15분 + status: TimerStatus.STOPPED + }, + laser: { + tablet_id: '', + timer_type: TimerType.LASER, + countdown: 300, // 5분 + status: TimerStatus.STOPPED + } }); const [callState, setCallState] = useState({ @@ -167,23 +179,38 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { // ✨ 치료완료 리셋 핸들러 (태블릿에서 터치로 리셋) const handleTreatmentCompletedReset = () => { - if (timerState.status !== TimerStatus.FINISHED) return; + const physicalFinished = dualTimerState.physical.status === TimerStatus.FINISHED; + const laserFinished = dualTimerState.laser.status === TimerStatus.FINISHED; + + if (!physicalFinished && !laserFinished) return; console.log('🔄 태블릿에서 치료완료 리셋 요청'); addLog('치료완료 상태를 대기로 변경합니다', 'info'); - const success = socketManager.current.resetTimer(currentTabletId.current, 'tablet'); - if (success) { - addLog('대기 상태로 변경됨', 'success'); - } else { - addLog('상태 변경 실패', 'error'); + // 물리치료가 완료된 경우 리셋 + if (physicalFinished) { + const success = socketManager.current.resetTimer(currentTabletId.current, 'tablet', 'physical'); + if (success) { + addLog('물리치료 대기 상태로 변경됨', 'success'); + } + } + + // 레이저치료가 완료된 경우 리셋 + if (laserFinished) { + const success = socketManager.current.resetTimer(currentTabletId.current, 'tablet', 'laser'); + if (success) { + addLog('레이저치료 대기 상태로 변경됨', 'success'); + } } }; - // ======================== 통합 터치 핸들러 ======================== + // ======================== 통합 터치 핸들러 (듀얼 타이머 지원) ======================== const handleCallToggle = () => { // ✨ 치료완료 상태일 때는 리셋 처리 - if (timerState.status === TimerStatus.FINISHED) { + const physicalFinished = dualTimerState.physical.status === TimerStatus.FINISHED; + const laserFinished = dualTimerState.laser.status === TimerStatus.FINISHED; + + if (physicalFinished || laserFinished) { console.log('🎉 치료완료 상태 - 리셋 요청'); handleTreatmentCompletedReset(); return; @@ -302,11 +329,10 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { // 백엔드에서 태블릿 목록 가져오기 const loadAvailableTablets = async () => { try { - const response = await fetch('https://api2.ysleadersos.com/api/timers'); - const data = await response.json(); + const { timers } = await timerService.getAllTimers(); - if (data.success && data.data && data.data.timers) { - const tabletIds = Object.keys(data.data.timers); + if (timers) { + const tabletIds = Object.keys(timers); setAvailableTablets(tabletIds); addLog(`태블릿 목록 로드 완료: ${tabletIds.length}개`, 'success'); return tabletIds; @@ -328,15 +354,14 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { const loadSlides = async () => { try { - const response = await fetch('https://api2.ysleadersos.com/api/slides'); - const data = await response.json(); + const slidesData = await slideService.getAllSlides(); - if (data.success) { - setSlides(data.data); - addLog(`슬라이드 로드 완료: ${data.data.length}개`, 'success'); + if (slidesData) { + setSlides(slidesData); + addLog(`슬라이드 로드 완료: ${slidesData.length}개`, 'success'); // 슬라이드가 로드되면 자동 시작 - if (data.data.length > 0) { + if (slidesData.length > 0) { setCurrentSlideIndex(0); // 첫 번째 슬라이드로 리셋 if (!isSignagePlaying) { setTimeout(() => { @@ -381,10 +406,10 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { addLog(`태블릿 등록 완료: ${data.tablet_id}`, 'success'); setIsRegistered(true); - // 서버에서 받은 타이머 상태로 초기화 - if (data.timer_state) { - setTimerState(data.timer_state); - addLog(`타이머 상태 동기화: ${data.timer_state.status} (${data.timer_state.countdown}s)`, 'info'); + // 서버에서 받은 듀얼 타이머 상태로 초기화 + if (data.timer_states) { + setDualTimerState(data.timer_states); + addLog(`듀얼 타이머 상태 동기화: 물리(${data.timer_states.physical.status}) 레이저(${data.timer_states.laser.status})`, 'info'); } // 서버에서 받은 호출 상태로 초기화 @@ -413,12 +438,17 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { onTimerStateChanged: (data: TimerStateChanged) => { // 내 태블릿의 상태 변경만 처리 if (data.tablet_id === currentTabletId.current) { - addLog(`상태 변경: ${data.status} (${data.countdown}s)`, 'success'); - setTimerState(prev => ({ + const timerTypeName = data.timer_type === TimerType.PHYSICAL ? '물리치료' : '레이저치료'; + addLog(`${timerTypeName} 상태 변경: ${data.status} (${data.countdown}s)`, 'success'); + + setDualTimerState(prev => ({ ...prev, - countdown: data.countdown, - status: data.status as TimerStatus, - tablet_id: data.tablet_id + [data.timer_type]: { + ...prev[data.timer_type as keyof DualTimerState], + countdown: data.countdown, + status: data.status as TimerStatus, + tablet_id: data.tablet_id + } })); } }, @@ -426,25 +456,29 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { onTimerTick: (data: TimerTick) => { // 내 태블릿의 틱만 처리 if (data.tablet_id === currentTabletId.current) { - setTimerState(prev => ({ + setDualTimerState(prev => ({ ...prev, - countdown: data.countdown, - status: data.status as TimerStatus + [data.timer_type]: { + ...prev[data.timer_type as keyof DualTimerState], + countdown: data.countdown, + status: data.status as TimerStatus + } })); // 10초 이하일 때만 로그 if (data.countdown <= 10 && data.countdown > 0) { - addLog(`⚠️ ${data.countdown}초 남음!`, 'warning'); + const timerTypeName = data.timer_type === TimerType.PHYSICAL ? '물리치료' : '레이저치료'; + addLog(`⚠️ ${timerTypeName} ${data.countdown}초 남음!`, 'warning'); } } }, onAllTimersState: (data: AllTimersState) => { - // 내 태블릿 상태 동기화 + // 내 태블릿 듀얼 타이머 상태 동기화 if (currentTabletId.current && data.timers[currentTabletId.current]) { - const myTimerState = data.timers[currentTabletId.current]; - setTimerState(myTimerState); - addLog('서버와 타이머 상태 동기화 완료', 'info'); + const myDualTimerState = data.timers[currentTabletId.current]; + setDualTimerState(myDualTimerState); + addLog('서버와 듀얼 타이머 상태 동기화 완료', 'info'); } }, @@ -495,38 +529,53 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { onTimerFinished: (data) => { if (data.tablet_id === currentTabletId.current) { - addLog('🏁 타이머 완료!', 'success'); - setTimerState(prev => ({ + const timerTypeName = data.timer_type === TimerType.PHYSICAL ? '물리치료' : '레이저치료'; + addLog(`🏁 ${timerTypeName} 타이머 완료!`, 'success'); + + setDualTimerState(prev => ({ ...prev, - countdown: 0, - status: TimerStatus.STOPPED + [data.timer_type]: { + ...prev[data.timer_type as keyof DualTimerState], + countdown: 0, + status: TimerStatus.STOPPED + } })); } }, - // ✨ 새로운 치료완료 이벤트 처리 + // ✨ 새로운 듀얼 타이머 치료완료 이벤트 처리 onTreatmentCompleted: (data) => { if (data.tablet_id === currentTabletId.current) { - addLog('🎉 치료 완료!', 'success'); - setTimerState(prev => ({ + const timerTypeName = data.timer_type === TimerType.PHYSICAL ? '물리치료' : '레이저칙료'; + addLog(`🎉 ${timerTypeName} 칙료 완료!`, 'success'); + + setDualTimerState(prev => ({ ...prev, - status: TimerStatus.FINISHED, - countdown: 0, - completion_time: data.completion_time + [data.timer_type]: { + ...prev[data.timer_type as keyof DualTimerState], + status: TimerStatus.FINISHED, + countdown: 0, + completion_time: data.completion_time + } })); } }, - // ✨ 새로운 타이머 리셋 이벤트 처리 + // ✨ 새로운 듀얼 타이머 리셋 이벤트 처리 onTimerReset: (data) => { if (data.tablet_id === currentTabletId.current) { const source = data.reset_source === 'tablet' ? '태블릿' : '관제센터'; - addLog(`🔄 대기 상태로 변경됨 (${source})`, 'success'); - setTimerState(prev => ({ + const timerTypeName = data.timer_type === TimerType.PHYSICAL ? '물리칙료' : '레이저칙료'; + addLog(`🔄 ${timerTypeName} 대기 상태로 변경됨 (${source})`, 'success'); + + setDualTimerState(prev => ({ ...prev, - status: TimerStatus.STOPPED, - countdown: data.countdown, - completion_time: undefined + [data.timer_type]: { + ...prev[data.timer_type as keyof DualTimerState], + status: TimerStatus.STOPPED, + countdown: data.countdown, + completion_time: undefined + } })); } }, @@ -615,11 +664,20 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { currentTabletId.current = selectedTabletId; setIsRegistered(false); - // 타이머 상태 초기화 - setTimerState({ - tablet_id: selectedTabletId, - countdown: 30, - status: TimerStatus.STOPPED + // 듀얼 타이머 상태 초기화 + setDualTimerState({ + physical: { + tablet_id: selectedTabletId, + timer_type: TimerType.PHYSICAL, + countdown: 900, // 15분 + status: TimerStatus.STOPPED + }, + laser: { + tablet_id: selectedTabletId, + timer_type: TimerType.LASER, + countdown: 300, // 5분 + status: TimerStatus.STOPPED + } }); // 호출 상태 초기화 @@ -666,27 +724,52 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { setTimeout(() => setIsLoading(false), 3000); }; - const handleStart = () => { - handleTimerAction('시작', () => - socketManager.current.startTimer(currentTabletId.current) + // 듀얼 타이머 제어 함수들 + const handleStartPhysical = () => { + handleTimerAction('물리칙료 시작', () => + socketManager.current.startTimer(currentTabletId.current, 'physical') ); }; - const handlePause = () => { - handleTimerAction('일시정지', () => - socketManager.current.pauseTimer(currentTabletId.current) + const handleStartLaser = () => { + handleTimerAction('레이저칙료 시작', () => + socketManager.current.startTimer(currentTabletId.current, 'laser') ); }; - const handleResume = () => { - handleTimerAction('재개', () => - socketManager.current.resumeTimer(currentTabletId.current) + const handlePausePhysical = () => { + handleTimerAction('물리칙료 일시정지', () => + socketManager.current.pauseTimer(currentTabletId.current, 'physical') ); }; - const handleStop = () => { - handleTimerAction('정지', () => - socketManager.current.stopTimer(currentTabletId.current) + const handlePauseLaser = () => { + handleTimerAction('레이저칙료 일시정지', () => + socketManager.current.pauseTimer(currentTabletId.current, 'laser') + ); + }; + + const handleResumePhysical = () => { + handleTimerAction('물리칙료 재개', () => + socketManager.current.resumeTimer(currentTabletId.current, 'physical') + ); + }; + + const handleResumeLaser = () => { + handleTimerAction('레이저칙료 재개', () => + socketManager.current.resumeTimer(currentTabletId.current, 'laser') + ); + }; + + const handleStopPhysical = () => { + handleTimerAction('물리칙료 정지', () => + socketManager.current.stopTimer(currentTabletId.current, 'physical') + ); + }; + + const handleStopLaser = () => { + handleTimerAction('레이저칙료 정지', () => + socketManager.current.stopTimer(currentTabletId.current, 'laser') ); }; @@ -698,24 +781,26 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}.${ms}`; }; - const getTimerColor = (): string => { - if (timerState.countdown <= 10 && timerState.status === TimerStatus.RUNNING) { + const getTimerColor = (timerType: TimerType): string => { + const timer = timerType === TimerType.PHYSICAL ? dualTimerState.physical : dualTimerState.laser; + if (timer.countdown <= 10 && timer.status === TimerStatus.RUNNING) { return 'text-red-400 animate-pulse drop-shadow-lg'; } - if (timerState.status === TimerStatus.RUNNING) { + if (timer.status === TimerStatus.RUNNING) { return 'text-green-400'; } - if (timerState.status === TimerStatus.PAUSED) { + if (timer.status === TimerStatus.PAUSED) { return 'text-yellow-400'; } - if (timerState.status === TimerStatus.FINISHED) { + if (timer.status === TimerStatus.FINISHED) { return 'text-blue-400'; // ✨ 치료완료 색상 } return 'text-gray-400'; }; - const getStatusText = (): string => { - switch (timerState.status) { + const getStatusText = (timerType: TimerType): string => { + const timer = timerType === TimerType.PHYSICAL ? dualTimerState.physical : dualTimerState.laser; + switch (timer.status) { case TimerStatus.RUNNING: return '실행 중'; case TimerStatus.PAUSED: return '일시정지'; case TimerStatus.STOPPED: return '정지'; @@ -763,9 +848,20 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { const canOperate = isConnected && isRegistered && !isLoading; const currentSlide = slides[currentSlideIndex]; + // 이미지 URL 처리 함수 + const getImageUrl = (imageUrl: string): string => { + if (imageUrl.startsWith('http')) { + return imageUrl; // 이미 절대 URL인 경우 + } + return `${apiClient.getBaseURL()}${imageUrl}`; // 상대 URL에 서버 주소 추가 + }; + // ======================== 터치 안내 메시지 헬퍼 ======================== const getTouchGuideMessage = (): string => { - if (timerState.status === TimerStatus.FINISHED) { + const physicalFinished = dualTimerState.physical.status === TimerStatus.FINISHED; + const laserFinished = dualTimerState.laser.status === TimerStatus.FINISHED; + + if (physicalFinished || laserFinished) { return '화면을 터치하여 대기 상태로 변경'; // ✨ 치료완료 안내 추가 } else if (callState.status === CallStatus.NONE) { return '지원이 필요하면 화면을 터치하세요'; @@ -796,7 +892,7 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) {
{currentSlide.media_type === 'video' ? (
@@ -1105,109 +1206,6 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) {
)} - {/* 컴팩트한 타이머 디스플레이 */} - {tabletId && ( -
-
-
-
- {formatTime(timerState.countdown)} -
-
- {getStatusText()} -
-
- - {/* 컴팩트한 컨트롤 버튼 */} -
- {timerState.status === TimerStatus.STOPPED && ( - - )} - - {timerState.status === TimerStatus.RUNNING && ( - <> - - - - )} - - {timerState.status === TimerStatus.PAUSED && ( - <> - - - - )} -
-
- - {/* 긴급 경고 - 더 작게 */} - {timerState.countdown <= 10 && timerState.status === TimerStatus.RUNNING && ( -
-
- - 시간이 얼마 남지 않았습니다! -
-
- )} -
- )} {/* 사이니지 컨트롤 */}
@@ -1290,7 +1288,7 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) {
)} - {/* 우측 하단에 작은 타이머 + 호출 상태 표시 (설정 패널이 닫혀있을 때) */} + {/* 우측 하단에 듀얼 타이머 + 호출 상태 표시 (설정 패널이 닫혀있을 때) */} {!showSettings && tabletId && isRegistered && (
e.stopPropagation()} @@ -1298,108 +1296,191 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) { onTouchEnd={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}>
-
-
+ {/* 태블릿 ID 헤더 */} +
+
- {tabletId} -
-
- {formatTime(timerState.countdown)} -
-
- {getStatusText()} -
- - {/* 호출 상태 표시 */} - {callState.status !== CallStatus.NONE && ( -
- {callState.status === CallStatus.CALLING ? ( -
- - 호출 중 -
- ) : ( -
- - 확인됨 -
- )} -
- )} - - {/* 간단한 컨트롤 */} -
- {timerState.status === TimerStatus.STOPPED && ( - - )} - - {timerState.status === TimerStatus.RUNNING && ( - <> - - - - )} - - {timerState.status === TimerStatus.PAUSED && ( - <> - - - - )} + {tabletId}
+ + {/* 듀얼 타이머 표시 */} +
+ {/* 물리칙료 타이머 (15분) */} +
+
물리칙료 (15분)
+
+ {formatTime(dualTimerState.physical.countdown)} +
+
+ {getStatusText(TimerType.PHYSICAL)} +
+ {/* 물리치료 컨트롤 */} +
+ {dualTimerState.physical.status === TimerStatus.STOPPED && ( + + )} + {dualTimerState.physical.status === TimerStatus.RUNNING && ( + <> + + + + )} + {dualTimerState.physical.status === TimerStatus.PAUSED && ( + <> + + + + )} +
+
+ + {/* 레이저칙료 타이머 (5분) */} +
+
레이저칙료 (5분)
+
+ {formatTime(dualTimerState.laser.countdown)} +
+
+ {getStatusText(TimerType.LASER)} +
+ {/* 레이저치료 컨트롤 */} +
+ {dualTimerState.laser.status === TimerStatus.STOPPED && ( + + )} + {dualTimerState.laser.status === TimerStatus.RUNNING && ( + <> + + + + )} + {dualTimerState.laser.status === TimerStatus.PAUSED && ( + <> + + + + )} +
+
+
+ + {/* 호출 상태 표시 */} + {callState.status !== CallStatus.NONE && ( +
+ {callState.status === CallStatus.CALLING ? ( +
+ + 호출 중 +
+ ) : ( +
+ + 확인됨 +
+ )} +
+ )}
)} diff --git a/src/components/SlideManager.tsx b/src/components/SlideManager.tsx index bef1eed..d107dea 100644 --- a/src/components/SlideManager.tsx +++ b/src/components/SlideManager.tsx @@ -8,6 +8,7 @@ import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger, Dialog import { Alert, AlertDescription } from "./ui/alert"; import { ArrowLeft, Plus, Upload, Image, Video, Edit, Trash2, Eye, EyeOff, GripVertical, Smartphone, Monitor, Download, Clock, CheckCircle, AlertTriangle } from "lucide-react"; import { DragDropContext, Droppable, Draggable, type DropResult } from '@hello-pangea/dnd'; +import { slideService, fileService, healthService } from '../api'; // 백엔드 변경사항 반영된 인터페이스 interface Slide { @@ -50,6 +51,7 @@ interface SlideItemProps { onEdit: (slide: Slide) => void; onDelete: (slideId: number) => void; isDragging?: boolean; + serverUrl: string; } const SlideItem = React.memo(({ @@ -59,7 +61,8 @@ const SlideItem = React.memo(({ onToggleActive, onEdit, onDelete, - isDragging + isDragging, + serverUrl }) => { const [imageError, setImageError] = useState(false); const [imageLoading, setImageLoading] = useState(true); @@ -115,7 +118,7 @@ const SlideItem = React.memo(({ // 비디오인 경우 썸네일이 있으면 썸네일을 미리보기로 사용 slide.thumbnail_url ? ( {`${slide.title}(({ /> ) : (
@@ -1487,7 +1412,7 @@ export function SlideManager({ onBack }: SlideManagerProps) { /> ) : ( 현재 미디어 diff --git a/src/utils/realtime-socket.ts b/src/utils/realtime-socket.ts index 4d65b64..25b218d 100644 --- a/src/utils/realtime-socket.ts +++ b/src/utils/realtime-socket.ts @@ -1,7 +1,7 @@ import { io, Socket } from 'socket.io-client'; // ======================== 서버 설정 ======================== -const SERVER_URL = 'https://api2.ysleadersos.com'; +const SERVER_URL = 'https://yapi.0bin.in'; // ======================== 타입 정의 ======================== @@ -18,6 +18,23 @@ export enum CallStatus { ACKNOWLEDGED = "acknowledged" } +export enum TimerType { + PHYSICAL = "physical", + LASER = "laser" +} + +export interface SingleTimerState { + tablet_id: string; + timer_type: TimerType; + countdown: number; + status: TimerStatus; +} + +export interface DualTimerState { + physical: SingleTimerState; + laser: SingleTimerState; +} + export interface TimerState { tablet_id: string; countdown: number; @@ -37,7 +54,7 @@ export interface CallState { } export interface AllTimersState { - timers: Record; + timers: Record; timestamp: number; } @@ -65,6 +82,7 @@ export interface RegistrationSuccess { export interface TimerStateChanged { tablet_id: string; + timer_type: TimerType; countdown: number; status: string; timestamp: number; @@ -72,6 +90,7 @@ export interface TimerStateChanged { export interface TimerTick { tablet_id: string; + timer_type: TimerType; countdown: number; status: string; timestamp: number; @@ -137,9 +156,9 @@ export interface SocketEventListeners { onMedicalCallCreated?: (data: MedicalCallCreated) => void; onMedicalCallAcknowledged?: (data: MedicalCallAcknowledged) => void; onMedicalCallCancelled?: (data: MedicalCallCancelled) => void; - onTimerFinished?: (data: { tablet_id: string; timestamp: number }) => void; - onTreatmentCompleted?: (data: { tablet_id: string; timestamp: number; completion_time: number; status: string }) => void; // ✨ 치료완료 이벤트 - onTimerReset?: (data: { tablet_id: string; countdown: number; status: string; timestamp: number; reset_source: string }) => void; // ✨ 리셋 이벤트 + onTimerFinished?: (data: { tablet_id: string; timer_type: TimerType; timestamp: number }) => void; + onTreatmentCompleted?: (data: { tablet_id: string; timer_type: TimerType; timestamp: number; completion_time: number; status: string }) => void; // ✨ 치료완료 이벤트 + onTimerReset?: (data: { tablet_id: string; timer_type: TimerType; countdown: number; status: string; timestamp: number; reset_source: string }) => void; // ✨ 리셋 이벤트 onActionSuccess?: (data: ActionSuccess) => void; onError?: (data: ErrorResponse) => void; onReconnect?: (attemptNumber: number) => void; @@ -165,14 +184,19 @@ export class RealtimeSocketManager { return RealtimeSocketManager.instance; } - connect(listeners: SocketEventListeners): void { + initialize(listeners: SocketEventListeners): void { + this.listeners = listeners; + } + + connect(listeners?: SocketEventListeners): void { + if (listeners) { + this.listeners = listeners; + } if (this.socket?.connected) { console.log('🔄 이미 연결된 Socket.IO를 재사용합니다'); return; } - this.listeners = listeners; - console.log(`🚀 Socket.IO 서버 연결 시도: ${SERVER_URL}`); this.socket = io(SERVER_URL, { @@ -327,47 +351,47 @@ export class RealtimeSocketManager { // ======================== 타이머 제어 메서드 ======================== - startTimer(tabletId: string): boolean { + startTimer(tabletId: string, timerType: 'physical' | 'laser' = 'physical'): boolean { if (!this.socket?.connected) { console.error('❌ Socket이 연결되지 않았습니다'); return false; } - console.log(`▶️ 타이머 시작 요청: ${tabletId}`); - this.socket.emit('start_timer', { tablet_id: tabletId }); + console.log(`▶️ 타이머 시작 요청: ${tabletId} (${timerType})`); + this.socket.emit('start_timer', { tablet_id: tabletId, timer_type: timerType }); return true; } - pauseTimer(tabletId: string): boolean { + pauseTimer(tabletId: string, timerType: 'physical' | 'laser' = 'physical'): boolean { if (!this.socket?.connected) { console.error('❌ Socket이 연결되지 않았습니다'); return false; } - console.log(`⏸️ 타이머 일시정지 요청: ${tabletId}`); - this.socket.emit('pause_timer', { tablet_id: tabletId }); + console.log(`⏸️ 타이머 일시정지 요청: ${tabletId} (${timerType})`); + this.socket.emit('pause_timer', { tablet_id: tabletId, timer_type: timerType }); return true; } - resumeTimer(tabletId: string): boolean { + resumeTimer(tabletId: string, timerType: 'physical' | 'laser' = 'physical'): boolean { if (!this.socket?.connected) { console.error('❌ Socket이 연결되지 않았습니다'); return false; } - console.log(`▶️ 타이머 재개 요청: ${tabletId}`); - this.socket.emit('resume_timer', { tablet_id: tabletId }); + console.log(`▶️ 타이머 재개 요청: ${tabletId} (${timerType})`); + this.socket.emit('resume_timer', { tablet_id: tabletId, timer_type: timerType }); return true; } - stopTimer(tabletId: string): boolean { + stopTimer(tabletId: string, timerType: 'physical' | 'laser' = 'physical'): boolean { if (!this.socket?.connected) { console.error('❌ Socket이 연결되지 않았습니다'); return false; } - console.log(`⏹️ 타이머 정지 요청: ${tabletId}`); - this.socket.emit('stop_timer', { tablet_id: tabletId }); + console.log(`⏹️ 타이머 정지 요청: ${tabletId} (${timerType})`); + this.socket.emit('stop_timer', { tablet_id: tabletId, timer_type: timerType }); return true; } @@ -440,17 +464,18 @@ export class RealtimeSocketManager { } // ✨ 새로운 메서드: 타이머 리셋 (관제센터 + 태블릿용) - resetTimer(tabletId: string, source: 'control' | 'tablet' = 'control'): boolean { + resetTimer(tabletId: string, source: 'control' | 'tablet' = 'control', timerType: 'physical' | 'laser' = 'physical'): boolean { if (!this.socket?.connected) { console.error('❌ Socket이 연결되지 않았습니다'); return false; } - console.log(`🔄 타이머 리셋 요청: ${tabletId} (출처: ${source})`); - this.socket.emit('reset_timer', { tablet_id: tabletId, source: source }); + console.log(`🔄 타이머 리셋 요청: ${tabletId} (출처: ${source}, 타입: ${timerType})`); + this.socket.emit('reset_timer', { tablet_id: tabletId, source: source, timer_type: timerType }); return true; } + // ======================== 연결 관리 메서드 ======================== isConnected(): boolean { diff --git a/vite.config.ts b/vite.config.ts index 176cf1e..638dc39 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,8 +9,8 @@ export default defineConfig({ tailwindcss(), ], server: { - port: 5070, + port: 5072, host: '0.0.0.0', - allowedHosts: ['ysl.0bin.in'] + allowedHosts: ['rvite.0bin.in'] } })