Initial commit for dev-front repository

This commit is contained in:
시골약사 2025-08-02 00:52:48 +00:00
parent c7b08005e9
commit 63b0e94ec2
12 changed files with 2639 additions and 977 deletions

View File

@ -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<TimerState>({
tablet_id: '',
timer_type: TimerType.PHYSICAL,
countdown: 0,
status: TimerStatus.STOPPED
});
const [laserTimer, setLaserTimer] = useState<TimerState>({
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 백엔드 시스템의 모든 기능을 완전히 활용하는 현대적이고 직관적인 프론트엔드를 구축하기 위한 종합적인 로드맵을 제공합니다.

View File

@ -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();

175
src/api/client.ts Normal file
View File

@ -0,0 +1,175 @@
/**
* Central API Client for dev8.py Backend
* Base URL: https://yapi.0bin.in
*/
export interface ApiResponse<T = any> {
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<T>(
endpoint: string,
options: RequestInit = {}
): Promise<T> {
const url = `${this.baseURL}${endpoint}`;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
// 기본 헤더 설정 (파일 업로드가 아닌 경우만)
const defaultHeaders: Record<string, string> = {};
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<T>(endpoint: string, params?: Record<string, any>): Promise<T> {
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<T>(url.pathname + url.search, {
method: 'GET',
});
}
async post<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
async put<T>(endpoint: string, data?: any): Promise<T> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
async delete<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint, {
method: 'DELETE',
});
}
// File upload with multipart/form-data
async uploadFile<T>(endpoint: string, file: File, additionalData?: Record<string, any>): Promise<T> {
const formData = new FormData();
formData.append('file', file);
if (additionalData) {
Object.entries(additionalData).forEach(([key, value]) => {
formData.append(key, String(value));
});
}
return this.request<T>(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 };

116
src/api/endpoints.ts Normal file
View File

@ -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, string | number>): 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;

131
src/api/index.ts Normal file
View File

@ -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;
},
};

311
src/api/services.ts Normal file
View File

@ -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<HealthCheckResponse> {
return apiClient.get<HealthCheckResponse>(API_ENDPOINTS.HEALTH);
},
/**
* Get system status and statistics
*/
async getStatus(): Promise<SystemStats> {
return apiClient.get<SystemStats>(API_ENDPOINTS.STATUS);
},
};
// Timer Services (Dual Timer System)
export const timerService = {
/**
* Get all timer states
*/
async getAllTimers(): Promise<AllTimersState> {
return apiClient.get<AllTimersState>(API_ENDPOINTS.TIMERS.LIST);
},
/**
* Start a specific timer
*/
async startTimer(tabletId: string, timerType: TimerType): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
return apiClient.post(API_ENDPOINTS.TIMERS.STOP_ALL);
},
/**
* Get timer statistics
*/
async getTimerStats(): Promise<TimerStats> {
return apiClient.get<TimerStats>(API_ENDPOINTS.STATS.TIMERS);
},
};
// Medical Call Services
export const callService = {
/**
* Get all call states
*/
async getAllCalls(): Promise<AllCallsState> {
return apiClient.get<AllCallsState>(API_ENDPOINTS.CALLS.LIST);
},
/**
* Create a medical call
*/
async createCall(tabletId: string, message?: string): Promise<void> {
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<void> {
return apiClient.post(API_ENDPOINTS.CALLS.ACKNOWLEDGE(callId));
},
/**
* Cancel a medical call
*/
async cancelCall(callId: string): Promise<void> {
return apiClient.post(API_ENDPOINTS.CALLS.CANCEL(callId));
},
/**
* Get call statistics
*/
async getCallStats(): Promise<CallStats> {
return apiClient.get<CallStats>(API_ENDPOINTS.STATS.CALLS);
},
};
// Digital Signage Services
export const slideService = {
/**
* Get all slides
*/
async getAllSlides(): Promise<SlideData[]> {
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<SlideData> {
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<SlideData> {
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<void> {
await apiClient.delete<{success: boolean}>(API_ENDPOINTS.SLIDES.DELETE(slideId));
},
/**
* Reorder slides
*/
async reorderSlides(slideIds: number[]): Promise<SlideData[]> {
return apiClient.post<SlideData[]>(API_ENDPOINTS.SLIDES.REORDER, { slide_ids: slideIds });
},
};
// File Management Services
export const fileService = {
/**
* Upload a file
*/
async uploadFile(file: File, additionalData?: Record<string, any>): Promise<FileUploadResponse> {
const response = await apiClient.uploadFile<{data: FileUploadResponse, success: boolean}>(API_ENDPOINTS.FILES.UPLOAD, file, additionalData);
return response.data;
},
/**
* Get all files
*/
async getAllFiles(): Promise<FileInfo[]> {
const response = await apiClient.get<{data: FileInfo[], success: boolean}>(API_ENDPOINTS.FILES.LIST);
return response.data || [];
},
/**
* Delete a file
*/
async deleteFile(fileId: string): Promise<void> {
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<SystemStats> {
return apiClient.get<SystemStats>(API_ENDPOINTS.STATS.OVERVIEW);
},
/**
* Get timer statistics
*/
async getTimerStats(): Promise<TimerStats> {
return timerService.getTimerStats();
},
/**
* Get call statistics
*/
async getCallStats(): Promise<CallStats> {
return callService.getCallStats();
},
};
// Utility functions for common operations
export const apiUtils = {
/**
* Test connection to the server
*/
async testConnection(): Promise<boolean> {
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);
},
};

318
src/api/types.ts Normal file
View File

@ -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<string, DualTimerState>; // tablet_id -> dual timer state
timestamp: number;
}
export interface AllCallsState {
calls: Record<string, CallState>;
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;
}

File diff suppressed because it is too large Load Diff

View File

@ -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<string>('');
const [availableTablets, setAvailableTablets] = useState<string[]>([]); // 동적 태블릿 목록
const [timerState, setTimerState] = useState<TimerState>({
tablet_id: '',
countdown: 30,
status: TimerStatus.STOPPED
// ✨ 듀얼 타이머 상태 관리
const [dualTimerState, setDualTimerState] = useState<DualTimerState>({
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<CallState>({
@ -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) {
<div className={`w-full h-full transition-all duration-500 ${slideTransition ? 'opacity-0 scale-105' : 'opacity-100 scale-100'}`}>
{currentSlide.media_type === 'video' ? (
<video
src={currentSlide.image_url}
src={getImageUrl(currentSlide.image_url)}
className="w-full h-full object-cover"
style={{ filter: 'brightness(0.7)' }}
autoPlay
@ -806,10 +902,15 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) {
/>
) : (
<img
src={currentSlide.image_url}
src={getImageUrl(currentSlide.image_url)}
alt={currentSlide.title}
className="w-full h-full object-cover"
style={{ filter: 'brightness(0.7)' }}
onError={(e) => {
console.error('사이니지 이미지 로드 실패:', currentSlide.image_url);
const target = e.target as HTMLImageElement;
target.style.backgroundColor = '#1f2937';
}}
/>
)}
</div>
@ -1105,109 +1206,6 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) {
</div>
)}
{/* 컴팩트한 타이머 디스플레이 */}
{tabletId && (
<div className="bg-gradient-to-r from-gray-900/80 to-black/80 rounded-xl p-4 text-center backdrop-blur-sm">
<div className="flex items-center justify-center gap-4">
<div className="text-center">
<div className={`text-2xl font-mono ${getTimerColor()}`}>
{formatTime(timerState.countdown)}
</div>
<div className="text-xs text-white/60 mt-1">
{getStatusText()}
</div>
</div>
{/* 컴팩트한 컨트롤 버튼 */}
<div className="flex gap-2">
{timerState.status === TimerStatus.STOPPED && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleStart();
}}
disabled={!canOperate}
className="bg-green-600 hover:bg-green-700 text-white px-3 py-1 h-8"
>
<Play className="w-3 h-3 mr-1" />
</Button>
)}
{timerState.status === TimerStatus.RUNNING && (
<>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handlePause();
}}
disabled={!canOperate}
className="bg-yellow-600 hover:bg-yellow-700 text-white px-3 py-1 h-8"
>
<Pause className="w-3 h-3 mr-1" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={(e) => {
e.stopPropagation();
handleStop();
}}
disabled={!canOperate}
className="px-3 py-1 h-8"
>
<Square className="w-3 h-3 mr-1" />
</Button>
</>
)}
{timerState.status === TimerStatus.PAUSED && (
<>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleResume();
}}
disabled={!canOperate}
className="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 h-8"
>
<Play className="w-3 h-3 mr-1" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={(e) => {
e.stopPropagation();
handleStop();
}}
disabled={!canOperate}
className="px-3 py-1 h-8"
>
<Square className="w-3 h-3 mr-1" />
</Button>
</>
)}
</div>
</div>
{/* 긴급 경고 - 더 작게 */}
{timerState.countdown <= 10 && timerState.status === TimerStatus.RUNNING && (
<div className="mt-3 p-2 bg-red-500/20 border border-red-500/50 rounded-lg">
<div className="text-red-400 text-sm flex items-center justify-center gap-2">
<AlertTriangle className="w-4 h-4" />
!
</div>
</div>
)}
</div>
)}
{/* 사이니지 컨트롤 */}
<div className="flex justify-center gap-3">
@ -1290,7 +1288,7 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) {
</div>
)}
{/* 우측 하단에 작은 타이머 + 호출 상태 표시 (설정 패널이 닫혀있을 때) */}
{/* 우측 하단에 듀얼 타이머 + 호출 상태 표시 (설정 패널이 닫혀있을 때) */}
{!showSettings && tabletId && isRegistered && (
<div className="absolute bottom-8 right-8 z-10 pointer-events-auto timer-widget"
onClick={(e) => e.stopPropagation()}
@ -1298,108 +1296,191 @@ export function SignageTabletScreen({ onBack }: SignageTabletScreenProps) {
onTouchEnd={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}>
<div className="bg-black/60 backdrop-blur-sm rounded-xl p-4 text-white border border-white/20">
<div className="text-center">
<div className="flex items-center gap-2 mb-2">
{/* 태블릿 ID 헤더 */}
<div className="text-center mb-3">
<div className="flex items-center justify-center gap-2">
<Timer className="w-4 h-4 text-blue-400" />
<span className="text-sm">{tabletId}</span>
</div>
<div className={`text-xl font-mono ${getTimerColor()}`}>
{formatTime(timerState.countdown)}
</div>
<div className="text-xs text-white/60 mt-1">
{getStatusText()}
</div>
{/* 호출 상태 표시 */}
{callState.status !== CallStatus.NONE && (
<div className={`text-xs mt-2 ${getCallStatusColor()}`}>
{callState.status === CallStatus.CALLING ? (
<div className="flex items-center gap-1">
<PhoneCall className="w-3 h-3" />
</div>
) : (
<div className="flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
</div>
)}
</div>
)}
{/* 간단한 컨트롤 */}
<div className="flex gap-1 mt-2">
{timerState.status === TimerStatus.STOPPED && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleStart();
}}
disabled={!canOperate}
className="bg-green-600 hover:bg-green-700 text-white px-2 py-1 h-6 text-xs"
>
<Play className="w-2 h-2" />
</Button>
)}
{timerState.status === TimerStatus.RUNNING && (
<>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handlePause();
}}
disabled={!canOperate}
className="bg-yellow-600 hover:bg-yellow-700 text-white px-2 py-1 h-6 text-xs"
>
<Pause className="w-2 h-2" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={(e) => {
e.stopPropagation();
handleStop();
}}
disabled={!canOperate}
className="px-2 py-1 h-6 text-xs"
>
<Square className="w-2 h-2" />
</Button>
</>
)}
{timerState.status === TimerStatus.PAUSED && (
<>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleResume();
}}
disabled={!canOperate}
className="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 h-6 text-xs"
>
<Play className="w-2 h-2" />
</Button>
<Button
size="sm"
variant="destructive"
onClick={(e) => {
e.stopPropagation();
handleStop();
}}
disabled={!canOperate}
className="px-2 py-1 h-6 text-xs"
>
<Square className="w-2 h-2" />
</Button>
</>
)}
<span className="text-sm font-semibold">{tabletId}</span>
</div>
</div>
{/* 듀얼 타이머 표시 */}
<div className="space-y-3">
{/* 물리칙료 타이머 (15분) */}
<div className="border border-green-500/30 rounded-lg p-2">
<div className="text-xs text-green-300 mb-1"> (15)</div>
<div className={`text-lg font-mono ${getTimerColor(TimerType.PHYSICAL)}`}>
{formatTime(dualTimerState.physical.countdown)}
</div>
<div className="text-xs text-white/60 mb-2">
{getStatusText(TimerType.PHYSICAL)}
</div>
{/* 물리치료 컨트롤 */}
<div className="flex gap-1 justify-center">
{dualTimerState.physical.status === TimerStatus.STOPPED && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleStartPhysical();
}}
disabled={!canOperate}
className="bg-green-600 hover:bg-green-700 text-white px-2 py-1 h-6 text-xs"
>
<Play className="w-3 h-3" />
</Button>
)}
{dualTimerState.physical.status === TimerStatus.RUNNING && (
<>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handlePausePhysical();
}}
disabled={!canOperate}
className="bg-yellow-600 hover:bg-yellow-700 text-white px-2 py-1 h-6 text-xs"
>
<Pause className="w-3 h-3" />
</Button>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleStopPhysical();
}}
disabled={!canOperate}
className="bg-red-600 hover:bg-red-700 text-white px-2 py-1 h-6 text-xs"
>
<Square className="w-3 h-3" />
</Button>
</>
)}
{dualTimerState.physical.status === TimerStatus.PAUSED && (
<>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleResumePhysical();
}}
disabled={!canOperate}
className="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 h-6 text-xs"
>
<Play className="w-3 h-3" />
</Button>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleStopPhysical();
}}
disabled={!canOperate}
className="bg-red-600 hover:bg-red-700 text-white px-2 py-1 h-6 text-xs"
>
<Square className="w-3 h-3" />
</Button>
</>
)}
</div>
</div>
{/* 레이저칙료 타이머 (5분) */}
<div className="border border-purple-500/30 rounded-lg p-2">
<div className="text-xs text-purple-300 mb-1"> (5)</div>
<div className={`text-lg font-mono ${getTimerColor(TimerType.LASER)}`}>
{formatTime(dualTimerState.laser.countdown)}
</div>
<div className="text-xs text-white/60 mb-2">
{getStatusText(TimerType.LASER)}
</div>
{/* 레이저치료 컨트롤 */}
<div className="flex gap-1 justify-center">
{dualTimerState.laser.status === TimerStatus.STOPPED && (
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleStartLaser();
}}
disabled={!canOperate}
className="bg-purple-600 hover:bg-purple-700 text-white px-2 py-1 h-6 text-xs"
>
<Play className="w-3 h-3" />
</Button>
)}
{dualTimerState.laser.status === TimerStatus.RUNNING && (
<>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handlePauseLaser();
}}
disabled={!canOperate}
className="bg-yellow-600 hover:bg-yellow-700 text-white px-2 py-1 h-6 text-xs"
>
<Pause className="w-3 h-3" />
</Button>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleStopLaser();
}}
disabled={!canOperate}
className="bg-red-600 hover:bg-red-700 text-white px-2 py-1 h-6 text-xs"
>
<Square className="w-3 h-3" />
</Button>
</>
)}
{dualTimerState.laser.status === TimerStatus.PAUSED && (
<>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleResumeLaser();
}}
disabled={!canOperate}
className="bg-blue-600 hover:bg-blue-700 text-white px-2 py-1 h-6 text-xs"
>
<Play className="w-3 h-3" />
</Button>
<Button
size="sm"
onClick={(e) => {
e.stopPropagation();
handleStopLaser();
}}
disabled={!canOperate}
className="bg-red-600 hover:bg-red-700 text-white px-2 py-1 h-6 text-xs"
>
<Square className="w-3 h-3" />
</Button>
</>
)}
</div>
</div>
</div>
{/* 호출 상태 표시 */}
{callState.status !== CallStatus.NONE && (
<div className={`text-xs mt-3 text-center ${getCallStatusColor()}`}>
{callState.status === CallStatus.CALLING ? (
<div className="flex items-center justify-center gap-1">
<PhoneCall className="w-3 h-3" />
</div>
) : (
<div className="flex items-center justify-center gap-1">
<CheckCircle className="w-3 h-3" />
</div>
)}
</div>
)}
</div>
</div>
)}

View File

@ -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<SlideItemProps>(({
@ -59,7 +61,8 @@ const SlideItem = React.memo<SlideItemProps>(({
onToggleActive,
onEdit,
onDelete,
isDragging
isDragging,
serverUrl
}) => {
const [imageError, setImageError] = useState(false);
const [imageLoading, setImageLoading] = useState(true);
@ -115,7 +118,7 @@ const SlideItem = React.memo<SlideItemProps>(({
// 비디오인 경우 썸네일이 있으면 썸네일을 미리보기로 사용
slide.thumbnail_url ? (
<img
src={slide.thumbnail_url}
src={slide.thumbnail_url.startsWith('http') ? slide.thumbnail_url : `${serverUrl}${slide.thumbnail_url}`}
alt={`${slide.title} 썸네일`}
className={`w-full h-full object-cover transition-opacity ${imageLoading ? 'opacity-0' : 'opacity-100'}`}
loading="lazy"
@ -130,7 +133,7 @@ const SlideItem = React.memo<SlideItemProps>(({
/>
) : (
<video
src={slide.image_url}
src={slide.image_url.startsWith('http') ? slide.image_url : `${serverUrl}${slide.image_url}`}
className={`w-full h-full object-cover transition-opacity ${imageLoading ? 'opacity-0' : 'opacity-100'}`}
muted
playsInline
@ -146,7 +149,7 @@ const SlideItem = React.memo<SlideItemProps>(({
)
) : (
<img
src={slide.image_url}
src={slide.image_url.startsWith('http') ? slide.image_url : `${serverUrl}${slide.image_url}`}
alt={slide.title}
className={`w-full h-full object-cover transition-opacity ${imageLoading ? 'opacity-0' : 'opacity-100'}`}
loading="lazy"
@ -264,13 +267,14 @@ export function SlideManager({ onBack }: SlideManagerProps) {
const dropZoneRef = useRef<HTMLDivElement>(null);
// 서버 연결 상태 및 설정
const [serverUrl, setServerUrl] = useState('https://api2.ysleadersos.com');
const [serverUrl, setServerUrl] = useState('https://yapi.0bin.in');
const [isServerOnline, setIsServerOnline] = useState<boolean | null>(null);
const [showServerSettings, setShowServerSettings] = useState(false);
// 사용 가능한 서버 URL 옵션
const serverOptions = [
{ url: 'https://api2.ysleadersos.com', name: '원격 서버' },
{ url: 'https://yapi.0bin.in', name: '현재 서버' },
{ url: 'https://api2.ysleadersos.com', name: '원격 서버 (구)' },
{ url: 'http://localhost:5000', name: '로컬 서버 (5000)' },
{ url: 'http://localhost:8000', name: '로컬 서버 (8000)' },
{ url: 'http://127.0.0.1:5000', name: '로컬호스트 (5000)' },
@ -295,19 +299,9 @@ export function SlideManager({ onBack }: SlideManagerProps) {
const checkServerConnection = useCallback(async (url: string) => {
try {
console.log('서버 연결 확인 중:', url);
const response = await fetch(`${url}/api/health`, {
method: 'GET',
signal: AbortSignal.timeout(5000) // 5초 타임아웃
});
if (response.ok) {
const data = await response.json();
console.log('서버 연결 성공:', data);
return true;
} else {
console.log('서버 응답 오류:', response.status);
return false;
}
await healthService.checkHealth();
console.log('서버 연결 성공');
return true;
} catch (error) {
console.log('서버 연결 실패:', error);
return false;
@ -320,60 +314,51 @@ export function SlideManager({ onBack }: SlideManagerProps) {
setLoading(true);
setError(null);
console.log('슬라이드 로드 시도:', `${SERVER_URL}/api/slides`);
const response = await fetch(`${SERVER_URL}/api/slides`, {
signal: AbortSignal.timeout(10000) // 10초 타임아웃
});
console.log('슬라이드 로드 시도');
const slidesData = await slideService.getAllSlides();
if (!response.ok) {
throw new Error(`HTTP ${response.status}: 서버 연결에 실패했습니다`);
}
const data = await response.json();
console.log('서버 응답:', data);
if (data.success) {
setSlides(data.data);
// 배열인지 확인하고 안전하게 설정
if (Array.isArray(slidesData)) {
setSlides(slidesData);
setIsServerOnline(true);
console.log('슬라이드 로드 성공:', data.data.length);
console.log('슬라이드 로드 성공:', slidesData.length);
} else {
throw new Error(data.error || '슬라이드 조회에 실패했습니다');
console.warn('슬라이드 데이터가 배열이 아닙니다:', slidesData);
setSlides([]); // 안전한 기본값
setIsServerOnline(false);
setError('슬라이드 데이터 형식이 올바르지 않습니다');
}
} catch (err) {
console.error('슬라이드 로드 오류:', err);
setIsServerOnline(false);
setSlides([]); // 오류 시에도 빈 배열로 설정
if (err instanceof Error) {
if (err.name === 'AbortError') {
setError('서버 응답 시간이 초과되었습니다. 서버 상태를 확인해주세요.');
} else if (err.message.includes('Failed to fetch')) {
setError('서버에 연결할 수 없습니다. 서버가 실행 중인지 확인해주세요.');
} else {
setError(err.message);
}
setError(err.message);
} else {
setError('슬라이드 조회 중 알 수 없는 오류가 발생했습니다');
}
} finally {
setLoading(false);
}
}, [SERVER_URL]);
}, []);
const loadFiles = useCallback(async () => {
try {
const response = await fetch(`${SERVER_URL}/api/files`);
const data = await response.json();
if (data.success) {
setFiles(data.data);
console.log('파일 목록 로드 성공:', data.data.length);
const filesData = await fileService.getAllFiles();
// 배열인지 확인하고 안전하게 설정
if (Array.isArray(filesData)) {
setFiles(filesData);
console.log('파일 목록 로드 성공:', filesData.length);
} else {
console.error('파일 목록 로드 실패:', data.error);
console.warn('파일 데이터가 배열이 아닙니다:', filesData);
setFiles([]); // 안전한 기본값
}
} catch (error) {
console.error('파일 목록 로드 오류:', error);
setFiles([]); // 오류 시에도 빈 배열로 설정
}
}, [SERVER_URL]);
}, []);
useEffect(() => {
loadSlides();
@ -446,25 +431,11 @@ export function SlideManager({ onBack }: SlideManagerProps) {
setUploading(true);
setUploadProgress(0);
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('uploaded_by', 'slide_manager');
const response = await fetch(`${SERVER_URL}/api/files/upload`, {
method: 'POST',
body: formData,
});
const data = await response.json();
if (data.success) {
console.log('파일 업로드 성공:', data.data);
setUploadProgress(100);
await loadFiles();
return data.data;
} else {
throw new Error(data.error || '파일 업로드에 실패했습니다');
}
const uploadData = await fileService.uploadFile(selectedFile, { uploaded_by: 'slide_manager' });
console.log('파일 업로드 성공:', uploadData);
setUploadProgress(100);
await loadFiles();
return uploadData;
} catch (error) {
console.error('파일 업로드 오류:', error);
setError(error instanceof Error ? error.message : '파일 업로드 중 오류가 발생했습니다');
@ -489,7 +460,7 @@ export function SlideManager({ onBack }: SlideManagerProps) {
console.log('파일 업로드 시작:', selectedFile.name, selectedFile.type);
const uploadedFile = await uploadFile();
if (uploadedFile) {
const fullUrl = `${SERVER_URL}${uploadedFile.file_url}`;
const fullUrl = uploadedFile.url || uploadedFile.file_url || '';
console.log('업로드된 파일 URL:', fullUrl);
setNewSlide(prev => ({
...prev,
@ -557,11 +528,11 @@ export function SlideManager({ onBack }: SlideManagerProps) {
mediaType = file.file_type;
console.log('files 배열에서 미디어 타입 결정:', file.original_filename, file.file_type, '→', mediaType);
} else {
console.log('files 배열에서 파일을 찾지 못함. files 목록:', files.map(f => ({
console.log('files 배열에서 파일을 찾지 못함. files 목록:', Array.isArray(files) ? files.map(f => ({
name: f.original_filename,
url: f.file_url,
type: f.file_type
})));
})) : 'files가 배열이 아님');
// 3. 마지막으로 URL 확장자로 추론 (fallback)
const videoExtensions = ['.mp4', '.webm', '.mov', '.avi', '.mkv'];
@ -576,41 +547,28 @@ export function SlideManager({ onBack }: SlideManagerProps) {
const slideData = {
...newSlide,
image_url: imageUrl,
media_type: mediaType // 백엔드 media_type 필드에 정확한 타입 전달
// Don't send media_type if using centralized API
};
console.log('서버로 전송할 슬라이드 데이터:', slideData);
const response = await fetch(`${SERVER_URL}/api/slides`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(slideData),
});
const data = await response.json();
const createdSlide = await slideService.createSlide(slideData);
console.log('슬라이드 생성 성공:', createdSlide);
setIsCreateDialogOpen(false);
if (data.success) {
console.log('슬라이드 생성 성공:', data.data);
setIsCreateDialogOpen(false);
// 폼 초기화
setNewSlide({
title: '',
subtitle: '',
image_url: '',
duration: 5,
sequence: slides.length + 1
});
setSelectedFile(null);
setPreviewUrl('');
setSuccess('슬라이드가 생성되었습니다.');
await loadSlides();
} else {
throw new Error(data.error || '슬라이드 생성에 실패했습니다');
}
// 폼 초기화
setNewSlide({
title: '',
subtitle: '',
image_url: '',
duration: 5,
sequence: slides.length + 1
});
setSelectedFile(null);
setPreviewUrl('');
setSuccess('슬라이드가 생성되었습니다.');
await loadSlides();
} catch (error) {
console.error('슬라이드 생성 오류:', error);
setError(error instanceof Error ? error.message : '슬라이드 생성 중 오류가 발생했습니다');
@ -631,32 +589,19 @@ export function SlideManager({ onBack }: SlideManagerProps) {
setLoading(true);
setError(null);
const response = await fetch(`${SERVER_URL}/api/slides/${editingSlide.id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(editingSlide),
});
const data = await response.json();
if (data.success) {
console.log('슬라이드 수정 성공:', data.data);
setIsEditDialogOpen(false);
setEditingSlide(null);
setSuccess('슬라이드가 수정되었습니다.');
await loadSlides();
} else {
throw new Error(data.error || '슬라이드 수정에 실패했습니다');
}
const updatedSlide = await slideService.updateSlide(editingSlide.id, editingSlide);
console.log('슬라이드 수정 성공:', updatedSlide);
setIsEditDialogOpen(false);
setEditingSlide(null);
setSuccess('슬라이드가 수정되었습니다.');
await loadSlides();
} catch (error) {
console.error('슬라이드 수정 오류:', error);
setError(error instanceof Error ? error.message : '슬라이드 수정 중 오류가 발생했습니다');
} finally {
setLoading(false);
}
}, [editingSlide, SERVER_URL, loadSlides]);
}, [editingSlide, loadSlides]);
const deleteSlide = useCallback(async (slideId: number) => {
if (!confirm('정말로 이 슬라이드를 삭제하시겠습니까?')) return;
@ -665,19 +610,10 @@ export function SlideManager({ onBack }: SlideManagerProps) {
setLoading(true);
setError(null);
const response = await fetch(`${SERVER_URL}/api/slides/${slideId}`, {
method: 'DELETE',
});
const data = await response.json();
if (data.success) {
console.log('슬라이드 삭제 성공');
setSuccess('슬라이드가 삭제되었습니다.');
await loadSlides();
} else {
throw new Error(data.error || '슬라이드 삭제에 실패했습니다');
}
await slideService.deleteSlide(slideId);
console.log('슬라이드 삭제 성공');
setSuccess('슬라이드가 삭제되었습니다.');
await loadSlides();
} catch (error) {
console.error('슬라이드 삭제 오류:', error);
setError(error instanceof Error ? error.message : '슬라이드 삭제 중 오류가 발생했습니다');
@ -723,11 +659,7 @@ export function SlideManager({ onBack }: SlideManagerProps) {
// 서버에 순서 업데이트
const updatePromises = updatedSlides.map(slide =>
fetch(`${SERVER_URL}/api/slides/${slide.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sequence: slide.sequence }),
})
slideService.updateSlide(slide.id, { sequence: slide.sequence })
);
await Promise.all(updatePromises);
@ -750,22 +682,12 @@ export function SlideManager({ onBack }: SlideManagerProps) {
setError(null);
const updateData = { is_active: !slide.is_active };
const response = await fetch(`${SERVER_URL}/api/slides/${slide.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updateData),
});
const data = await response.json();
await slideService.updateSlide(slide.id, updateData);
if (data.success) {
setSlides(prev => prev.map(s =>
s.id === slide.id ? { ...s, is_active: !s.is_active } : s
));
setSuccess(`슬라이드가 ${!slide.is_active ? '활성화' : '비활성화'}되었습니다.`);
} else {
throw new Error(data.error || '슬라이드 상태 변경에 실패했습니다');
}
setSlides(prev => prev.map(s =>
s.id === slide.id ? { ...s, is_active: !s.is_active } : s
));
setSuccess(`슬라이드가 ${!slide.is_active ? '활성화' : '비활성화'}되었습니다.`);
} catch (error) {
console.error('슬라이드 상태 변경 오류:', error);
setError(error instanceof Error ? error.message : '슬라이드 상태 변경 중 오류가 발생했습니다');
@ -1057,7 +979,7 @@ export function SlideManager({ onBack }: SlideManagerProps) {
<div>
<Label className="text-sm"> </Label>
<div className="max-h-32 overflow-y-auto border border-white/20 rounded p-2 space-y-2 mt-2">
{files.length === 0 ? (
{!Array.isArray(files) || files.length === 0 ? (
<p className="text-center text-gray-400 py-4"> </p>
) : (
files.map((file) => (
@ -1071,7 +993,7 @@ export function SlideManager({ onBack }: SlideManagerProps) {
{file.file_type === 'video' ? (
file.thumbnail_url ? (
<img
src={file.thumbnail_url}
src={file.thumbnail_url.startsWith('http') ? file.thumbnail_url : `${SERVER_URL}${file.thumbnail_url}`}
alt={`${file.original_filename} 썸네일`}
className="w-full h-full object-cover"
/>
@ -1200,6 +1122,7 @@ export function SlideManager({ onBack }: SlideManagerProps) {
onEdit={openEditModal}
onDelete={deleteSlide}
isDragging={snapshot.isDragging}
serverUrl={serverUrl}
/>
</div>
)}
@ -1242,7 +1165,7 @@ export function SlideManager({ onBack }: SlideManagerProps) {
{file.file_type === 'video' ? (
file.thumbnail_url ? (
<img
src={file.thumbnail_url}
src={file.thumbnail_url.startsWith('http') ? file.thumbnail_url : `${SERVER_URL}${file.thumbnail_url}`}
alt={`${file.original_filename} 썸네일`}
className="w-full h-full object-cover"
/>
@ -1421,7 +1344,7 @@ export function SlideManager({ onBack }: SlideManagerProps) {
<div>
<Label> </Label>
<div className="max-h-32 overflow-y-auto border border-white/20 rounded p-2 space-y-2 mt-2">
{files.map((file) => (
{Array.isArray(files) ? files.map((file) => (
<div
key={file.uuid}
className="flex items-center gap-3 p-3 hover:bg-white/10 rounded cursor-pointer transition-colors border border-white/10"
@ -1432,7 +1355,7 @@ export function SlideManager({ onBack }: SlideManagerProps) {
{file.file_type === 'video' ? (
file.thumbnail_url ? (
<img
src={file.thumbnail_url}
src={file.thumbnail_url.startsWith('http') ? file.thumbnail_url : `${SERVER_URL}${file.thumbnail_url}`}
alt={`${file.original_filename} 썸네일`}
className="w-full h-full object-cover"
/>
@ -1469,7 +1392,9 @@ export function SlideManager({ onBack }: SlideManagerProps) {
</div>
</div>
</div>
))}
)) : (
<p className="text-center text-gray-400 py-4"> </p>
)}
</div>
</div>
@ -1487,7 +1412,7 @@ export function SlideManager({ onBack }: SlideManagerProps) {
/>
) : (
<img
src={editingSlide.image_url}
src={editingSlide.image_url.startsWith('http') ? editingSlide.image_url : `${SERVER_URL}${editingSlide.image_url}`}
alt="현재 미디어"
className="w-full max-h-48 object-cover rounded"
/>

View File

@ -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<string, TimerState>;
timers: Record<string, DualTimerState>;
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 {

View File

@ -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']
}
})