Initial commit for dev-front repository
This commit is contained in:
parent
c7b08005e9
commit
63b0e94ec2
384
DEV8_MIGRATION_MASTER_PLAN.md
Normal file
384
DEV8_MIGRATION_MASTER_PLAN.md
Normal 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 백엔드 시스템의 모든 기능을 완전히 활용하는 현대적이고 직관적인 프론트엔드를 구축하기 위한 종합적인 로드맵을 제공합니다.
|
||||
15
src/App.tsx
15
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();
|
||||
|
||||
175
src/api/client.ts
Normal file
175
src/api/client.ts
Normal 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
116
src/api/endpoints.ts
Normal 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
131
src/api/index.ts
Normal 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
311
src/api/services.ts
Normal 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
318
src/api/types.ts
Normal 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
@ -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>
|
||||
)}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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']
|
||||
}
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user