feat(cctv): optimize real-time monitoring immediate video feed and layout sync
This commit is contained in:
parent
da6d30a718
commit
af849ba8ec
@ -57,8 +57,11 @@ smartims/
|
|||||||
## 4. 기타 주요 디렉토리
|
## 4. 기타 주요 디렉토리
|
||||||
|
|
||||||
- `docs/`:
|
- `docs/`:
|
||||||
- `MODULAR_ARCHITECTURE_GUIDE.md`: 모듈화 상세 설계 원칙.
|
- `operation/`: 설치 및 운영 관련 가이드 (환경 설정, 배포, 라이선스 서버 등)
|
||||||
- `DEPLOYMENT_GUIDE.md`: 서버 배포 방법.
|
- `modules/`: 모듈별 상세 기술 및 사용자 가이드 (자산 분류 기준 등)
|
||||||
|
- `design/`: 플랫폼 설계 원칙, 시스템 아키텍처 및 디렉토리 구조
|
||||||
|
- `version_control/`: Git 운영 정책 및 버전 관리 규정
|
||||||
|
- `roadmap/`: 프로젝트 통합 마스터 플랜 및 단계별 로드맵 (`INTEGRATED_ROADMAP.md`)
|
||||||
- `tools/`:
|
- `tools/`:
|
||||||
- 라이선스 관리 도구, 데이터 마이그레이션 스크립트 등 개발 보조 도구 포함.
|
- 라이선스 관리 도구, 데이터 마이그레이션 스크립트 등 개발 보조 도구 포함.
|
||||||
|
|
||||||
49
docs/modules/asset/ASSET_CLASSIFICATION_GUIDE.md
Normal file
49
docs/modules/asset/ASSET_CLASSIFICATION_GUIDE.md
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
# 📋 자산 분류 및 등록 기준 가이드 (Asset Classification Guide)
|
||||||
|
|
||||||
|
이 문서는 SmartIMS 자산 관리 시스템에서 각 메뉴(카테고리)별로 등록해야 할 자산의 구분 기준과 대표적인 품목들을 정의합니다. 자산 등록 시 올바른 카테고리를 선택하는 기준으로 활용하시기 바랍니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 🏗️ 시설물 관리 (Facilities)
|
||||||
|
**구분**: 생산 설비, 건축물 부속 설비 등 사업장의 유지 운영에 필요한 규모가 큰 고정 자산
|
||||||
|
- **생산 설비**: CNC 머시닝 센터, 사출 성형기, 프레스기, 산업용 로봇, 생산 라인 컨베이어 등
|
||||||
|
- **유틸리티 설비**: 공기 압축기(컴프레셔), 변압기, 배전반, 산업용 보일러, 항온항습기, 대형 펌프 등
|
||||||
|
- **건축 부속**: 소방 설비 시스템, 보안 시스템(CCTV 본체 등), 엘리베이터, 중앙 제어 조명 등
|
||||||
|
|
||||||
|
## 2. 🔧 공구 관리 (Tools)
|
||||||
|
**구분**: 생산 및 정비 작업에 사용되는 도구 중 비교적 내구성이 있고 개별 관리가 필요한 품목
|
||||||
|
- **전동/에어 공구**: 충전 드릴, 그라인더, 임팩트 렌치, 에어 커터, 전기 톱 등
|
||||||
|
- **수공구 및 일반**: 토크 렌치, 대형 바이스, 특수 렌치 세트, 공구함 등
|
||||||
|
- **치공구/금형**: 전용 지그(Jig), 성형 금형(Die), 툴 홀더, 척(Chuck) 등
|
||||||
|
|
||||||
|
## 3. 📏 계측기 관리 (Instruments)
|
||||||
|
**구분**: 정밀도 유지가 중요하며 법적 또는 사내 규정에 따라 **정기적인 검교정(Calibration)**이 필요한 장비
|
||||||
|
- **길이/정밀 측정**: 버니어 캘리퍼스, 마이크로미터, 다이얼 게이지, 하이트 게이지, 3차원 측정기 등
|
||||||
|
- **전기/전자 측정**: 디지털 멀티미터, 오실로스코프, 절연 저항계, 전력 분석기 등
|
||||||
|
- **물리/환경 측정**: 정밀 저울, 온도/습도 기록계, 압력계, 소음계, 가스 분석기 등
|
||||||
|
|
||||||
|
## 4. 🚚 차량/운반 (Vehicles)
|
||||||
|
**구분**: 인원 이동, 자재 수송 및 상하역 작업을 수행하는 모빌리티 장비
|
||||||
|
- **운반/하역 장비**: 지게차(Forklift), 전동 스태커, 핸드 자키, 전동 대차(Cart) 등
|
||||||
|
- **사내외 차량**: 법인 승용차, 화물 트럭(1톤~5톤), 통근 버스 등
|
||||||
|
- **특수 장비**: 고소 작업대, 이동식 크레인, 트랙터 등
|
||||||
|
|
||||||
|
## 5. 💻 일반 자산 (General Assets)
|
||||||
|
**구분**: 사무 환경 조성 및 지원 업무에 사용되는 집기류 및 IT 기기
|
||||||
|
- **IT/사무 기기**: PC 본체, 모니터, 노트북, 복합기, 레이저 프린터, 서버 서버랙 등
|
||||||
|
- **사무 가구**: 책상, 사무용 의자, 회의용 테이블, 문서 보관함(캐비닛), 금고 등
|
||||||
|
- **생활/환경 비품**: 공용 냉장고, 정수기, 공기 청정기, 사내 공지용 TV(DID) 등
|
||||||
|
|
||||||
|
## 6. 📦 소모품 관리 (Consumables)
|
||||||
|
**구분**: 사용 시 가치가 즉시 소멸되거나 교체 주기가 매우 짧아 재고 수량 관리가 중요한 물품
|
||||||
|
- **가공 소모품**: 드릴 비트, 엔드밀, 절단석, 사포, 용접봉 등
|
||||||
|
- **유지보수 부자재**: 산업용 오일(윤활유), 에어 필터, 구동 벨트, 베어링, 전구/LED 램프 등
|
||||||
|
- **사무/안전 용품**: 프린터 토너/카트리지, 복사 용지, 장갑, 마스크, 안전화 등
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 등록 시 주의사항
|
||||||
|
1. **관리번호 자동 생성**: 각 카테고리 선택 시 설정된 규칙(예: `FAC-2024-001`)에 따라 관리번호가 부여됩니다.
|
||||||
|
2. **검교정 대상 확인**: 계측기 카테고리 품목은 등록 시 반드시 **교정 주기**를 설정하여 알림을 관리하십시오.
|
||||||
|
3. **위치 정보**: 자산 이동 발생 시 시스템의 **설치 위치** 정보를 업데이트하여 실물과 일치시키십시오.
|
||||||
|
4. **사진 첨부**: 외관 확인 및 식별을 위해 자산 정면과 명판(Nameplate) 사진을 함께 등록할 것을 권장합니다.
|
||||||
231
docs/roadmap/INTEGRATED_ROADMAP.md
Normal file
231
docs/roadmap/INTEGRATED_ROADMAP.md
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
# 🚀 SMART IMS 통합 프로젝트 로드맵 (Integrated Master Plan)
|
||||||
|
|
||||||
|
**프로젝트명:** SOKUREE Platform - Smart Integrated Management System
|
||||||
|
**최초 작성일:** 2026-01-25
|
||||||
|
**최근 업데이트:** 2026-01-25
|
||||||
|
**버전:** v0.4.1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 개요 (Overview)
|
||||||
|
본 문서는 SOKUREE 플랫폼의 핵심 성능 개선(Platform Review)과 스마트 관리 모듈(Smart Module Implementation Plan)의 구현 계획을 통합하여, 기능 구현 단계별 태그(Tag) 기반의 전체 마스터 플랜을 정의합니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🗺️ 구현 로드맵 (Implementation Roadmap)
|
||||||
|
|
||||||
|
### 🟦 Phase 1: 기반 강화 및 보안 고도화 (Platform Stability)
|
||||||
|
**목표:** 플랫폼의 안정성 확보 및 사용자 중심의 보안/관리 체계 구축
|
||||||
|
|
||||||
|
#### 🏷️ Tag: `v0.4.1.0` (완료)
|
||||||
|
- [x] **세션 관리 엔진 최적화**
|
||||||
|
- 세션 자동 로그아웃 시간 합산 오류 수정 (시간+분 단위 정상 동작 확인)
|
||||||
|
- 활동 부재 시 자동 로그아웃 잔여 시간 추적 로그 시스템 도입
|
||||||
|
- **(수정사항)**: 서버 요청 로그([KST])에 해당 세션의 남은 시간(`m s left`)을 실시간 표시하도록 개선 및 세션 연장 로직 최적화
|
||||||
|
-사용자가 마지막 활동 후 **설정된 시간(기본 10분)**이 경과하면, 30초 간격으로 돌아가는 감시 엔진에 의해 즉시 포착되어 로그아웃이 수행됩니다. 따라서 체감상 로그아웃이 발생하는 "최초 트리거 타임"은 [설정된 시간] + (0~30초) 이내입니다.
|
||||||
|
|
||||||
|
- [x] **사용자별 맞춤 보안 설정**
|
||||||
|
- 시스템 기본 설정의 보안/세션 을 개별 사용자의 기본설정에서 설정 가능하도록 이동
|
||||||
|
- 사용자 별 세션 만료 시간 적용가능하도록 사용자 DB에 이 설정 값을 포함하도록 재 구성
|
||||||
|
- 사용자 등록 양식 기본 값을 10분으로 설정
|
||||||
|
- 사용자 등록화면에서의 권한 명칭 수정 (최고관리자, 관리자, 사용자 로 통일)
|
||||||
|
- **(수정사항)**: `users` 테이블 스키마 확장(`session_timeout` 추가) 및 로그인 세션 생성 시 사용자별 커스텀 타임아웃 주입 엔진 구현
|
||||||
|
- [x] **CCTV 설정 추가**
|
||||||
|
- [x] 등록된 CCTV 조회 메뉴 추가하여 게시판 형으로 목록화 하고 활성(비활성)/수정/삭제 할수 있는 관리 기능 구현
|
||||||
|
- [x] CCTV 설정 파일 DB 저장 시 username/password 필드 암호화 처리
|
||||||
|
- [x] CCTV 설정 파일 조회 시 username/password 필드 복호화 처리
|
||||||
|
- **(수정사항)**: `CameraManagementPage` 신설 및 `cryptoUtil`을 통한 RTSP 계정 정보의 DB 암호화 저장 로직 완성 (AES-256)
|
||||||
|
|
||||||
|
#### 🏷️ Tag: `v0.4.2.0`
|
||||||
|
- [ ] **소모품 관리**
|
||||||
|
- [ ] 이 기능은 자재/재고 관리 모듈 생성하여 메뉴가 아니라 속성(카테고리)으로 추가할 예정
|
||||||
|
|
||||||
|
- [ ] **부속설비 등록**
|
||||||
|
- [ ] 자산 등록 화면에서 부속설비 등록 버튼 클릭 시 자산 등록 화면으로 이동하도록 구현
|
||||||
|
- [ ] 부속설비 등록 버튼으로 이동 시 상위설비 자동 선택되도록 구현
|
||||||
|
- [ ] 자산 등록 화면에서 상위설비 드롭다운 메뉴에서 목록이 표시되지 않고 있음
|
||||||
|
- [ ] 상위설비 드롭다운 메뉴를 입력 텍스트 박스로 변경하고 옆에 검색 아이콘 추가
|
||||||
|
- [ ] 검색 아이콘 클릭 시 상위설비 검색 화면으로 이동
|
||||||
|
- [ ] 상위설비 검색 화면에서 검색어 입력 후 검색 버튼 클릭 시 검색 결과 목록 표시
|
||||||
|
- [ ] 검색 결과 목록에서 상위설비 선택 시 자산 등록 화면으로 이동하고 입력 텍스트 박스에 상위설비 이름 표시
|
||||||
|
- [ ] **자산관리 모듈의 현재 메뉴들을 기준정보 메뉴의 하위 메뉴로 변경**
|
||||||
|
- [ ]
|
||||||
|
- [ ] **설정 메뉴 추가**
|
||||||
|
- [ ] IoT 센서 모니터링 메뉴 추가
|
||||||
|
- [ ] 모듈 관리 메뉴 추가
|
||||||
|
- [ ] 설비 관리 메뉴 추가
|
||||||
|
- [ ]
|
||||||
|
- [ ] **시스템 관리 - 사용자 관리**
|
||||||
|
- [ ] 사용자 관리에 모듈 접근 권한을 설정할 수 있도록 구현
|
||||||
|
- [ ] 관리권한 재 검토
|
||||||
|
- [ ] 최고관리자, 관리자, 모듈 관리자, 사용자
|
||||||
|
|
||||||
|
#### 🏷️ Tag: `v0.5.0.x`
|
||||||
|
- [ ] 모듈 간 연동을 위한 API 인터페이스 정의
|
||||||
|
- [ ] 자재/재고 관리 모듈 추가
|
||||||
|
- [ ] 생산관리 모듈
|
||||||
|
- [ ] 생산 공정 메뉴
|
||||||
|
- [ ] 생산 라인 메뉴
|
||||||
|
- [ ] 생산 실적 관리 메뉴
|
||||||
|
- [ ] 품질관리 모듈
|
||||||
|
|
||||||
|
- [ ] IoT 모듈
|
||||||
|
- [ ] IoT 센서 모니터링 메뉴
|
||||||
|
- [ ] IoT 센서 데이터 수집 및 저장
|
||||||
|
- [ ] IoT 센서 데이터 시각화
|
||||||
|
|
||||||
|
- [ ] **사용자 관리 시스템 강화**
|
||||||
|
- 사용자 고유 관리번호 도입 (중복 불가)
|
||||||
|
- 프로필 이미지 필드 추가 및 업로드 기능
|
||||||
|
- 사용자 목록 UI 최적화 (페이지당 10개 제한, 네비게이션 추가)
|
||||||
|
- 복합 검색 기능 (관리번호, 아이디, 이름) 및 소속 필터 적용
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟩 Phase 2: 자산관리 MVP 및 모니터링 (Core Assets & IoT)
|
||||||
|
**목표:** 통합 자산관리의 핵심 기능 구현 및 실시간 데이터 연동 기반 구축
|
||||||
|
|
||||||
|
#### 🏷️ Tag: `v0.5.1.x`
|
||||||
|
- [ ] **자산관리 기본 모듈 (Asset CRUD)**
|
||||||
|
- 6대 카테고리(`FAC`, `TOL`, `INS`, `VEH`, `GEN`, `CSM`) 기반 관리 체계 확립
|
||||||
|
- 자산 등록/수정/삭제 웹 인터페이스 구현
|
||||||
|
- QR 코드 자동 생성 및 라벨 출력 기능
|
||||||
|
- [ ] **에너지 미터 모니터링 (Energy Meter)**
|
||||||
|
- MQTT 프로토콜 기반 실시간 데이터 수집 (qideun.com:1883)
|
||||||
|
- 전압, 전류, 전력량, 역률 시각화
|
||||||
|
- 년/월/일/시간별 누적 소비량 통계 및 차트 구현
|
||||||
|
- [ ] **CCTV 스트리밍 연동**
|
||||||
|
- 현재의 JSMpeg 기반 스트리밍 구조 유지 및 모니터링 탭 통합
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 🟧 Phase 3: 지능형 통합 및 품질 관리 (Smart Integration)
|
||||||
|
**목표:** 모듈 간 데이터 연쇄 작용 및 생산/품질 시스템과의 연동 고도화
|
||||||
|
|
||||||
|
#### 🏷️ Tag: `v0.6.0.x`
|
||||||
|
- [ ] **스마트 센서 매핑 및 확장 시스템**
|
||||||
|
- 설비별 임의 센서(온도, 진동 등)를 동적으로 추가할 수 있는 Sensor Mapping 기능
|
||||||
|
- 센서 임계치 도달 시 **고장 접수 자동화** 및 **예방정비(PM) 알림** 연계
|
||||||
|
- [ ] **품질-생산 연동 (Accuracy Management)**
|
||||||
|
- 계측기 검교정 후 발생하는 **보정치(Bias/Offset)** 실시간 저장
|
||||||
|
- **MES 연동**: 생산 공정 데이터 측정 시 자산 모듈의 보정 계수를 실시간 반영하여 정밀도 확보
|
||||||
|
- [ ] **예방정비-재고 자동 연동**
|
||||||
|
- 예방정비 스케줄 도래 시 필요한 소모품(`CSM`)의 재고 확인 및 자동 할당
|
||||||
|
- 소모품 안전재고(ROP) 기반 자동 발주 알림 처리
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 기술 상세 규격 (Technical Specifications)
|
||||||
|
|
||||||
|
### 1. 전력 모니터링 MQTT 규격
|
||||||
|
- **Topic:** `sokuree/home/ems` (실시간), `sokuree/home/ems/report` (통계)
|
||||||
|
- **Data Payload:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"node_id": "PZEM004T-R4",
|
||||||
|
"voltage": "225.20",
|
||||||
|
"current": "2.17",
|
||||||
|
"power": "408.40",
|
||||||
|
"energy": "157.94"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 고도화된 데이터 모델 (Interface)
|
||||||
|
|
||||||
|
#### 시설물 (Facility + IoT + Parts)
|
||||||
|
```typescript
|
||||||
|
interface FacilityAsset extends AssetBase {
|
||||||
|
iotConfig?: {
|
||||||
|
nodeId: string;
|
||||||
|
sensors: Array<{ type: string; field: string; threshold?: number }>;
|
||||||
|
};
|
||||||
|
linkedParts: Array<{ itemId: string; quantity: number }>; // 예방정비 소요 부품
|
||||||
|
maintenance: { nextDate: string; intervalDays: number };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 계측기 (Instrument + Correction)
|
||||||
|
```typescript
|
||||||
|
interface InstrumentAsset extends AssetBase {
|
||||||
|
calibration: {
|
||||||
|
nextDate: string;
|
||||||
|
correction: { bias: number; slope: number }; // 생산관리 연동용 보정치
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 자산 분류 및 관리 전략
|
||||||
|
| 코드 | 분류명 | 핵심 연동 항목 |
|
||||||
|
| :--- | :--- | :--- |
|
||||||
|
| **FAC** | 시설물 | **PM(예방정비), IoT 가동률, 소모품 재고** |
|
||||||
|
| **INS** | 계측기 | **검교정 이력, MES 보정값 반영** |
|
||||||
|
| **CSM** | 소모품 | **정비 부품 할당, 안전재고 알림** |
|
||||||
|
| **TOL** | 공구 | **불출/반납 이력, 금형 수명 관리** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💡 참고 사항
|
||||||
|
- 모든 기능 구현 시 플랫폼의 디자인 시스템(CSS Variables)을 준수해야 함.
|
||||||
|
- 모듈 독립성 유지를 위해 모듈 간 통신은 정의된 API 인터페이스를 통해서만 수행함.
|
||||||
|
|
||||||
|
## 아키텍처 상세 도식
|
||||||
|
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Layer 5: 지능형 분석 및 시각화 (The Brain) │
|
||||||
|
│ ┌───────────────────────┐ ┌──────────────────────────────┐ │
|
||||||
|
│ │ AI 분석 엔진 │◀──────────▶│ 통합 관제 대시보드 (BI) │ │
|
||||||
|
│ │ (예지보전, 품질최적화) │ 인사이트 │ (KPI, 가동률 실시간 모니터링)│ │
|
||||||
|
│ └───────────▲───────────┘ └──────────────▲───────────────┘ │
|
||||||
|
└──────────────┼───────────────────────────────────────┼──────────────────┘
|
||||||
|
│ 분석 결과 피드백 │ 실시간 현황
|
||||||
|
▼ ▼
|
||||||
|
┌──────────────┼───────────────────────────────────────┼──────────────────┐
|
||||||
|
│ │ Layer 4: 애플리케이션 계층 (IT System) │ │
|
||||||
|
│ ┌───────────▼──┐ ┌──────────────┐ ┌─────────────┐ ┌──────────┐ │
|
||||||
|
│ │ MES │◀─▶│ ERP │◀─▶│ WMS │◀─▶│ QMS │ │
|
||||||
|
│ │ (생산 실행) │ │ (전사적 자원) │ │ (창고관리) │ │(품질관리) │ │
|
||||||
|
│ └───────▲──────┘ └───────▲──────┘ └──────▲──────┘ └─────▲────┘ │
|
||||||
|
└──────────┼──────────────────┼─────────────────┼────────────────┼────────┘
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌─────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ⭐ Layer 3: 통합 데이터 플랫폼 (CORE HUB & Data Lake) │
|
||||||
|
│ ┌───────────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ 통합 데이터 허브 (Data Broker) │ │
|
||||||
|
│ │ "모든 데이터가 모이고, 필요한 곳으로 분배된다" │ │
|
||||||
|
│ └─────────────────────────────────▲─────────────────────────────────┘ │
|
||||||
|
│ │ ↕ 양방향 데이터 동기화 │
|
||||||
|
│ ┌──────────▼──────────┐ │
|
||||||
|
│ │ 실시간 / 이력 DB │ │
|
||||||
|
│ └─────────────────────┘ │
|
||||||
|
└────────────────────────────────────▲────────────────────────────────────┘
|
||||||
|
│ 표준 프로토콜 (MQTT/OPC-UA)
|
||||||
|
┌────────────────────────────────────┼────────────────────────────────────┐
|
||||||
|
│ Layer 2: 엣지 컴퓨팅 계층 (Translator) │
|
||||||
|
│ ┌────────────────────────┴───────────────────────┐ │
|
||||||
|
│ │ 엣지 게이트웨이 (Edge GW) │ │
|
||||||
|
│ │ (데이터 수집, 프로토콜 변환, 1차 필터링) │ │
|
||||||
|
│ └──────▲─────────────────▲────────────────▲──────┘ │
|
||||||
|
└──────────────────┼─────────────────┼────────────────┼───────────────────┘
|
||||||
|
│ │ │
|
||||||
|
┌──────────────────┼─────────────────┼────────────────┼───────────────────┐
|
||||||
|
│ ┌─────────┴──────┐ ┌──────┴───────┐ ┌────┴─────┐ │
|
||||||
|
│ │ P1. 기존 설비 │ │ P2. 최신설비 │ │ P3. 센서 │ │
|
||||||
|
│ │ (PLC/Legacy) │ │ (Robot/CNC) │ │ (환경) │ │
|
||||||
|
│ └────────────────┘ └──────────────┘ └──────────┘ │
|
||||||
|
│ Layer 1: 현장 물리 계층 (Data Source) │
|
||||||
|
└─────────────────────────────────────────────────────────────────────────┘
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
https://www.bulums.io/smartfactory-cmms
|
||||||
|
|
||||||
|

|
||||||
|
https://blog.naver.com/8pmcorp/224146369431
|
||||||
BIN
docs/roadmap/image-1.png
Normal file
BIN
docs/roadmap/image-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 46 KiB |
BIN
docs/roadmap/image-2.png
Normal file
BIN
docs/roadmap/image-2.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
BIN
docs/roadmap/image-3.png
Normal file
BIN
docs/roadmap/image-3.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 58 KiB |
BIN
docs/roadmap/image-4.png
Normal file
BIN
docs/roadmap/image-4.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/roadmap/image.png
Normal file
BIN
docs/roadmap/image.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 193 KiB |
165
server/index.js
165
server/index.js
@ -81,15 +81,21 @@ app.use(session({
|
|||||||
app.use(async (req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
if (req.session && req.session.user) {
|
if (req.session && req.session.user) {
|
||||||
// Skip session extension for background check requests
|
// Skip session extension for background check requests
|
||||||
// These requests are prefixed by /api from the client but might be handled differently in middleware
|
|
||||||
// Checking both common forms for safety
|
|
||||||
if (req.path === '/api/check' || req.path === '/check' || req.path.includes('/auth/check')) {
|
if (req.path === '/api/check' || req.path === '/check' || req.path.includes('/auth/check')) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
|
// Priority: User's individual timeout > System default
|
||||||
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
|
let timeoutMinutes = 60; // Default fallback
|
||||||
|
|
||||||
|
if (req.session.user.session_timeout) {
|
||||||
|
timeoutMinutes = parseInt(req.session.user.session_timeout);
|
||||||
|
} else {
|
||||||
|
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
|
||||||
|
timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 10; // New default fallback 10 as requested
|
||||||
|
}
|
||||||
|
|
||||||
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
|
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
|
||||||
|
|
||||||
// Explicitly save session before moving to next middleware
|
// Explicitly save session before moving to next middleware
|
||||||
@ -108,12 +114,24 @@ app.use(async (req, res, next) => {
|
|||||||
// Apply CSRF Protection
|
// Apply CSRF Protection
|
||||||
app.use(csrfProtection);
|
app.use(csrfProtection);
|
||||||
|
|
||||||
// Request Logger
|
// Request Logger with Session Remaining Time
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
// UTC 시간에 9시간을 더한 뒤 ISO 문자열로 변환하고 끝의 'Z'를 제거하여 한국 시간 형식 생성
|
const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', '').replace('T', ' ');
|
||||||
const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', '');
|
|
||||||
console.log(`[${kstDate}] ${req.method} ${req.url}`);
|
let sessionInfo = '';
|
||||||
|
if (req.session && req.session.cookie && req.session.cookie.expires) {
|
||||||
|
const remainingMs = req.session.cookie.expires - now;
|
||||||
|
if (remainingMs > 0) {
|
||||||
|
const remMin = Math.floor(remainingMs / 60000);
|
||||||
|
const remSec = Math.floor((remainingMs % 60000) / 1000);
|
||||||
|
sessionInfo = ` [Session: ${remMin}m ${remSec}s left]`;
|
||||||
|
} else {
|
||||||
|
sessionInfo = ` [Session: Expired]`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`[${kstDate}]${sessionInfo} ${req.method} ${req.url}`);
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -171,6 +189,14 @@ const initTables = async () => {
|
|||||||
console.log("✅ Added 'quantity' column to assets");
|
console.log("✅ Added 'quantity' column to assets");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check/Add 'parent_id' column to assets for sub-equipment management
|
||||||
|
const [parentIdCols] = await db.query("SHOW COLUMNS FROM assets LIKE 'parent_id'");
|
||||||
|
if (parentIdCols.length === 0) {
|
||||||
|
await db.query("ALTER TABLE assets ADD COLUMN parent_id VARCHAR(20) AFTER id");
|
||||||
|
await db.query("ALTER TABLE assets ADD CONSTRAINT fk_assets_parent FOREIGN KEY (parent_id) REFERENCES assets(id) ON DELETE SET NULL");
|
||||||
|
console.log("✅ Added 'parent_id' column to assets");
|
||||||
|
}
|
||||||
|
|
||||||
// Create maintenance_parts table
|
// Create maintenance_parts table
|
||||||
const maintenancePartsTable = `
|
const maintenancePartsTable = `
|
||||||
CREATE TABLE IF NOT EXISTS maintenance_parts (
|
CREATE TABLE IF NOT EXISTS maintenance_parts (
|
||||||
@ -196,20 +222,14 @@ const initTables = async () => {
|
|||||||
position VARCHAR(100),
|
position VARCHAR(100),
|
||||||
phone VARCHAR(255),
|
phone VARCHAR(255),
|
||||||
role ENUM('supervisor', 'admin', 'user') DEFAULT 'user',
|
role ENUM('supervisor', 'admin', 'user') DEFAULT 'user',
|
||||||
|
session_timeout INT DEFAULT 10,
|
||||||
last_login TIMESTAMP NULL,
|
last_login TIMESTAMP NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
`;
|
`;
|
||||||
await db.query(usersTableSQL);
|
await db.query(usersTableSQL);
|
||||||
|
console.log('✅ Users Table Created');
|
||||||
// Update existing table if needed
|
|
||||||
try {
|
|
||||||
await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'");
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore if it fails (e.g. column doesn't exist yet handled by SQL above)
|
|
||||||
}
|
|
||||||
console.log('✅ Users Table Initialized with Supervisor role');
|
|
||||||
|
|
||||||
// Default Admin
|
// Default Admin
|
||||||
const adminId = 'admin';
|
const adminId = 'admin';
|
||||||
@ -222,12 +242,22 @@ const initTables = async () => {
|
|||||||
[adminId, hashedPass, '관리자', 'supervisor', 'IT팀', '관리자']
|
[adminId, hashedPass, '관리자', 'supervisor', 'IT팀', '관리자']
|
||||||
);
|
);
|
||||||
console.log('✅ Default Admin Created as Supervisor');
|
console.log('✅ Default Admin Created as Supervisor');
|
||||||
} else {
|
|
||||||
// Ensure existing admin has supervisor role for this transition
|
|
||||||
await db.query('UPDATE users SET role = "supervisor" WHERE id = ?', [adminId]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. Ensure schema updates for existing table
|
||||||
|
try {
|
||||||
|
await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'");
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
|
||||||
|
const [userTimeoutCols] = await db.query("SHOW COLUMNS FROM users LIKE 'session_timeout'");
|
||||||
|
if (userTimeoutCols.length === 0) {
|
||||||
|
await db.query("ALTER TABLE users ADD COLUMN session_timeout INT DEFAULT 10 AFTER role");
|
||||||
|
console.log("✅ Added 'session_timeout' column to users");
|
||||||
|
}
|
||||||
|
|
||||||
console.log('✅ Tables Initialized');
|
console.log('✅ Tables Initialized');
|
||||||
// Create asset_manuals table
|
// Create asset_manuals table
|
||||||
const manualTable = `
|
const manualTable = `
|
||||||
@ -242,43 +272,105 @@ const initTables = async () => {
|
|||||||
`;
|
`;
|
||||||
await db.query(manualTable);
|
await db.query(manualTable);
|
||||||
|
|
||||||
// Create camera_settings table
|
// Create asset_accessories table
|
||||||
|
const accessoryTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS asset_accessories (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
asset_id VARCHAR(20) NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
spec VARCHAR(100),
|
||||||
|
quantity INT DEFAULT 1,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
`;
|
||||||
|
await db.query(accessoryTable);
|
||||||
|
|
||||||
|
// 1. Rename camera_settings to cctv_settings if old table exists
|
||||||
|
const [oldCamTable] = await db.query("SHOW TABLES LIKE 'camera_settings'");
|
||||||
|
if (oldCamTable.length > 0) {
|
||||||
|
await db.query("RENAME TABLE camera_settings TO cctv_settings");
|
||||||
|
console.log("✅ Renamed 'camera_settings' to 'cctv_settings'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cctv_settings table
|
||||||
const cameraTable = `
|
const cameraTable = `
|
||||||
CREATE TABLE IF NOT EXISTS camera_settings (
|
CREATE TABLE IF NOT EXISTS cctv_settings (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(100) NOT NULL,
|
name VARCHAR(100) NOT NULL,
|
||||||
|
zone VARCHAR(50) DEFAULT '기본 구역',
|
||||||
ip_address VARCHAR(100) NOT NULL,
|
ip_address VARCHAR(100) NOT NULL,
|
||||||
port INT DEFAULT 554,
|
port INT DEFAULT 554,
|
||||||
username VARCHAR(100),
|
username VARCHAR(100),
|
||||||
password VARCHAR(100),
|
password VARCHAR(255),
|
||||||
stream_path VARCHAR(200) DEFAULT '/stream1',
|
stream_path VARCHAR(200) DEFAULT '/stream1',
|
||||||
|
is_active BOOLEAN DEFAULT TRUE,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
`;
|
`;
|
||||||
await db.query(cameraTable);
|
await db.query(cameraTable);
|
||||||
|
|
||||||
|
// Ensure password field is long enough for encryption
|
||||||
|
await db.query("ALTER TABLE cctv_settings MODIFY COLUMN password VARCHAR(255)");
|
||||||
|
|
||||||
// Check for 'transport_mode' and 'rtsp_encoding' columns and add if missing
|
// Check for 'transport_mode' and 'rtsp_encoding' columns and add if missing
|
||||||
const [camColumns] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'transport_mode'");
|
const [camColumns] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'transport_mode'");
|
||||||
if (camColumns.length === 0) {
|
if (camColumns.length === 0) {
|
||||||
await db.query("ALTER TABLE camera_settings ADD COLUMN transport_mode ENUM('tcp', 'udp', 'auto') DEFAULT 'tcp' AFTER stream_path");
|
await db.query("ALTER TABLE cctv_settings ADD COLUMN transport_mode ENUM('tcp', 'udp', 'auto') DEFAULT 'tcp' AFTER stream_path");
|
||||||
await db.query("ALTER TABLE camera_settings ADD COLUMN rtsp_encoding BOOLEAN DEFAULT FALSE AFTER transport_mode");
|
await db.query("ALTER TABLE cctv_settings ADD COLUMN rtsp_encoding BOOLEAN DEFAULT FALSE AFTER transport_mode");
|
||||||
await db.query("ALTER TABLE camera_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); // Default to low for stability
|
await db.query("ALTER TABLE cctv_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); // Default to low for stability
|
||||||
console.log("✅ Added 'transport_mode', 'quality', and 'rtsp_encoding' columns to camera_settings");
|
console.log("✅ Added 'transport_mode', 'quality', and 'rtsp_encoding' columns to cctv_settings");
|
||||||
} else {
|
} else {
|
||||||
// Check if quality exists (for subsequent updates)
|
// Check if quality exists (for subsequent updates)
|
||||||
const [qualCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'quality'");
|
const [qualCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'quality'");
|
||||||
if (qualCol.length === 0) {
|
if (qualCol.length === 0) {
|
||||||
await db.query("ALTER TABLE camera_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode");
|
await db.query("ALTER TABLE cctv_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode");
|
||||||
console.log("✅ Added 'quality' column to camera_settings");
|
console.log("✅ Added 'quality' column to cctv_settings");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for 'zone' column
|
||||||
|
const [zoneCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'zone'");
|
||||||
|
if (zoneCol.length === 0) {
|
||||||
|
await db.query("ALTER TABLE cctv_settings ADD COLUMN zone VARCHAR(50) DEFAULT '기본 구역' AFTER name");
|
||||||
|
console.log("✅ Added 'zone' column to cctv_settings");
|
||||||
|
}
|
||||||
|
|
||||||
// Check for 'display_order' column
|
// Check for 'display_order' column
|
||||||
const [orderCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'display_order'");
|
const [orderCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'display_order'");
|
||||||
if (orderCol.length === 0) {
|
if (orderCol.length === 0) {
|
||||||
await db.query("ALTER TABLE camera_settings ADD COLUMN display_order INT DEFAULT 0 AFTER quality");
|
await db.query("ALTER TABLE cctv_settings ADD COLUMN display_order INT DEFAULT 0 AFTER quality");
|
||||||
console.log("✅ Added 'display_order' column to camera_settings");
|
console.log("✅ Added 'display_order' column to cctv_settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create cctv_zones table
|
||||||
|
const zoneTable = `
|
||||||
|
CREATE TABLE IF NOT EXISTS cctv_zones (
|
||||||
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
name VARCHAR(50) NOT NULL UNIQUE,
|
||||||
|
layout ENUM('1', '1*2', '2*2') DEFAULT '2*2',
|
||||||
|
display_order INT DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
`;
|
||||||
|
await db.query(zoneTable);
|
||||||
|
|
||||||
|
// Check for 'layout' column (for migration)
|
||||||
|
const [layoutCol] = await db.query("SHOW COLUMNS FROM cctv_zones LIKE 'layout'");
|
||||||
|
if (layoutCol.length === 0) {
|
||||||
|
await db.query("ALTER TABLE cctv_zones ADD COLUMN layout VARCHAR(10) DEFAULT '2*2' AFTER name");
|
||||||
|
console.log("✅ Added 'layout' column to cctv_zones");
|
||||||
|
} else {
|
||||||
|
// Migration: Convert ENUM to VARCHAR
|
||||||
|
await db.query("ALTER TABLE cctv_zones MODIFY COLUMN layout VARCHAR(10) DEFAULT '2*2'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize default zone if empty
|
||||||
|
const [existingZones] = await db.query('SELECT 1 FROM cctv_zones LIMIT 1');
|
||||||
|
if (existingZones.length === 0) {
|
||||||
|
await db.query("INSERT INTO cctv_zones (name, display_order) VALUES ('기본 구역', 0)");
|
||||||
|
console.log("✅ Initialized default CCTV zone");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create system_settings table (Key-Value store)
|
// Create system_settings table (Key-Value store)
|
||||||
@ -348,7 +440,12 @@ const initTables = async () => {
|
|||||||
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`;
|
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`;
|
||||||
await db.query(insert, ['asset', '자산 관리', true, 'dev']);
|
await db.query(insert, ['asset', '자산 관리', true, 'dev']);
|
||||||
await db.query(insert, ['production', '생산 관리', false, null]);
|
await db.query(insert, ['production', '생산 관리', false, null]);
|
||||||
await db.query(insert, ['monitoring', 'CCTV', false, null]);
|
await db.query(insert, ['cctv', 'CCTV', true, 'dev']);
|
||||||
|
} else {
|
||||||
|
// One-time update: Rename 'monitoring' code to 'cctv' and ensure it's active
|
||||||
|
await db.query("UPDATE system_modules SET code = 'cctv', is_active = 1 WHERE code = 'monitoring'");
|
||||||
|
// Also ensure 'cctv' is active if it already exists but was inactive due to renaming glitches
|
||||||
|
await db.query("UPDATE system_modules SET is_active = 1 WHERE code = 'cctv'");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('✅ Tables Initialized');
|
console.log('✅ Tables Initialized');
|
||||||
|
|||||||
@ -23,7 +23,22 @@ router.get('/assets/:id', async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT * FROM assets WHERE id = ?', [req.params.id]);
|
const [rows] = await db.query('SELECT * FROM assets WHERE id = ?', [req.params.id]);
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
res.json(rows[0]);
|
|
||||||
|
const asset = rows[0];
|
||||||
|
|
||||||
|
// Fetch sub-assets (children)
|
||||||
|
const [children] = await db.query('SELECT id, name, category, status, location FROM assets WHERE parent_id = ?', [req.params.id]);
|
||||||
|
asset.children = children;
|
||||||
|
|
||||||
|
// Fetch parent name if exists
|
||||||
|
if (asset.parent_id) {
|
||||||
|
const [parentRows] = await db.query('SELECT name FROM assets WHERE id = ?', [asset.parent_id]);
|
||||||
|
if (parentRows.length > 0) {
|
||||||
|
asset.parent_name = parentRows[0].name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(asset);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
@ -32,14 +47,14 @@ router.get('/assets/:id', async (req, res) => {
|
|||||||
|
|
||||||
// Create Asset
|
// Create Asset
|
||||||
router.post('/assets', async (req, res) => {
|
router.post('/assets', async (req, res) => {
|
||||||
const { id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body;
|
const { id, parent_id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO assets (id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url)
|
INSERT INTO assets (id, parent_id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
await db.query(sql, [id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url]);
|
await db.query(sql, [id, parent_id || null, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity || 1]);
|
||||||
res.status(201).json({ message: 'Asset created', id });
|
res.status(201).json({ message: 'Asset created', id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -49,15 +64,15 @@ router.post('/assets', async (req, res) => {
|
|||||||
|
|
||||||
// Update Asset
|
// Update Asset
|
||||||
router.put('/assets/:id', async (req, res) => {
|
router.put('/assets/:id', async (req, res) => {
|
||||||
const { name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body;
|
const { parent_id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sql = `
|
const sql = `
|
||||||
UPDATE assets
|
UPDATE assets
|
||||||
SET name=?, category=?, model_name=?, serial_number=?, manufacturer=?, location=?, purchase_date=?, manager=?, status=?, specs=?, purchase_price=?, image_url=?
|
SET parent_id=?, name=?, category=?, model_name=?, serial_number=?, manufacturer=?, location=?, purchase_date=?, manager=?, status=?, specs=?, purchase_price=?, image_url=?, quantity=?
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`;
|
`;
|
||||||
const [result] = await db.query(sql, [name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, req.params.id]);
|
const [result] = await db.query(sql, [parent_id || null, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity || 1, req.params.id]);
|
||||||
|
|
||||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
res.json({ message: 'Asset updated' });
|
res.json({ message: 'Asset updated' });
|
||||||
@ -279,4 +294,46 @@ router.delete('/manuals/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 4. Accessories
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
// Get Accessories for an Asset
|
||||||
|
router.get('/assets/:asset_id/accessories', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await db.query('SELECT * FROM asset_accessories WHERE asset_id = ? ORDER BY created_at ASC', [req.params.asset_id]);
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add Accessory to Asset
|
||||||
|
router.post('/assets/:asset_id/accessories', async (req, res) => {
|
||||||
|
const { name, spec, quantity } = req.body;
|
||||||
|
const { asset_id } = req.params;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const sql = `INSERT INTO asset_accessories (asset_id, name, spec, quantity) VALUES (?, ?, ?, ?)`;
|
||||||
|
const [result] = await db.query(sql, [asset_id, name, spec, quantity || 1]);
|
||||||
|
res.status(201).json({ message: 'Accessory added', id: result.insertId });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete Accessory
|
||||||
|
router.delete('/accessories/:id', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [result] = await db.query('DELETE FROM asset_accessories WHERE id = ?', [req.params.id]);
|
||||||
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Accessory not found' });
|
||||||
|
res.json({ message: 'Accessory deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -3,12 +3,21 @@ const router = express.Router();
|
|||||||
const db = require('../../db');
|
const db = require('../../db');
|
||||||
const { isAuthenticated, hasRole } = require('../../middleware/authMiddleware');
|
const { isAuthenticated, hasRole } = require('../../middleware/authMiddleware');
|
||||||
const { requireModule } = require('../../middleware/licenseMiddleware');
|
const { requireModule } = require('../../middleware/licenseMiddleware');
|
||||||
|
const cryptoUtil = require('../../utils/cryptoUtil');
|
||||||
|
|
||||||
// Get all cameras - Protected by Module License
|
// Get all cameras - Protected by Module License
|
||||||
router.get('/', requireModule('monitoring'), async (req, res) => {
|
router.get('/', requireModule('cctv'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT * FROM camera_settings ORDER BY display_order ASC, created_at DESC');
|
const [rows] = await db.query('SELECT * FROM cctv_settings ORDER BY display_order ASC, created_at DESC');
|
||||||
res.json(rows);
|
|
||||||
|
// Decrypt usernames and passwords for the UI
|
||||||
|
const cameras = rows.map(cam => ({
|
||||||
|
...cam,
|
||||||
|
username: cam.username ? cryptoUtil.decryptMasterKey(cam.username) : cam.username,
|
||||||
|
password: cam.password ? cryptoUtil.decryptMasterKey(cam.password) : cam.password
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json(cameras);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
@ -32,7 +41,7 @@ router.put('/reorder', hasRole('admin'), async (req, res) => {
|
|||||||
const id = typeof cam === 'object' ? cam.id : cam;
|
const id = typeof cam === 'object' ? cam.id : cam;
|
||||||
const order = i;
|
const order = i;
|
||||||
|
|
||||||
await db.query('UPDATE camera_settings SET display_order = ? WHERE id = ?', [order, id]);
|
await db.query('UPDATE cctv_settings SET display_order = ? WHERE id = ?', [order, id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.query('COMMIT');
|
await db.query('COMMIT');
|
||||||
@ -43,13 +52,73 @@ router.put('/reorder', hasRole('admin'), async (req, res) => {
|
|||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// --- Zone Management ---
|
||||||
|
|
||||||
|
// Get all zones
|
||||||
|
router.get('/zones', isAuthenticated, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [rows] = await db.query('SELECT * FROM cctv_zones ORDER BY display_order ASC, name ASC');
|
||||||
|
res.json(rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update zones (Sync list)
|
||||||
|
router.put('/zones', hasRole('admin'), async (req, res) => {
|
||||||
|
const { zones } = req.body;
|
||||||
|
if (!Array.isArray(zones)) return res.status(400).json({ error: 'Invalid data' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.query('START TRANSACTION');
|
||||||
|
|
||||||
|
// Rebuild zone table for simplicity since we link by name string in cctv_settings
|
||||||
|
await db.query('DELETE FROM cctv_zones');
|
||||||
|
|
||||||
|
for (let i = 0; i < zones.length; i++) {
|
||||||
|
const z = zones[i];
|
||||||
|
const name = typeof z === 'string' ? z : z.name;
|
||||||
|
const layout = typeof z === 'object' ? (z.layout || '2*2') : '2*2';
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
await db.query('INSERT INTO cctv_zones (name, layout, display_order) VALUES (?, ?, ?)', [name, layout, i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query('COMMIT');
|
||||||
|
res.json({ message: 'Zones updated' });
|
||||||
|
} catch (err) {
|
||||||
|
await db.query('ROLLBACK');
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to update zones' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update specific zone layout (Real-time override)
|
||||||
|
router.patch('/zones/:name/layout', hasRole('admin'), async (req, res) => {
|
||||||
|
const { layout } = req.body;
|
||||||
|
try {
|
||||||
|
const [result] = await db.query('UPDATE cctv_zones SET layout = ? WHERE name = ?', [layout, req.params.name]);
|
||||||
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Zone not found' });
|
||||||
|
res.json({ success: true, message: 'Layout updated' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Failed to update layout' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get single camera
|
// Get single camera
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT * FROM camera_settings WHERE id = ?', [req.params.id]);
|
const [rows] = await db.query('SELECT * FROM cctv_settings WHERE id = ?', [req.params.id]);
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' });
|
if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' });
|
||||||
res.json(rows[0]);
|
|
||||||
|
const camera = rows[0];
|
||||||
|
camera.username = camera.username ? cryptoUtil.decryptMasterKey(camera.username) : camera.username;
|
||||||
|
camera.password = camera.password ? cryptoUtil.decryptMasterKey(camera.password) : camera.password;
|
||||||
|
|
||||||
|
res.json(camera);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
@ -58,10 +127,13 @@ router.get('/:id', async (req, res) => {
|
|||||||
|
|
||||||
// Add camera (Admin only)
|
// Add camera (Admin only)
|
||||||
router.post('/', hasRole('admin'), async (req, res) => {
|
router.post('/', hasRole('admin'), async (req, res) => {
|
||||||
const { name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
|
const { name, zone, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
|
||||||
try {
|
try {
|
||||||
const sql = `INSERT INTO camera_settings (name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
const encryptedUser = username ? cryptoUtil.encryptMasterKey(username) : username;
|
||||||
const [result] = await db.query(sql, [name, ip_address, port || 554, username, password, stream_path || '/stream1', transport_mode || 'tcp', rtsp_encoding || false, quality || 'low']);
|
const encryptedPass = password ? cryptoUtil.encryptMasterKey(password) : password;
|
||||||
|
|
||||||
|
const sql = `INSERT INTO cctv_settings (name, zone, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||||
|
const [result] = await db.query(sql, [name, zone || '기본 구역', ip_address, port || 554, encryptedUser, encryptedPass, stream_path || '/stream1', transport_mode || 'tcp', rtsp_encoding || false, quality || 'low']);
|
||||||
res.status(201).json({ message: 'Camera added', id: result.insertId });
|
res.status(201).json({ message: 'Camera added', id: result.insertId });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -71,10 +143,13 @@ router.post('/', hasRole('admin'), async (req, res) => {
|
|||||||
|
|
||||||
// Update camera (Admin only)
|
// Update camera (Admin only)
|
||||||
router.put('/:id', hasRole('admin'), async (req, res) => {
|
router.put('/:id', hasRole('admin'), async (req, res) => {
|
||||||
const { name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
|
const { name, zone, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
|
||||||
try {
|
try {
|
||||||
const sql = `UPDATE camera_settings SET name=?, ip_address=?, port=?, username=?, password=?, stream_path=?, transport_mode=?, rtsp_encoding=?, quality=? WHERE id=?`;
|
const encryptedUser = username ? cryptoUtil.encryptMasterKey(username) : username;
|
||||||
const [result] = await db.query(sql, [name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality, req.params.id]);
|
const encryptedPass = password ? cryptoUtil.encryptMasterKey(password) : password;
|
||||||
|
|
||||||
|
const sql = `UPDATE cctv_settings SET name=?, zone=?, ip_address=?, port=?, username=?, password=?, stream_path=?, transport_mode=?, rtsp_encoding=?, quality=? WHERE id=?`;
|
||||||
|
const [result] = await db.query(sql, [name, zone, ip_address, port, encryptedUser, encryptedPass, stream_path, transport_mode, rtsp_encoding, quality, req.params.id]);
|
||||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
||||||
|
|
||||||
// Force stream reset (kick clients to trigger reconnect)
|
// Force stream reset (kick clients to trigger reconnect)
|
||||||
@ -95,7 +170,7 @@ router.put('/:id', hasRole('admin'), async (req, res) => {
|
|||||||
router.patch('/:id/status', hasRole('admin'), async (req, res) => {
|
router.patch('/:id/status', hasRole('admin'), async (req, res) => {
|
||||||
const { is_active } = req.body;
|
const { is_active } = req.body;
|
||||||
try {
|
try {
|
||||||
const [result] = await db.query('UPDATE camera_settings SET is_active = ? WHERE id = ?', [is_active, req.params.id]);
|
const [result] = await db.query('UPDATE cctv_settings SET is_active = ? WHERE id = ?', [is_active, req.params.id]);
|
||||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
||||||
|
|
||||||
const streamRelay = req.app.get('streamRelay');
|
const streamRelay = req.app.get('streamRelay');
|
||||||
@ -115,7 +190,7 @@ router.patch('/:id/status', hasRole('admin'), async (req, res) => {
|
|||||||
// Delete camera (Admin only)
|
// Delete camera (Admin only)
|
||||||
router.delete('/:id', hasRole('admin'), async (req, res) => {
|
router.delete('/:id', hasRole('admin'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [result] = await db.query('DELETE FROM camera_settings WHERE id = ?', [req.params.id]);
|
const [result] = await db.query('DELETE FROM cctv_settings WHERE id = ?', [req.params.id]);
|
||||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
||||||
|
|
||||||
// Stop stream
|
// Stop stream
|
||||||
@ -135,10 +210,11 @@ const { exec } = require('child_process');
|
|||||||
|
|
||||||
// ... existing routes ...
|
// ... existing routes ...
|
||||||
|
|
||||||
|
|
||||||
// 7. Ping Test (Troubleshooting)
|
// 7. Ping Test (Troubleshooting)
|
||||||
router.get('/:id/ping', isAuthenticated, async (req, res) => {
|
router.get('/:id/ping', isAuthenticated, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT ip_address FROM camera_settings WHERE id = ?', [req.params.id]);
|
const [rows] = await db.query('SELECT ip_address FROM cctv_settings WHERE id = ?', [req.params.id]);
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' });
|
if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' });
|
||||||
|
|
||||||
const ip = rows[0].ip_address;
|
const ip = rows[0].ip_address;
|
||||||
|
|||||||
@ -3,6 +3,7 @@ const ffmpeg = require('fluent-ffmpeg');
|
|||||||
const staticFfmpegBinary = require('ffmpeg-static'); // Renamed for clarity
|
const staticFfmpegBinary = require('ffmpeg-static'); // Renamed for clarity
|
||||||
const db = require('../../db');
|
const db = require('../../db');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
const cryptoUtil = require('../../utils/cryptoUtil');
|
||||||
|
|
||||||
// We don't set global ffmpeg path here immediately because we might want to switch per-stream or check system paths dynamically
|
// We don't set global ffmpeg path here immediately because we might want to switch per-stream or check system paths dynamically
|
||||||
// (though fluent-ffmpeg usage usually suggests setting it once if possible, but we check inside startFFmpeg for robustness)
|
// (though fluent-ffmpeg usage usually suggests setting it once if possible, but we check inside startFFmpeg for robustness)
|
||||||
@ -76,7 +77,7 @@ class StreamRelay {
|
|||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT * FROM camera_settings WHERE id = ?', [cameraId]);
|
const [rows] = await db.query('SELECT * FROM cctv_settings WHERE id = ?', [cameraId]);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
console.error(`Camera ${cameraId} not found`);
|
console.error(`Camera ${cameraId} not found`);
|
||||||
return;
|
return;
|
||||||
@ -96,8 +97,10 @@ class StreamRelay {
|
|||||||
|
|
||||||
let rtspUrl = 'rtsp://';
|
let rtspUrl = 'rtsp://';
|
||||||
if (camera.username && camera.password) {
|
if (camera.username && camera.password) {
|
||||||
const user = camera.rtsp_encoding ? encodeURIComponent(camera.username) : camera.username;
|
const decUser = cryptoUtil.decryptMasterKey(camera.username);
|
||||||
const pass = camera.rtsp_encoding ? encodeURIComponent(camera.password) : camera.password;
|
const decPass = cryptoUtil.decryptMasterKey(camera.password);
|
||||||
|
const user = camera.rtsp_encoding ? encodeURIComponent(decUser) : decUser;
|
||||||
|
const pass = camera.rtsp_encoding ? encodeURIComponent(decPass) : decPass;
|
||||||
rtspUrl += `${user}:${pass}@`;
|
rtspUrl += `${user}:${pass}@`;
|
||||||
}
|
}
|
||||||
rtspUrl += `${camera.ip_address}:${camera.port || 554}${camera.stream_path || '/stream1'}`;
|
rtspUrl += `${camera.ip_address}:${camera.port || 554}${camera.stream_path || '/stream1'}`;
|
||||||
|
|||||||
@ -63,24 +63,29 @@ router.post('/login', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1.5. Check Session (New)
|
// 1.5. Check Session
|
||||||
router.get('/check', async (req, res) => {
|
router.get('/check', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.session.user) {
|
if (req.session.user) {
|
||||||
// Ensure CSRF token exists, if not generate one (edge case)
|
// Ensure CSRF token exists
|
||||||
if (!req.session.csrfToken) {
|
if (!req.session.csrfToken) {
|
||||||
req.session.csrfToken = generateToken();
|
req.session.csrfToken = generateToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch session timeout from settings
|
// Priority: User's individual timeout > System default
|
||||||
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
|
let timeoutMinutes = 60;
|
||||||
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
|
if (req.session.user.session_timeout) {
|
||||||
|
timeoutMinutes = parseInt(req.session.user.session_timeout);
|
||||||
|
} else {
|
||||||
|
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
|
||||||
|
timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 10;
|
||||||
|
}
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
user: req.session.user,
|
user: req.session.user,
|
||||||
csrfToken: req.session.csrfToken,
|
csrfToken: req.session.csrfToken,
|
||||||
sessionTimeout: timeoutMinutes * 60 * 1000 // Convert to ms
|
sessionTimeout: timeoutMinutes * 60 * 1000
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
res.json({ isAuthenticated: false });
|
res.json({ isAuthenticated: false });
|
||||||
@ -130,7 +135,7 @@ router.post('/verify-supervisor', isAuthenticated, async (req, res) => {
|
|||||||
// 2. List Users (Admin Only)
|
// 2. List Users (Admin Only)
|
||||||
router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT id, name, department, position, phone, role, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
|
const [rows] = await db.query('SELECT id, name, department, position, phone, role, session_timeout, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
|
||||||
|
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
return res.json([]);
|
return res.json([]);
|
||||||
@ -154,7 +159,7 @@ router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
|||||||
|
|
||||||
// 3. Create User
|
// 3. Create User
|
||||||
router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||||
const { id, password, name, department, position, phone, role } = req.body;
|
const { id, password, name, department, position, phone, role, session_timeout } = req.body;
|
||||||
|
|
||||||
if (!id || !password || !name) {
|
if (!id || !password || !name) {
|
||||||
return res.status(400).json({ error: 'Missing required fields' });
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
@ -171,11 +176,11 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
|||||||
const encryptedPhone = await encrypt(phone);
|
const encryptedPhone = await encrypt(phone);
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO users (id, password, name, department, position, phone, role)
|
INSERT INTO users (id, password, name, department, position, phone, role, session_timeout)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await db.query(sql, [id, hashedPassword, name, department, position, encryptedPhone, role || 'user']);
|
await db.query(sql, [id, hashedPassword, name, department, position, encryptedPhone, role || 'user', session_timeout || 10]);
|
||||||
|
|
||||||
res.status(201).json({ message: 'User created' });
|
res.status(201).json({ message: 'User created' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -186,41 +191,20 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
|||||||
|
|
||||||
// 4. Update User
|
// 4. Update User
|
||||||
router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) => {
|
router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||||
const { password, name, department, position, phone, role } = req.body;
|
const { password, name, department, position, phone, role, session_timeout } = req.body;
|
||||||
const userId = req.params.id;
|
const userId = req.params.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch current to keep old values if not provided? Frontend usually sends all.
|
// ... (existing logic)
|
||||||
// We update everything provided.
|
|
||||||
|
|
||||||
// Build query dynamically or just assume full update
|
|
||||||
let updates = [];
|
let updates = [];
|
||||||
let params = [];
|
let params = [];
|
||||||
|
if (password) { updates.push('password = ?'); params.push(hashPassword(password)); }
|
||||||
if (password) {
|
if (name) { updates.push('name = ?'); params.push(name); }
|
||||||
updates.push('password = ?');
|
if (department !== undefined) { updates.push('department = ?'); params.push(department); }
|
||||||
params.push(hashPassword(password));
|
if (position !== undefined) { updates.push('position = ?'); params.push(position); }
|
||||||
}
|
if (phone !== undefined) { updates.push('phone = ?'); params.push(await encrypt(phone)); }
|
||||||
if (name) {
|
if (role) { updates.push('role = ?'); params.push(role); }
|
||||||
updates.push('name = ?');
|
if (session_timeout !== undefined) { updates.push('session_timeout = ?'); params.push(session_timeout); }
|
||||||
params.push(name);
|
|
||||||
}
|
|
||||||
if (department !== undefined) {
|
|
||||||
updates.push('department = ?');
|
|
||||||
params.push(department);
|
|
||||||
}
|
|
||||||
if (position !== undefined) {
|
|
||||||
updates.push('position = ?');
|
|
||||||
params.push(position);
|
|
||||||
}
|
|
||||||
if (phone !== undefined) {
|
|
||||||
updates.push('phone = ?');
|
|
||||||
params.push(await encrypt(phone));
|
|
||||||
}
|
|
||||||
if (role) {
|
|
||||||
updates.push('role = ?');
|
|
||||||
params.push(role);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updates.length === 0) return res.json({ message: 'No changes' });
|
if (updates.length === 0) return res.json({ message: 'No changes' });
|
||||||
|
|
||||||
@ -229,7 +213,53 @@ router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) =>
|
|||||||
|
|
||||||
await db.query(sql, params);
|
await db.query(sql, params);
|
||||||
res.json({ message: 'User updated' });
|
res.json({ message: 'User updated' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4.5. Update My Profile (New)
|
||||||
|
router.put('/profile', isAuthenticated, async (req, res) => {
|
||||||
|
const { name, phone, session_timeout } = req.body;
|
||||||
|
const userId = req.session.user.id;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let updates = [];
|
||||||
|
let params = [];
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
updates.push('name = ?');
|
||||||
|
params.push(name);
|
||||||
|
}
|
||||||
|
if (phone !== undefined) {
|
||||||
|
updates.push('phone = ?');
|
||||||
|
params.push(await encrypt(phone));
|
||||||
|
}
|
||||||
|
if (session_timeout !== undefined) {
|
||||||
|
updates.push('session_timeout = ?');
|
||||||
|
params.push(session_timeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updates.length === 0) return res.json({ message: 'No changes' });
|
||||||
|
|
||||||
|
const sql = `UPDATE users SET ${updates.join(', ')} WHERE id = ?`;
|
||||||
|
params.push(userId);
|
||||||
|
|
||||||
|
await db.query(sql, params);
|
||||||
|
|
||||||
|
// Update session user object
|
||||||
|
const [rows] = await db.query('SELECT id, name, department, position, phone, role, session_timeout FROM users WHERE id = ?', [userId]);
|
||||||
|
req.session.user = rows[0];
|
||||||
|
|
||||||
|
// Also update cookie maxAge if timeout changed
|
||||||
|
if (session_timeout) {
|
||||||
|
req.session.cookie.maxAge = parseInt(session_timeout) * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
req.session.save(() => {
|
||||||
|
res.json({ message: 'Profile updated', user: req.session.user });
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
res.status(500).json({ error: 'Database error' });
|
res.status(500).json({ error: 'Database error' });
|
||||||
|
|||||||
@ -274,7 +274,7 @@ router.get('/modules', isAuthenticated, async (req, res) => {
|
|||||||
const [rows] = await db.query('SELECT * FROM system_modules');
|
const [rows] = await db.query('SELECT * FROM system_modules');
|
||||||
|
|
||||||
const modules = {};
|
const modules = {};
|
||||||
const defaults = ['asset', 'production', 'monitoring'];
|
const defaults = ['asset', 'production', 'cctv'];
|
||||||
|
|
||||||
// Get stored subscriber ID
|
// Get stored subscriber ID
|
||||||
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
||||||
@ -375,7 +375,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
|||||||
const names = {
|
const names = {
|
||||||
'asset': '자산 관리',
|
'asset': '자산 관리',
|
||||||
'production': '생산 관리',
|
'production': '생산 관리',
|
||||||
'monitoring': 'CCTV'
|
'cctv': 'CCTV'
|
||||||
};
|
};
|
||||||
|
|
||||||
await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]);
|
await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]);
|
||||||
|
|||||||
@ -63,6 +63,13 @@ const cryptoUtil = {
|
|||||||
return this._internalProcess(plainKey, true);
|
return this._internalProcess(plainKey, true);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 내부 보안으로 암호화된 키를 복호화하는 함수
|
||||||
|
*/
|
||||||
|
decryptMasterKey(encryptedKey) {
|
||||||
|
return this._internalProcess(encryptedKey, false);
|
||||||
|
},
|
||||||
|
|
||||||
clearCache() {
|
clearCache() {
|
||||||
cachedKey = null;
|
cachedKey = null;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -18,6 +18,7 @@ export interface IModuleRoute {
|
|||||||
*/
|
*/
|
||||||
export interface IModuleDefinition {
|
export interface IModuleDefinition {
|
||||||
moduleName: string; // 紐⑤뱢 ?앸퀎??(?? 'asset-management')
|
moduleName: string; // 紐⑤뱢 ?앸퀎??(?? 'asset-management')
|
||||||
|
label?: string; // 紐⑤뱢 ?쒖떆 紐낆묶 (?? 'CCTV')
|
||||||
basePath: string; // 紐⑤뱢??湲곕낯 寃쎈줈 (?? '/asset')
|
basePath: string; // 紐⑤뱢??湲곕낯 寃쎈줈 (?? '/asset')
|
||||||
routes: IModuleRoute[]; // 紐⑤뱢 ?대? ?쇱슦???뺣낫
|
routes: IModuleRoute[]; // 紐⑤뱢 ?대? ?쇱슦???뺣낫
|
||||||
requiredRoles?: string[]; // 紐⑤뱢 ?묎렐???꾪븳 ?꾩슂 沅뚰븳 (Optional)
|
requiredRoles?: string[]; // 紐⑤뱢 ?묎렐???꾪븳 ?꾩슂 沅뚰븳 (Optional)
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import { createPortal } from 'react-dom';
|
|||||||
import { useAuth } from '../../../shared/auth/AuthContext';
|
import { useAuth } from '../../../shared/auth/AuthContext';
|
||||||
|
|
||||||
interface AssetBasicInfoProps {
|
interface AssetBasicInfoProps {
|
||||||
asset: Asset & { image?: string, consumables?: any[] };
|
asset: Asset & { image?: string, accessories?: any[] };
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -21,6 +21,39 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
|||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [editData, setEditData] = useState(asset);
|
const [editData, setEditData] = useState(asset);
|
||||||
const [isZoomed, setIsZoomed] = useState(false);
|
const [isZoomed, setIsZoomed] = useState(false);
|
||||||
|
const [allAssets, setAllAssets] = useState<Asset[]>([]);
|
||||||
|
const [accessories, setAccessories] = useState<any[]>([]);
|
||||||
|
const [showAccModal, setShowAccModal] = useState(false);
|
||||||
|
const [newAcc, setNewAcc] = useState({ name: '', spec: '', quantity: 1 });
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
loadAccessories();
|
||||||
|
}, [asset.id]);
|
||||||
|
|
||||||
|
const loadAccessories = async () => {
|
||||||
|
try {
|
||||||
|
const data = await assetApi.getAccessories(asset.id);
|
||||||
|
setAccessories(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load accessories", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (isEditing) {
|
||||||
|
const loadAllAssets = async () => {
|
||||||
|
try {
|
||||||
|
const data = await assetApi.getAll();
|
||||||
|
setAllAssets(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load assets", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAllAssets();
|
||||||
|
}
|
||||||
|
}, [isEditing]);
|
||||||
|
|
||||||
|
const isFacility = asset.category === '설비';
|
||||||
|
|
||||||
const handleChange = (
|
const handleChange = (
|
||||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||||
@ -64,6 +97,28 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
|||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleAddAccessory = async () => {
|
||||||
|
if (!newAcc.name) return alert('품명을 입력해주세요.');
|
||||||
|
try {
|
||||||
|
await assetApi.addAccessory(asset.id, newAcc);
|
||||||
|
setNewAcc({ name: '', spec: '', quantity: 1 });
|
||||||
|
setShowAccModal(false);
|
||||||
|
loadAccessories();
|
||||||
|
} catch (err) {
|
||||||
|
alert('등록 실패');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteAccessory = async (id: number) => {
|
||||||
|
if (!confirm('삭제하시겠습니까?')) return;
|
||||||
|
try {
|
||||||
|
await assetApi.deleteAccessory(id);
|
||||||
|
loadAccessories();
|
||||||
|
} catch (err) {
|
||||||
|
alert('삭제 실패');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center mb-4 gap-1" style={{ justifyContent: 'flex-end' }}>
|
<div className="flex items-center mb-4 gap-1" style={{ justifyContent: 'flex-end' }}>
|
||||||
@ -75,7 +130,7 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
|||||||
) : (
|
) : (
|
||||||
canEdit && <Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}>수정</Button>
|
canEdit && <Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}>수정</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="secondary" size="sm" icon={<Printer size={16} />}>출력</Button>
|
<Button variant="secondary" size="sm" icon={<Printer size={16} />} onClick={() => window.print()}>출력</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="content-card print-friendly">
|
<Card className="content-card print-friendly">
|
||||||
@ -327,51 +382,202 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-full !h-14 flex items-center px-2 border-0 !border-none">
|
<div className="w-full !h-14 flex items-center px-2 border-0 !border-none">
|
||||||
<span className={`badge ${editData.status === 'active' ? 'badge-success' : editData.status === 'disposed' ? 'badge-neutral' : 'badge-warning'} !text - lg!font - medium px - 4 py - 2`}>
|
<span className={`badge ${editData.status === 'active' ? 'badge-success' : editData.status === 'disposed' ? 'badge-neutral' : 'badge-warning'} !text-lg !font-medium px-4 py-2`}>
|
||||||
{editData.status === 'active' ? '정상 가동' : editData.status === 'disposed' ? '폐기 (말소)' : editData.status === 'maintain' ? '점검 중' : '수리 필요'}
|
{editData.status === 'active' ? '정상 가동' : editData.status === 'disposed' ? '폐기 (말소)' : editData.status === 'maintain' ? '점검 중' : '수리 필요'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{isFacility && (
|
||||||
|
<tr style={{ height: '70px' }}>
|
||||||
|
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">상위 설비</th>
|
||||||
|
<td colSpan={5} className="border border-slate-300 p-2">
|
||||||
|
{isEditing ? (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<select
|
||||||
|
name="parentId"
|
||||||
|
value={editData.parentId || ''}
|
||||||
|
onChange={handleChange}
|
||||||
|
className="block px-3 py-1 bg-transparent border border-slate-300 rounded-none !text-lg !font-medium text-slate-700 outline-none focus:bg-slate-50 transition-colors appearance-none shadow-sm"
|
||||||
|
style={{ WebkitAppearance: 'none', MozAppearance: 'none', appearance: 'none', backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`, backgroundPosition: 'right 0.5rem center', backgroundRepeat: 'no-repeat', backgroundSize: '1.5em 1.5em', paddingRight: '2.5rem' }}
|
||||||
|
>
|
||||||
|
<option value="">(없음 - 메인 설비)</option>
|
||||||
|
{allAssets
|
||||||
|
.filter(a => a.category === '설비' && a.id !== asset.id)
|
||||||
|
.map(a => (
|
||||||
|
<option key={a.id} value={a.id}>[{a.id}] {a.name}</option>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||||
|
{asset.parentId ? (
|
||||||
|
<a href={`/asset/detail/${asset.parentId}`} className="text-blue-600 hover:underline">
|
||||||
|
[{asset.parentId}] {asset.parentName || '상위 설비'}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-400">메인 설비 (상위 설비 없음)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Sub-Equipment Section (Only for Facilities) */}
|
||||||
|
{isFacility && (
|
||||||
|
<div className="sub-equipment-section mt-8">
|
||||||
|
<div className="flex justify-between items-center mb-2">
|
||||||
|
<h3 className="section-title text-lg font-bold">부속 설비 내역</h3>
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
icon={<Plus size={14} />}
|
||||||
|
onClick={() => {
|
||||||
|
// Navigate to register with pre-filled parent
|
||||||
|
// Note: Assuming navigation or handling via state
|
||||||
|
window.location.href = `/asset/register?parentId=${asset.id}&categoryId=${allAssets.find(a => a.category === '설비')?.id || ''}`;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
부속 설비 등록
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<table className="doc-table w-full text-center border-collapse border border-slate-300">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="border border-slate-300 p-2 bg-slate-50">관리번호</th>
|
||||||
|
<th className="border border-slate-300 p-2 bg-slate-50">설비명</th>
|
||||||
|
<th className="border border-slate-300 p-2 bg-slate-50">위치</th>
|
||||||
|
<th className="border border-slate-300 p-2 bg-slate-50">상태</th>
|
||||||
|
<th className="border border-slate-300 p-2 bg-slate-50">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{asset.children && asset.children.length > 0 ? (
|
||||||
|
asset.children.map(child => (
|
||||||
|
<tr key={child.id}>
|
||||||
|
<td className="border border-slate-300 p-2 font-mono">{child.id}</td>
|
||||||
|
<td className="border border-slate-300 p-2 font-medium">{child.name}</td>
|
||||||
|
<td className="border border-slate-300 p-2">{child.location}</td>
|
||||||
|
<td className="border border-slate-300 p-2">
|
||||||
|
<span className={`badge ${child.status === 'active' ? 'badge-success' : 'badge-warning'}`}>
|
||||||
|
{child.status === 'active' ? '정상' : '점검/이동'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="border border-slate-300 p-2">
|
||||||
|
<a href={`/asset/detail/${child.id}`} className="text-blue-600 underline">이동</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="p-8 text-slate-400">등록된 부속 설비가 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="section-divider my-6 border-b border-slate-200"></div>
|
<div className="section-divider my-6 border-b border-slate-200"></div>
|
||||||
|
|
||||||
{/* Consumables Section */}
|
{/* Accessories Section (For non-facility or all?) - User said skip hierarchical for others, use simple accessories */}
|
||||||
<div className="consumables-section">
|
{!isFacility && (
|
||||||
<div className="flex justify-between items-center mb-2">
|
<div className="accessories-section">
|
||||||
<h3 className="section-title text-lg font-bold">관련 소모품 관리</h3>
|
<div className="flex justify-between items-center mb-2">
|
||||||
{canEdit && <Button size="sm" variant="secondary" icon={<Plus size={14} />}>소모품 추가</Button>}
|
<h3 className="section-title text-lg font-bold">부속품 관리</h3>
|
||||||
</div>
|
{canEdit && (
|
||||||
<table className="doc-table w-full text-center border-collapse border border-slate-300">
|
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => setShowAccModal(true)}>
|
||||||
<thead>
|
부속품 추가
|
||||||
<tr>
|
</Button>
|
||||||
<th className="border border-slate-300 p-2 bg-slate-50">품명</th>
|
)}
|
||||||
<th className="border border-slate-300 p-2 bg-slate-50">규격</th>
|
</div>
|
||||||
<th className="border border-slate-300 p-2 bg-slate-50">현재고</th>
|
<table className="doc-table w-full text-center border-collapse border border-slate-300">
|
||||||
<th className="border border-slate-300 p-2 bg-slate-50">관리</th>
|
<thead>
|
||||||
</tr>
|
<tr>
|
||||||
</thead>
|
<th className="border border-slate-300 p-2 bg-slate-50">품명</th>
|
||||||
<tbody>
|
<th className="border border-slate-300 p-2 bg-slate-50">규격</th>
|
||||||
{asset.consumables?.map(item => (
|
<th className="border border-slate-300 p-2 bg-slate-50">수량</th>
|
||||||
<tr key={item.id}>
|
<th className="border border-slate-300 p-2 bg-slate-50">관리</th>
|
||||||
<td className="border border-slate-300 p-2">{item.name}</td>
|
|
||||||
<td className="border border-slate-300 p-2">{item.spec}</td>
|
|
||||||
<td className="border border-slate-300 p-2">{item.qty}개</td>
|
|
||||||
<td className="border border-slate-300 p-2">
|
|
||||||
<button className="text-slate-400 hover:text-red-500 text-sm underline">삭제</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
</thead>
|
||||||
</tbody>
|
<tbody>
|
||||||
</table>
|
{accessories.length > 0 ? (
|
||||||
</div>
|
accessories.map(item => (
|
||||||
|
<tr key={item.id}>
|
||||||
|
<td className="border border-slate-300 p-2">{item.name}</td>
|
||||||
|
<td className="border border-slate-300 p-2">{item.spec || '-'}</td>
|
||||||
|
<td className="border border-slate-300 p-2">{item.quantity}개</td>
|
||||||
|
<td className="border border-slate-300 p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteAccessory(item.id)}
|
||||||
|
className="text-slate-400 hover:text-red-500 text-sm underline"
|
||||||
|
>
|
||||||
|
삭제
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="p-8 text-slate-400">등록된 부속품이 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Accessory Add Modal */}
|
||||||
|
{showAccModal && createPortal(
|
||||||
|
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="bg-white rounded-lg shadow-xl w-[400px] p-6">
|
||||||
|
<h3 className="text-xl font-bold mb-4">부속품 추가</h3>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">품명 *</label>
|
||||||
|
<input
|
||||||
|
className="w-full border p-2 rounded"
|
||||||
|
value={newAcc.name}
|
||||||
|
onChange={e => setNewAcc({ ...newAcc, name: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">규격 / 사양</label>
|
||||||
|
<input
|
||||||
|
className="w-full border p-2 rounded"
|
||||||
|
value={newAcc.spec}
|
||||||
|
onChange={e => setNewAcc({ ...newAcc, spec: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">수량</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="w-full border p-2 rounded"
|
||||||
|
value={newAcc.quantity}
|
||||||
|
onChange={e => setNewAcc({ ...newAcc, quantity: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 mt-6">
|
||||||
|
<Button variant="secondary" onClick={() => setShowAccModal(false)}>취소</Button>
|
||||||
|
<Button onClick={handleAddAccessory}>등록</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Image Zoom Modal - Moved to Portal */}
|
{/* Image Zoom Modal - Moved to Portal */}
|
||||||
{isZoomed && editData.image && createPortal(
|
{isZoomed && editData.image && createPortal(
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import { Card } from '../../../shared/ui/Card';
|
import { Card } from '../../../shared/ui/Card';
|
||||||
import { Button } from '../../../shared/ui/Button';
|
import { Button } from '../../../shared/ui/Button';
|
||||||
import { Input } from '../../../shared/ui/Input';
|
import { Input } from '../../../shared/ui/Input';
|
||||||
import { Search, Plus, Filter, Download, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Search, Plus, Filter, Download, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight, Printer } from 'lucide-react';
|
||||||
import { useAuth } from '../../../shared/auth/AuthContext';
|
import { useAuth } from '../../../shared/auth/AuthContext';
|
||||||
import './AssetListPage.css';
|
import './AssetListPage.css';
|
||||||
|
|
||||||
@ -280,6 +280,7 @@ export function AssetListPage() {
|
|||||||
>
|
>
|
||||||
필터
|
필터
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button variant="secondary" icon={<Printer size={16} />} onClick={() => window.print()}>출력</Button>
|
||||||
<Button variant="secondary" icon={<Download size={16} />} onClick={handleExcelDownload}>엑셀 다운로드</Button>
|
<Button variant="secondary" icon={<Download size={16} />} onClick={handleExcelDownload}>엑셀 다운로드</Button>
|
||||||
{canRegister && (
|
{canRegister && (
|
||||||
<Button onClick={() => navigate('/asset/register')} icon={<Plus size={16} />}>자산 등록</Button>
|
<Button onClick={() => navigate('/asset/register')} icon={<Plus size={16} />}>자산 등록</Button>
|
||||||
|
|||||||
@ -19,6 +19,7 @@ export function AssetRegisterPage() {
|
|||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
id: '', // Asset ID (Auto-generated)
|
id: '', // Asset ID (Auto-generated)
|
||||||
|
parentId: '',
|
||||||
name: '',
|
name: '',
|
||||||
categoryId: '', // Use ID for selection
|
categoryId: '', // Use ID for selection
|
||||||
model: '',
|
model: '',
|
||||||
@ -33,6 +34,20 @@ export function AssetRegisterPage() {
|
|||||||
manufacturer: ''
|
manufacturer: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [allAssets, setAllAssets] = useState<Asset[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadAllAssets = async () => {
|
||||||
|
try {
|
||||||
|
const data = await assetApi.getAll();
|
||||||
|
setAllAssets(data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to load assets for parent selection", err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadAllAssets();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Auto-generate Asset ID
|
// Auto-generate Asset ID
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If category is required by rule but not selected, can't generate fully
|
// If category is required by rule but not selected, can't generate fully
|
||||||
@ -106,6 +121,7 @@ export function AssetRegisterPage() {
|
|||||||
|
|
||||||
const payload: Partial<Asset> = {
|
const payload: Partial<Asset> = {
|
||||||
id: formData.id,
|
id: formData.id,
|
||||||
|
parentId: isFacility ? formData.parentId : undefined,
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
category: selectedCategory ? selectedCategory.name : '미지정',
|
category: selectedCategory ? selectedCategory.name : '미지정',
|
||||||
model: formData.model,
|
model: formData.model,
|
||||||
@ -129,6 +145,8 @@ export function AssetRegisterPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isFacility = categories.find(c => c.id === formData.categoryId)?.name === '설비';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header-right">
|
<div className="page-header-right">
|
||||||
@ -155,6 +173,22 @@ export function AssetRegisterPage() {
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{isFacility && (
|
||||||
|
<Select
|
||||||
|
label="상위 설비 (메인 설비)"
|
||||||
|
name="parentId"
|
||||||
|
value={formData.parentId}
|
||||||
|
onChange={handleChange}
|
||||||
|
options={[
|
||||||
|
{ label: '(없음 - 메인 설비로 등록)', value: '' },
|
||||||
|
...allAssets
|
||||||
|
.filter(a => a.category === '설비' && a.id !== formData.id)
|
||||||
|
.map(a => ({ label: `[${a.id}] ${a.name}`, value: a.id }))
|
||||||
|
]}
|
||||||
|
placeholder="메인 설비가 있는 경우 선택"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="자산 관리 번호 (자동 생성)"
|
label="자산 관리 번호 (자동 생성)"
|
||||||
name="id"
|
name="id"
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
import type { IModuleDefinition } from '../../core/types';
|
import type { IModuleDefinition } from '../../core/types';
|
||||||
import { MonitoringPage } from './pages/MonitoringPage';
|
import { MonitoringPage } from './pages/MonitoringPage';
|
||||||
|
import { CameraManagementPage } from './pages/CameraManagementPage';
|
||||||
|
import { CctvSettingsPage } from './pages/CctvSettingsPage';
|
||||||
|
|
||||||
export const cctvModule: IModuleDefinition = {
|
export const cctvModule: IModuleDefinition = {
|
||||||
moduleName: 'monitoring-cctv',
|
moduleName: 'cctv',
|
||||||
|
label: 'CCTV',
|
||||||
basePath: '/monitoring',
|
basePath: '/monitoring',
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/live', element: <MonitoringPage />, label: '실시간 관제' },
|
{ path: '/live', element: <MonitoringPage />, label: '실시간 관제' },
|
||||||
|
{ path: '/manage', element: <CameraManagementPage />, label: '장치 관리' },
|
||||||
|
{ path: '/settings', element: <CctvSettingsPage />, label: '기본 설정' },
|
||||||
],
|
],
|
||||||
requiredRoles: ['admin', 'operator', 'user']
|
requiredRoles: ['admin', 'operator', 'user']
|
||||||
};
|
};
|
||||||
|
|||||||
392
src/modules/cctv/pages/CameraManagementPage.tsx
Normal file
392
src/modules/cctv/pages/CameraManagementPage.tsx
Normal file
@ -0,0 +1,392 @@
|
|||||||
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { Card } from '../../../shared/ui/Card';
|
||||||
|
import { Button } from '../../../shared/ui/Button';
|
||||||
|
import { Input } from '../../../shared/ui/Input';
|
||||||
|
import { apiClient } from '../../../shared/api/client';
|
||||||
|
import {
|
||||||
|
Plus, Edit2, Trash2, X, Check, Search,
|
||||||
|
Settings, Video, RefreshCcw, Power,
|
||||||
|
PowerOff, LayoutGrid
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '../../../shared/auth/AuthContext';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
|
interface Camera {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
ip_address: string;
|
||||||
|
port: number;
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
stream_path?: string;
|
||||||
|
transport_mode?: 'tcp' | 'udp' | 'auto';
|
||||||
|
rtsp_encoding?: boolean;
|
||||||
|
quality?: 'low' | 'medium' | 'original';
|
||||||
|
is_active?: boolean | number;
|
||||||
|
display_order?: number;
|
||||||
|
zone?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CameraManagementPage() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [cameras, setCameras] = useState<Camera[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [availableZones, setAvailableZones] = useState<string[]>([]);
|
||||||
|
const fetchedRef = useRef(false);
|
||||||
|
|
||||||
|
// Modal State
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const [formData, setFormData] = useState<Partial<Camera>>({
|
||||||
|
name: '',
|
||||||
|
ip_address: '',
|
||||||
|
port: 554,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
stream_path: '/stream1',
|
||||||
|
transport_mode: 'tcp',
|
||||||
|
rtsp_encoding: false,
|
||||||
|
quality: 'low',
|
||||||
|
zone: '기본 구역'
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (fetchedRef.current) return;
|
||||||
|
fetchedRef.current = true;
|
||||||
|
fetchCameras();
|
||||||
|
fetchZones();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchZones = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/cameras/zones');
|
||||||
|
setAvailableZones(res.data.map((z: any) => z.name));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch zones', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchCameras = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/cameras');
|
||||||
|
setCameras(res.data);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch cameras', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAdd = () => {
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
ip_address: '',
|
||||||
|
port: 554,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
stream_path: '/stream1',
|
||||||
|
transport_mode: 'tcp',
|
||||||
|
rtsp_encoding: false,
|
||||||
|
quality: 'low',
|
||||||
|
zone: '기본 구역'
|
||||||
|
});
|
||||||
|
setIsEditing(false);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEdit = (camera: Camera) => {
|
||||||
|
setFormData(camera);
|
||||||
|
setIsEditing(true);
|
||||||
|
setIsModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: number) => {
|
||||||
|
if (!confirm('정말 이 카메라를 삭제하시겠습니까?')) return;
|
||||||
|
try {
|
||||||
|
await apiClient.delete(`/cameras/${id}`);
|
||||||
|
fetchCameras();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to delete camera', error);
|
||||||
|
alert('삭제 실패');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleStatus = async (camera: Camera) => {
|
||||||
|
const newStatus = !(camera.is_active !== 0 && camera.is_active !== false);
|
||||||
|
try {
|
||||||
|
await apiClient.patch(`/cameras/${camera.id}/status`, { is_active: newStatus });
|
||||||
|
fetchCameras();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to toggle status', err);
|
||||||
|
alert('상태 변경 실패');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
if (isEditing) {
|
||||||
|
await apiClient.put(`/cameras/${formData.id}`, formData);
|
||||||
|
alert('수정되었습니다.');
|
||||||
|
} else {
|
||||||
|
await apiClient.post('/cameras', formData);
|
||||||
|
alert('등록되었습니다.');
|
||||||
|
}
|
||||||
|
setIsModalOpen(false);
|
||||||
|
fetchCameras();
|
||||||
|
} catch (error: any) {
|
||||||
|
alert(`오류: ${error.response?.data?.error || error.message}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredCameras = cameras.filter(cam =>
|
||||||
|
cam.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
|
cam.ip_address.includes(searchTerm)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container p-6 max-w-7xl mx-auto">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Video className="text-indigo-600" />
|
||||||
|
CCTV 통합 관리
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 mt-1">시스템에 등록된 모든 CCTV 장치를 목록 형태로 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => navigate('/monitoring/live')} icon={<LayoutGrid size={16} />}>실시간 관제</Button>
|
||||||
|
{(user?.role === 'admin' || user?.role === 'supervisor') && (
|
||||||
|
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}>장치 추가</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="mb-6 p-4 border-slate-200">
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||||
|
<Input
|
||||||
|
className="pl-10"
|
||||||
|
placeholder="카메라 이름 또는 IP 주소로 검색..."
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={fetchCameras} icon={<RefreshCcw size={16} className={loading ? 'animate-spin' : ''} />}>
|
||||||
|
새로고침
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="overflow-hidden shadow-sm border-slate-200">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm text-left">
|
||||||
|
<thead className="text-xs text-slate-500 uppercase bg-slate-50/80 border-b border-slate-200">
|
||||||
|
<tr>
|
||||||
|
<th className="px-6 py-4">상태</th>
|
||||||
|
<th className="px-6 py-4">카메라 명칭</th>
|
||||||
|
<th className="px-6 py-4">구역</th>
|
||||||
|
<th className="px-6 py-4">네트워크 정보 (IP:Port)</th>
|
||||||
|
<th className="px-6 py-4 text-center">관리</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 bg-white">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-slate-400">데이터를 불러오는 중...</td>
|
||||||
|
</tr>
|
||||||
|
) : filteredCameras.map((camera) => {
|
||||||
|
const isActive = camera.is_active !== 0 && camera.is_active !== false;
|
||||||
|
return (
|
||||||
|
<tr key={camera.id} className="hover:bg-slate-50/50 transition-colors">
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleStatus(camera)}
|
||||||
|
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-bold ring-1 transition-all ${isActive
|
||||||
|
? 'bg-green-50 text-green-700 ring-green-200 hover:bg-green-100'
|
||||||
|
: 'bg-slate-100 text-slate-500 ring-slate-200 hover:bg-slate-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isActive ? <Power size={12} /> : <PowerOff size={12} />}
|
||||||
|
{isActive ? '운영 중' : '중지됨'}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="font-bold text-slate-900">{camera.name}</div>
|
||||||
|
<div className="text-[11px] text-slate-500 mt-0.5">ID: {camera.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded-md text-[11px] font-medium border border-slate-200">
|
||||||
|
{camera.zone || '기본 구역'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-mono text-slate-700">
|
||||||
|
{camera.ip_address}:{camera.port}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex justify-center gap-1">
|
||||||
|
{(user?.role === 'admin' || user?.role === 'supervisor') && (
|
||||||
|
<>
|
||||||
|
<button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(camera)}>
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(camera.id)}>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{!loading && filteredCameras.length === 0 && (
|
||||||
|
<tr className="no-result-row">
|
||||||
|
<td colSpan={5} className="px-6 py-12 text-center text-slate-400">검색 결과가 없습니다.</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Modal */}
|
||||||
|
{isModalOpen && (
|
||||||
|
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||||
|
<Card className="w-full max-w-lg shadow-2xl border-slate-200 overflow-hidden">
|
||||||
|
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||||
|
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||||
|
<Settings size={20} className="text-indigo-600" />
|
||||||
|
{isEditing ? '장치 설정 수정' : '새 장치 등록'}
|
||||||
|
</h2>
|
||||||
|
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1">
|
||||||
|
<X size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">카메라 이름 <span className="text-red-500">*</span></label>
|
||||||
|
<Input
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="예: 정문 CCTV"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">설치 구역 <span className="text-red-500">*</span></label>
|
||||||
|
<select
|
||||||
|
className="w-full bg-white border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all appearance-none"
|
||||||
|
value={formData.zone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, zone: e.target.value })}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="" disabled>구역을 선택하세요</option>
|
||||||
|
{availableZones.map(zone => (
|
||||||
|
<option key={zone} value={zone}>{zone}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">IP 주소 <span className="text-red-500">*</span></label>
|
||||||
|
<Input
|
||||||
|
value={formData.ip_address}
|
||||||
|
onChange={(e) => setFormData({ ...formData, ip_address: e.target.value })}
|
||||||
|
placeholder="192.168.1.100"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">포트 (RTSP)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.port}
|
||||||
|
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 554 })}
|
||||||
|
placeholder="554"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4 pt-2 border-t border-slate-50">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">RTSP 계정명</label>
|
||||||
|
<Input
|
||||||
|
value={formData.username}
|
||||||
|
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
||||||
|
placeholder="admin"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">RTSP 비밀번호</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={formData.password}
|
||||||
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 pt-4 border-t border-slate-50">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">스트림 경로</label>
|
||||||
|
<Input
|
||||||
|
value={formData.stream_path}
|
||||||
|
onChange={(e) => setFormData({ ...formData, stream_path: e.target.value })}
|
||||||
|
placeholder="/stream1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">전송 방식</label>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||||
|
value={formData.transport_mode}
|
||||||
|
onChange={(e) => setFormData({ ...formData, transport_mode: e.target.value as any })}
|
||||||
|
>
|
||||||
|
<option value="tcp">TCP (권장)</option>
|
||||||
|
<option value="udp">UDP</option>
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">화질 등급</label>
|
||||||
|
<select
|
||||||
|
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-indigo-500/20"
|
||||||
|
value={formData.quality}
|
||||||
|
onChange={(e) => setFormData({ ...formData, quality: e.target.value as any })}
|
||||||
|
>
|
||||||
|
<option value="low">Low (640p)</option>
|
||||||
|
<option value="medium">Medium (HD)</option>
|
||||||
|
<option value="original">Original</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="rtsp_enc"
|
||||||
|
checked={formData.rtsp_encoding}
|
||||||
|
onChange={(e) => setFormData({ ...formData, rtsp_encoding: e.target.checked })}
|
||||||
|
className="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
<label htmlFor="rtsp_enc" className="text-xs font-medium text-slate-600 cursor-pointer">특수문자 인코딩 사용 (경로에 @ 등이 포함된 경우)</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||||
|
<Button type="submit" className="bg-indigo-600" icon={<Check size={16} />}>{isEditing ? '변경사항 저장' : '등록 완료'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
147
src/modules/cctv/pages/CctvSettingsPage.tsx
Normal file
147
src/modules/cctv/pages/CctvSettingsPage.tsx
Normal file
@ -0,0 +1,147 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card } from '../../../shared/ui/Card';
|
||||||
|
import { Button } from '../../../shared/ui/Button';
|
||||||
|
import { Input } from '../../../shared/ui/Input';
|
||||||
|
import { apiClient } from '../../../shared/api/client';
|
||||||
|
import { Plus, Trash2, Save, GripVertical, Settings } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ZoneConfig {
|
||||||
|
name: string;
|
||||||
|
layout: '1' | '1*2' | '2*2';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CctvSettingsPage() {
|
||||||
|
const [zones, setZones] = useState<ZoneConfig[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchZones();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const fetchZones = async () => {
|
||||||
|
try {
|
||||||
|
const res = await apiClient.get('/cameras/zones');
|
||||||
|
setZones(res.data.map((z: any) => ({
|
||||||
|
name: z.name,
|
||||||
|
layout: z.layout || '2*2'
|
||||||
|
})));
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch zones', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddZone = () => {
|
||||||
|
setZones([...zones, { name: '', layout: '2*2' }]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRemoveZone = (index: number) => {
|
||||||
|
const newZones = [...zones];
|
||||||
|
newZones.splice(index, 1);
|
||||||
|
setZones(newZones);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoneNameChange = (index: number, value: string) => {
|
||||||
|
const newZones = [...zones];
|
||||||
|
newZones[index].name = value;
|
||||||
|
setZones(newZones);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleZoneLayoutChange = (index: number, value: '1' | '1*2' | '2*2') => {
|
||||||
|
const newZones = [...zones];
|
||||||
|
newZones[index].layout = value;
|
||||||
|
setZones(newZones);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (loading) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const filteredZones = zones.filter(z => z.name.trim() !== '');
|
||||||
|
if (filteredZones.length === 0) {
|
||||||
|
alert('최소 하나 이상의 구역이 필요합니다.');
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await apiClient.put('/cameras/zones', { zones: filteredZones });
|
||||||
|
alert('구역 및 레이아웃 설정이 저장되었습니다.');
|
||||||
|
fetchZones();
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
alert('저장 실패');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-container p-6 max-w-5xl mx-auto">
|
||||||
|
<div className="mb-6 text-indigo-600">
|
||||||
|
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
||||||
|
<Settings className="text-indigo-600" />
|
||||||
|
CCTV 기본 설정
|
||||||
|
</h1>
|
||||||
|
<p className="text-slate-500 mt-1">CCTV 모듈의 전역 설정을 관리합니다.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="p-6 border-slate-200">
|
||||||
|
<h2 className="text-lg font-bold text-slate-800 mb-2 font-noto">설치 구역 및 레이아웃 관리</h2>
|
||||||
|
<p className="text-sm text-slate-500 mb-6 font-medium">
|
||||||
|
관제 화면에서 사용할 구역과 각 구역별 화면 분할(레이아웃) 방식을 설정합니다.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-3 mb-8">
|
||||||
|
<div className="grid grid-cols-[40px_1fr_200px_40px] gap-4 px-2 mb-2 text-xs font-bold text-slate-400 uppercase tracking-wider">
|
||||||
|
<div className="text-center">#</div>
|
||||||
|
<div>구역 명칭</div>
|
||||||
|
<div>분할 레이아웃 (행*열)</div>
|
||||||
|
<div></div>
|
||||||
|
</div>
|
||||||
|
{zones.map((zone, index) => (
|
||||||
|
<div key={index} className="grid grid-cols-[40px_1fr_200px_40px] gap-4 items-center animate-in slide-in-from-left-2 duration-200" style={{ animationDelay: `${index * 50}ms` }}>
|
||||||
|
<div className="p-2 text-slate-300 flex justify-center">
|
||||||
|
<GripVertical size={20} />
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
value={zone.name}
|
||||||
|
onChange={(e) => handleZoneNameChange(index, e.target.value)}
|
||||||
|
placeholder="예: 정문, A구역 등"
|
||||||
|
className="flex-1"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
value={zone.layout}
|
||||||
|
onChange={(e) => handleZoneLayoutChange(index, e.target.value as any)}
|
||||||
|
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 transition-all font-medium"
|
||||||
|
>
|
||||||
|
<option value="1">1 (1x1)</option>
|
||||||
|
<option value="1*2">1*2 (1x2)</option>
|
||||||
|
<option value="2*2">2*2 (2x2)</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => handleRemoveZone(index)}
|
||||||
|
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{zones.length === 0 && (
|
||||||
|
<div className="text-center py-12 text-slate-400 border-2 border-dashed border-slate-100 rounded-xl bg-slate-50/50">
|
||||||
|
등록된 구역이 없습니다. 구역을 추가해 주세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-between items-center pt-6 border-t border-slate-100">
|
||||||
|
<Button variant="secondary" onClick={handleAddZone} icon={<Plus size={18} />}>
|
||||||
|
구역 추가
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave} disabled={loading} icon={<Save size={18} />}>
|
||||||
|
{loading ? '저장 중...' : '설정 저장'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,7 +1,8 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { apiClient } from '../../../shared/api/client';
|
import { apiClient } from '../../../shared/api/client';
|
||||||
import { JSMpegPlayer } from '../components/JSMpegPlayer';
|
import { JSMpegPlayer } from '../components/JSMpegPlayer';
|
||||||
import { Plus, Settings, Trash2, X, Video } from 'lucide-react';
|
import { Video, LayoutGrid, ChevronDown } from 'lucide-react';
|
||||||
import { useAuth } from '../../../shared/auth/AuthContext';
|
import { useAuth } from '../../../shared/auth/AuthContext';
|
||||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
|
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
|
||||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy } from '@dnd-kit/sortable';
|
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy } from '@dnd-kit/sortable';
|
||||||
@ -20,6 +21,7 @@ interface Camera {
|
|||||||
quality?: 'low' | 'medium' | 'original';
|
quality?: 'low' | 'medium' | 'original';
|
||||||
display_order?: number;
|
display_order?: number;
|
||||||
is_active?: boolean | number;
|
is_active?: boolean | number;
|
||||||
|
zone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wrap Camera Card with Sortable
|
// Wrap Camera Card with Sortable
|
||||||
@ -38,7 +40,7 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="bg-white rounded-xl shadow-lg overflow-hidden border border-slate-200">
|
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className={`relative bg-black overflow-hidden border ${disabled ? 'border-slate-800' : 'border-slate-700 hover:border-indigo-500'} transition-colors group h-full w-full`}>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -47,22 +49,12 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr
|
|||||||
export function MonitoringPage() {
|
export function MonitoringPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [cameras, setCameras] = useState<Camera[]>([]);
|
const [cameras, setCameras] = useState<Camera[]>([]);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [activeZone, setActiveZone] = useState<string>('');
|
||||||
const [editingCamera, setEditingCamera] = useState<Camera | null>(null);
|
const [availableZones, setAvailableZones] = useState<{ name: string, layout: string }[]>([]);
|
||||||
const [formData, setFormData] = useState<Partial<Camera>>({
|
const [showLayoutMenu, setShowLayoutMenu] = useState(false);
|
||||||
name: '',
|
const fetchedRef = useRef(false);
|
||||||
ip_address: '',
|
|
||||||
port: 554,
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
stream_path: '/stream1',
|
|
||||||
transport_mode: 'tcp',
|
|
||||||
rtsp_encoding: false,
|
|
||||||
quality: 'low'
|
|
||||||
});
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const [streamVersions, setStreamVersions] = useState<{ [key: number]: number }>({});
|
const [streamVersions] = useState<{ [key: number]: number }>({});
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
@ -72,6 +64,8 @@ export function MonitoringPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (fetchedRef.current) return;
|
||||||
|
fetchedRef.current = true;
|
||||||
fetchCameras();
|
fetchCameras();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -84,61 +78,49 @@ export function MonitoringPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const fetchZones = async () => {
|
||||||
const { name, value } = e.target;
|
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
try {
|
||||||
if (editingCamera) {
|
const res = await apiClient.get('/cameras/zones');
|
||||||
await apiClient.put(`/cameras/${editingCamera.id}`, formData);
|
const zones = res.data.map((z: any) => ({
|
||||||
// Force stream refresh for this camera
|
name: z.name,
|
||||||
setStreamVersions(prev => ({ ...prev, [editingCamera.id]: Date.now() }));
|
layout: z.layout || '2*2'
|
||||||
} else {
|
}));
|
||||||
await apiClient.post('/cameras', formData);
|
setAvailableZones(zones);
|
||||||
|
|
||||||
|
// Default to the first available zone to show video immediately
|
||||||
|
if (!activeZone && zones.length > 0) {
|
||||||
|
setActiveZone(zones[0].name);
|
||||||
}
|
}
|
||||||
setShowForm(false);
|
|
||||||
setEditingCamera(null);
|
|
||||||
setFormData({
|
|
||||||
name: '',
|
|
||||||
ip_address: '',
|
|
||||||
port: 554,
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
stream_path: '/stream1',
|
|
||||||
transport_mode: 'tcp',
|
|
||||||
rtsp_encoding: false,
|
|
||||||
quality: 'low'
|
|
||||||
});
|
|
||||||
fetchCameras();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to save camera', err);
|
console.error('Failed to fetch zones', err);
|
||||||
const errMsg = (err as any).response?.data?.error || (err as any).message || '카메라 저장 실패';
|
|
||||||
alert(`오류 발생: ${errMsg}`);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (camera: Camera) => {
|
// Determine layout based on active zone's setting
|
||||||
setEditingCamera(camera);
|
const currentZoneConfig = availableZones.find(z => z.name === activeZone);
|
||||||
setFormData(camera);
|
const viewLayout = currentZoneConfig ? currentZoneConfig.layout : '2*2';
|
||||||
setShowForm(true);
|
|
||||||
};
|
const handleLayoutChange = async (newLayout: string) => {
|
||||||
|
if (!activeZone) return;
|
||||||
|
|
||||||
|
// Optimistic UI update
|
||||||
|
setAvailableZones(prev => prev.map(z =>
|
||||||
|
z.name === activeZone ? { ...z, layout: newLayout } : z
|
||||||
|
));
|
||||||
|
setShowLayoutMenu(false);
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
if (!window.confirm('정말 삭제하시겠습니까?')) return;
|
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/cameras/${id}`);
|
await apiClient.patch(`/cameras/zones/${activeZone}/layout`, { layout: newLayout });
|
||||||
fetchCameras();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete camera', err);
|
console.error('Failed to update layout', err);
|
||||||
|
// Revert or show alert if needed
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredCameras = !activeZone
|
||||||
|
? []
|
||||||
|
: cameras.filter(c => (c.zone || '기본 구역') === activeZone);
|
||||||
|
|
||||||
const handleToggleStatus = async (camera: Camera) => {
|
const handleToggleStatus = async (camera: Camera) => {
|
||||||
// Optimistic UI Update: Calculate new status
|
// Optimistic UI Update: Calculate new status
|
||||||
const currentStatus = camera.is_active !== 0 && camera.is_active !== false;
|
const currentStatus = camera.is_active !== 0 && camera.is_active !== false;
|
||||||
@ -226,185 +208,186 @@ export function MonitoringPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCameras();
|
fetchCameras();
|
||||||
|
fetchZones();
|
||||||
checkServerVersion();
|
checkServerVersion();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
|
||||||
<Video className="text-blue-600" />
|
|
||||||
CCTV <span className="text-xs text-slate-400 font-normal">({user?.role})</span>
|
|
||||||
</h1>
|
|
||||||
{(user?.role === 'admin' || user?.role === 'supervisor') && (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingCamera(null);
|
|
||||||
setFormData({
|
|
||||||
name: '',
|
|
||||||
ip_address: '',
|
|
||||||
port: 554,
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
stream_path: '/stream1'
|
|
||||||
});
|
|
||||||
setShowForm(true);
|
|
||||||
}}
|
|
||||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 transition"
|
|
||||||
>
|
|
||||||
<Plus size={18} />
|
|
||||||
카메라 추가
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Camera Grid with DnD */}
|
const HeaderDropdown = () => {
|
||||||
<DndContext
|
const portalRoot = document.getElementById('header-portal-root');
|
||||||
sensors={sensors}
|
if (!portalRoot) return null;
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragEnd={handleDragEnd}
|
// Tabs: [Zones...]
|
||||||
>
|
const zoneTabs = availableZones.map(z => z.name);
|
||||||
<SortableContext
|
const layoutOptions = [
|
||||||
items={cameras.map(c => c.id)}
|
{ id: '1', label: '1개', icon: <div className="w-4 h-4 border border-white/40" /> },
|
||||||
strategy={rectSortingStrategy}
|
{ id: '1*2', label: '1*2', icon: <div className="w-4 h-4 border border-white/40 flex"><div className="w-1/2 border-r border-white/40" /></div> },
|
||||||
|
{ id: '2*2', label: '2*2', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-2 grid-rows-2"><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /><div className="border-r border-white/40" /></div> },
|
||||||
|
{ id: '3*3', label: '3*3', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-3 grid-rows-3"><div className="border-r border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /></div> },
|
||||||
|
{ id: '4*4', label: '4*4', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-4 grid-rows-4" /> },
|
||||||
|
{ id: '5*5', label: '5*5', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-5 grid-rows-5" /> },
|
||||||
|
{ id: '6*6', label: '6*6', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-6 grid-rows-6" /> }
|
||||||
|
];
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="flex items-center w-full h-full pointer-events-none">
|
||||||
|
{/* Left Area: Zone Tabs */}
|
||||||
|
<div className="flex gap-1 h-full items-end pb-1 overflow-x-auto scrollbar-hide pointer-events-auto border-l border-slate-200 ml-4 pl-4">
|
||||||
|
{zoneTabs.map(name => (
|
||||||
|
<button
|
||||||
|
key={name}
|
||||||
|
onClick={() => setActiveZone(name)}
|
||||||
|
className={`px-4 py-2 text-[13px] font-bold transition-all border-b-2 whitespace-nowrap ${activeZone === name
|
||||||
|
? 'text-indigo-600 border-indigo-600 bg-indigo-50/20'
|
||||||
|
: 'text-slate-400 border-transparent hover:text-slate-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Spacer to push everything else to the right */}
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
{/* Right Area: Layout Selector */}
|
||||||
|
<div className="px-3 pointer-events-auto flex items-center">
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowLayoutMenu(!showLayoutMenu)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border ${showLayoutMenu ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-slate-50 text-slate-600 border-slate-200 hover:border-indigo-400'} ${!activeZone ? 'opacity-50 grayscale cursor-not-allowed' : ''}`}
|
||||||
|
disabled={!activeZone}
|
||||||
|
>
|
||||||
|
<LayoutGrid size={14} />
|
||||||
|
레이아웃: {!activeZone ? '--' : viewLayout}
|
||||||
|
<ChevronDown size={14} className={`transition-transform duration-200 ${showLayoutMenu ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showLayoutMenu && activeZone && (
|
||||||
|
<div className="absolute right-0 top-full mt-2 w-48 bg-[#1a1a1a] rounded-xl shadow-2xl border border-white/10 p-2 z-[100] animate-in fade-in zoom-in duration-200">
|
||||||
|
<div className="px-2 py-1.5 text-[9px] font-bold text-white/30 uppercase tracking-[0.2em] border-b border-white/5 mb-1">
|
||||||
|
Monitor Arrangement
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-1">
|
||||||
|
{layoutOptions.map(opt => (
|
||||||
|
<button
|
||||||
|
key={opt.id}
|
||||||
|
onClick={() => handleLayoutChange(opt.id)}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-[11px] font-medium transition-all ${viewLayout === opt.id ? 'bg-indigo-600 text-white' : 'text-white/60 hover:bg-white/5 hover:text-white'}`}
|
||||||
|
>
|
||||||
|
<div className="w-4 flex justify-center opacity-60">{opt.icon}</div>
|
||||||
|
{opt.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
portalRoot
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLayoutInfo = () => {
|
||||||
|
if (viewLayout === '1') return { cols: 'grid-cols-1', total: 1 };
|
||||||
|
if (viewLayout === '1*2') return { cols: 'grid-cols-2', total: 2 };
|
||||||
|
|
||||||
|
// Multi-grid like 2*2, 3*3, 4*4
|
||||||
|
const parts = viewLayout.split('*');
|
||||||
|
if (parts.length === 2) {
|
||||||
|
const n = parseInt(parts[0]);
|
||||||
|
return { cols: `grid-cols-${n}`, total: n * n };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cols: 'grid-cols-2', total: 4 };
|
||||||
|
};
|
||||||
|
|
||||||
|
const layoutInfo = getLayoutInfo();
|
||||||
|
const totalSlots = Math.max(layoutInfo.total, filteredCameras.length);
|
||||||
|
// Fill slots: actual cameras + empty placeholders if needed to complete the grid rows
|
||||||
|
const slots = Array.from({ length: totalSlots }).map((_, i) => filteredCameras[i] || null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full bg-[#0a0a0a] flex flex-col min-h-0">
|
||||||
|
<HeaderDropdown />
|
||||||
|
|
||||||
|
<div className="flex-1 p-2 overflow-hidden flex flex-col min-h-0">
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
|
<SortableContext
|
||||||
{cameras.map(camera => (
|
items={filteredCameras.map(c => c.id)}
|
||||||
<SortableCamera key={camera.id} camera={camera} disabled={user?.role !== 'admin' && user?.role !== 'supervisor'}>
|
strategy={rectSortingStrategy}
|
||||||
<div className="relative aspect-video bg-black group">
|
disabled={true}
|
||||||
<JSMpegPlayer
|
>
|
||||||
key={`${camera.id}-${streamVersions[camera.id] || 0}`}
|
<div className={`grid h-full w-full gap-1 ${layoutInfo.cols} ${totalSlots > layoutInfo.total ? 'overflow-y-auto' : ''}`}
|
||||||
url={getStreamUrl(camera.id)}
|
style={{ gridAutoRows: (viewLayout === '1' || viewLayout === '1*2') ? '100%' : `${100 / Math.sqrt(layoutInfo.total)}%` }}>
|
||||||
className="w-full h-full"
|
{slots.map((camera, index) => (
|
||||||
/>
|
<div key={camera ? camera.id : `empty-${index}`} className="h-full w-full">
|
||||||
{/* Overlay Controls */}
|
{camera ? (
|
||||||
{(user?.role === 'admin' || user?.role === 'supervisor') && (
|
<SortableCamera camera={camera} disabled={true}>
|
||||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2" onPointerDown={(e) => e.stopPropagation()}>
|
<div className="relative w-full h-full flex flex-col group text-white/90">
|
||||||
<button
|
{/* VMS Slot Header */}
|
||||||
onClick={() => handleToggleStatus(camera)}
|
<div className="absolute top-0 left-0 right-0 z-10 bg-black/40 backdrop-blur-sm border-b border-white/5 px-3 py-1.5 flex justify-between items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
className={`${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-orange-500/80 hover:bg-orange-600' : 'bg-green-600/80 hover:bg-green-700'} text-white p-2 rounded-full`}
|
<div className="flex items-center gap-2">
|
||||||
title={(camera.is_active !== 0 && camera.is_active !== false) ? "스트리밍 중지 (Pause)" : "스트리밍 시작 (Play)"}
|
<div className={`w-2 h-2 rounded-full ${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></div>
|
||||||
>
|
<span className="text-[11px] font-bold truncate max-w-[150px]">{camera.name}</span>
|
||||||
{(camera.is_active !== 0 && camera.is_active !== false) ? (
|
</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
|
|
||||||
) : (
|
{(user?.role === 'admin' || user?.role === 'supervisor') && (
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
<div className="flex gap-1" onPointerDown={(e) => e.stopPropagation()}>
|
||||||
)}
|
<button
|
||||||
</button>
|
onClick={() => handlePing(camera.id)}
|
||||||
<button
|
className="p-1 text-white/50 hover:text-green-400 transition-colors"
|
||||||
onClick={() => handlePing(camera.id)}
|
title="Ping"
|
||||||
className="bg-green-600/80 text-white p-2 rounded-full hover:bg-green-700"
|
>
|
||||||
title="연결 테스트 (Ping)"
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3L15.5 7.5z" /></svg>
|
||||||
>
|
</button>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3L15.5 7.5z" /></svg>
|
<button
|
||||||
</button>
|
onClick={() => handleToggleStatus(camera)}
|
||||||
<button
|
className={`p-1 transition-colors ${(camera.is_active !== 0 && camera.is_active !== false) ? 'text-white/50 hover:text-orange-400' : 'text-white/50 hover:text-green-400'}`}
|
||||||
onClick={() => handleEdit(camera)}
|
title={(camera.is_active !== 0 && camera.is_active !== false) ? "Pause" : "Play"}
|
||||||
className="bg-black/50 text-white p-2 rounded-full hover:bg-black/70"
|
>
|
||||||
title="설정"
|
{(camera.is_active !== 0 && camera.is_active !== false) ? (
|
||||||
>
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
|
||||||
<Settings size={16} />
|
) : (
|
||||||
</button>
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
|
||||||
<button
|
)}
|
||||||
onClick={() => handleDelete(camera.id)}
|
</button>
|
||||||
className="bg-red-500/80 text-white p-2 rounded-full hover:bg-red-600"
|
</div>
|
||||||
title="삭제"
|
)}
|
||||||
>
|
</div>
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
{/* Streaming Video */}
|
||||||
|
<div className="flex-1 bg-black flex items-center justify-center">
|
||||||
|
<JSMpegPlayer
|
||||||
|
key={`${camera.id}-${streamVersions[camera.id] || 0}`}
|
||||||
|
url={getStreamUrl(camera.id)}
|
||||||
|
className="w-full h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Small persistent ID or Name overlay */}
|
||||||
|
<div className="absolute bottom-2 left-2 px-2 py-0.5 bg-black/60 rounded text-[10px] text-white/30 font-mono pointer-events-none border border-white/5">
|
||||||
|
CH {index + 1} | {camera.ip_address}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SortableCamera>
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full bg-[#121212] border border-slate-900/50 flex flex-col items-center justify-center text-slate-700 select-none">
|
||||||
|
<Video size={32} className="opacity-10 mb-2" />
|
||||||
|
<span className="text-[11px] font-bold opacity-20 uppercase tracking-widest">No Buffer</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
|
||||||
<h3 className="text-white font-medium flex items-center gap-2">
|
|
||||||
<span className={`w-2 h-2 rounded-full ${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></span>
|
|
||||||
{camera.name} {(camera.is_active === 0 || camera.is_active === false) && <span className="text-xs text-slate-400">(중지됨)</span>}
|
|
||||||
</h3>
|
|
||||||
{/* IP/Port Hidden as requested */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</SortableCamera>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</DndContext>
|
|
||||||
|
|
||||||
{cameras.length === 0 && (
|
|
||||||
<div className="text-center py-20 text-slate-500">
|
|
||||||
<Video size={48} className="mx-auto mb-4 opacity-50" />
|
|
||||||
<p>등록된 카메라가 없습니다. 카메라를 추가해주세요.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Add/Edit Modal */}
|
|
||||||
{showForm && (
|
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6 animate-in fade-in zoom-in duration-200">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<h2 className="text-xl font-bold">
|
|
||||||
{editingCamera ? '카메라 설정 수정' : '새 카메라 추가'}
|
|
||||||
</h2>
|
|
||||||
<button onClick={() => setShowForm(false)} className="text-slate-400 hover:text-slate-600">
|
|
||||||
<X size={24} />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
</DndContext>
|
||||||
<div>
|
</div>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">카메라 이름</label>
|
|
||||||
<input type="text" name="name" value={formData.name} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="예: 정문 CCTV" />
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div><label className="block text-sm font-medium text-slate-700 mb-1">IP 주소</label><input type="text" name="ip_address" value={formData.ip_address} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="192.168.1.100" /></div>
|
|
||||||
<div><label className="block text-sm font-medium text-slate-700 mb-1">포트</label><input type="number" name="port" value={formData.port} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="554" /></div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP 사용자명</label><input type="text" name="username" value={formData.username} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="admin" /></div>
|
|
||||||
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP 비밀번호</label><input type="password" name="password" value={formData.password} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" /></div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">스트림 경로</label>
|
|
||||||
<input type="text" name="stream_path" value={formData.stream_path} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="/stream1" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Advanced Settings */}
|
|
||||||
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
|
|
||||||
<h3 className="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
|
|
||||||
<Settings size={14} /> 고급 설정
|
|
||||||
</h3>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-medium text-slate-600 mb-1">전송 방식 (Transport)</label>
|
|
||||||
<select name="transport_mode" value={formData.transport_mode || 'tcp'} onChange={(e) => setFormData(prev => ({ ...prev, transport_mode: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
|
|
||||||
<option value="tcp">TCP (권장 - 안정적)</option>
|
|
||||||
<option value="udp">UDP (빠름 - 끊김가능)</option>
|
|
||||||
<option value="auto">Auto</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input type="checkbox" name="rtsp_encoding" checked={!!formData.rtsp_encoding} onChange={(e) => setFormData(prev => ({ ...prev, rtsp_encoding: e.target.checked }))} className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500" />
|
|
||||||
<span className="text-sm text-slate-600">특수문자 인코딩 사용</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-xs font-medium text-slate-600 mb-1">스트림 화질 (Quality)</label>
|
|
||||||
<select name="quality" value={formData.quality || 'low'} onChange={(e) => setFormData(prev => ({ ...prev, quality: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
|
|
||||||
<option value="low">Low (640px) - 빠르고 안정적 (권장)</option>
|
|
||||||
<option value="medium">Medium (1280px) - HD 화질</option>
|
|
||||||
<option value="original">Original - 원본 화질 (네트워크 부하 큼)</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 mt-6">
|
|
||||||
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition">취소</button>
|
|
||||||
<button type="submit" disabled={loading} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50">{loading ? '저장 중...' : '저장하기'}</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Card } from '../../shared/ui/Card';
|
import { Card } from '../../shared/ui/Card';
|
||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
import { Input } from '../../shared/ui/Input';
|
import { Input } from '../../shared/ui/Input';
|
||||||
import { apiClient } from '../../shared/api/client';
|
import { apiClient } from '../../shared/api/client';
|
||||||
import { useAuth } from '../../shared/auth/AuthContext';
|
import { useAuth } from '../../shared/auth/AuthContext';
|
||||||
import { Save, Clock, Database, Server, CheckCircle2, AlertCircle, Key, RefreshCcw, ShieldAlert, Lock, Unlock } from 'lucide-react';
|
import { Save, Database, Server, CheckCircle2, AlertCircle, Key, RefreshCcw, ShieldAlert, Lock, Unlock, Clock } from 'lucide-react';
|
||||||
|
|
||||||
interface SystemSettings {
|
interface SystemSettings {
|
||||||
session_timeout: number;
|
session_timeout: number;
|
||||||
@ -65,8 +65,11 @@ export function BasicSettingsPage() {
|
|||||||
const [verifyPassword, setVerifyPassword] = useState('');
|
const [verifyPassword, setVerifyPassword] = useState('');
|
||||||
const [verifying, setVerifying] = useState(false);
|
const [verifying, setVerifying] = useState(false);
|
||||||
const [verifyError, setVerifyError] = useState('');
|
const [verifyError, setVerifyError] = useState('');
|
||||||
|
const fetchedRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (fetchedRef.current) return;
|
||||||
|
fetchedRef.current = true;
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
fetchEncryptionStatus();
|
fetchEncryptionStatus();
|
||||||
}, []);
|
}, []);
|
||||||
@ -236,56 +239,15 @@ export function BasicSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{/* Section 1: Security & Session */}
|
{/* v0.4.1.0 Info: Session Timeout relocation notice */}
|
||||||
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
<Card className="p-4 bg-indigo-50 border-indigo-100 flex items-start gap-3">
|
||||||
<div className="p-6 border-b border-slate-50 bg-slate-50/50">
|
<Clock className="text-indigo-600 mt-1" size={20} />
|
||||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
<div>
|
||||||
<Clock size={20} className="text-blue-600" />
|
<h3 className="text-sm font-bold text-indigo-900">세션 관리 정책 변경 안내 (v0.4.1.0)</h3>
|
||||||
보안 및 세션
|
<p className="text-xs text-indigo-700 mt-1 leading-relaxed">
|
||||||
</h2>
|
보안 강화 및 개인화 설정을 위해 전역 세션 로그아웃 설정이 <strong>개별 사용자 설정</strong>으로 이전되었습니다.<br />
|
||||||
</div>
|
좌측 메뉴 하단의 <strong>[기본 설정]</strong> 메뉴 또는 <strong>[사용자 관리]</strong> 메뉴에서 사용자별로 시간을 설정하실 수 있습니다.
|
||||||
<div className="p-6">
|
</p>
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-3">세션 자동 로그아웃 시간</label>
|
|
||||||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="!w-20 text-center"
|
|
||||||
value={Math.floor((settings.session_timeout || 60) / 60)}
|
|
||||||
onChange={e => {
|
|
||||||
const h = parseInt(e.target.value) || 0;
|
|
||||||
setSettings({ ...settings, session_timeout: (h * 60) + (settings.session_timeout % 60) });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-slate-900 mx-1">시간</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
className="!w-20 text-center"
|
|
||||||
value={(settings.session_timeout || 60) % 60}
|
|
||||||
onChange={e => {
|
|
||||||
const m = parseInt(e.target.value) || 0;
|
|
||||||
setSettings({ ...settings, session_timeout: (Math.floor(settings.session_timeout / 60) * 60) + m });
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-slate-900 mx-1">분</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-6 py-4 bg-slate-50/30 border-t border-slate-50 flex items-center justify-between">
|
|
||||||
<div className="flex-1">
|
|
||||||
{saveResults.security && (
|
|
||||||
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.security.success ? 'text-green-600' : 'text-red-600'}`}>
|
|
||||||
{saveResults.security.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
|
|
||||||
{saveResults.security.message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="secondary" size="sm" onClick={fetchSettings}>취소</Button>
|
|
||||||
<Button size="sm" onClick={() => handleSaveSection('security')} disabled={saving} icon={<Save size={14} />}>설정 저장</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@ -2,14 +2,18 @@ import { useState } from 'react';
|
|||||||
import { Card } from '../../shared/ui/Card';
|
import { Card } from '../../shared/ui/Card';
|
||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
import { Select } from '../../shared/ui/Select';
|
import { Select } from '../../shared/ui/Select';
|
||||||
import { Save, Home, User } from 'lucide-react';
|
import { Input } from '../../shared/ui/Input';
|
||||||
|
import { Save, Home, User, Clock, ShieldAlert, RefreshCcw } from 'lucide-react';
|
||||||
import { useAuth } from '../../shared/auth/AuthContext';
|
import { useAuth } from '../../shared/auth/AuthContext';
|
||||||
|
import { apiClient } from '../../shared/api/client';
|
||||||
|
|
||||||
export function GeneralPreferencesPage() {
|
export function GeneralPreferencesPage() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [preferences, setPreferences] = useState({
|
const [preferences, setPreferences] = useState({
|
||||||
landingPage: localStorage.getItem(`landing_page_${user?.id}`) || '/home'
|
landingPage: localStorage.getItem(`landing_page_${user?.id}`) || '/home',
|
||||||
|
sessionTimeout: user?.session_timeout || 10
|
||||||
});
|
});
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
const [isSaved, setIsSaved] = useState(false);
|
||||||
|
|
||||||
const landingPageOptions = [
|
const landingPageOptions = [
|
||||||
@ -19,10 +23,25 @@ export function GeneralPreferencesPage() {
|
|||||||
{ label: '생산 대시보드', value: '/production/dashboard' }
|
{ label: '생산 대시보드', value: '/production/dashboard' }
|
||||||
];
|
];
|
||||||
|
|
||||||
const handleSave = () => {
|
const handleSave = async () => {
|
||||||
localStorage.setItem(`landing_page_${user?.id}`, preferences.landingPage);
|
setIsSaving(true);
|
||||||
setIsSaved(true);
|
try {
|
||||||
setTimeout(() => setIsSaved(false), 3000);
|
// 1. Save local preferences
|
||||||
|
localStorage.setItem(`landing_page_${user?.id}`, preferences.landingPage);
|
||||||
|
|
||||||
|
// 2. Save server-side preferences (Session Timeout)
|
||||||
|
await apiClient.put('/profile', {
|
||||||
|
session_timeout: preferences.sessionTimeout
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsSaved(true);
|
||||||
|
setTimeout(() => setIsSaved(false), 3000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save preferences', error);
|
||||||
|
alert('설정 저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -71,18 +90,65 @@ export function GeneralPreferencesPage() {
|
|||||||
<span className="text-slate-500 block mb-1">소속 부서</span>
|
<span className="text-slate-500 block mb-1">소속 부서</span>
|
||||||
<span className="font-medium text-slate-700">{user?.department || '미지정'}</span>
|
<span className="font-medium text-slate-700">{user?.department || '미지정'}</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500 block mb-1">현재 세션 만료</span>
|
||||||
|
<span className="font-bold text-blue-600">{user?.session_timeout}분</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
|
||||||
|
<Clock className="text-orange-600" size={24} />
|
||||||
|
<h2 className="text-lg font-bold">보안 및 세션 설정</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-w-md">
|
||||||
|
<p className="text-sm text-slate-600 mb-4">
|
||||||
|
활동이 없을 경우 자동으로 로그아웃되는 시간을 설정합니다. (단위: 분)
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={preferences.sessionTimeout}
|
||||||
|
onChange={(e) => setPreferences({ ...preferences, sessionTimeout: parseInt(e.target.value) || 0 })}
|
||||||
|
min={5}
|
||||||
|
max={1440}
|
||||||
|
className="!w-32"
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-bold text-slate-700">분 후 자동 로그아웃</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-slate-400 mt-2">
|
||||||
|
* 최소 5분에서 최대 1440분(24시간)까지 설정 가능합니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{user?.role !== 'user' && (
|
||||||
|
<Card className="p-4 bg-blue-50/50 border-blue-100">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<ShieldAlert className="text-blue-500 mt-0.5" size={18} />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-bold text-blue-900">관리자 안내</h3>
|
||||||
|
<p className="text-xs text-blue-700 mt-1 leading-relaxed">
|
||||||
|
기존 '시스템 관리 - 기본 설정'에 있던 전역 세션 설정은 v0.4.1.0 업데이트로 인해 폐지되었습니다.<br />
|
||||||
|
이제 모든 사용자의 세션 시간은 각 사용자의 <strong>개별 설정</strong> 또는 <strong>사용자 관리</strong> 메뉴에서 개별적으로 관리됩니다.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between bg-white p-4 rounded-xl border border-slate-200 shadow-sm mt-4">
|
<div className="flex items-center justify-between bg-white p-4 rounded-xl border border-slate-200 shadow-sm mt-4">
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
{isSaved && <span className="text-green-600 font-bold animate-pulse">✓ 설정이 저장되었습니다.</span>}
|
{isSaved && <span className="text-green-600 font-bold animate-pulse">✓ 설정이 저장되었습니다.</span>}
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
icon={<Save size={18} />}
|
disabled={isSaving}
|
||||||
|
icon={isSaving ? <RefreshCcw size={18} className="animate-spin" /> : <Save size={18} />}
|
||||||
>
|
>
|
||||||
설정 저장
|
{isSaving ? '저장 중...' : '설정 저장'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { Card } from '../../shared/ui/Card';
|
import { Card } from '../../shared/ui/Card';
|
||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
import { Input } from '../../shared/ui/Input';
|
import { Input } from '../../shared/ui/Input';
|
||||||
import { apiClient } from '../../shared/api/client';
|
import { apiClient } from '../../shared/api/client';
|
||||||
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw } from 'lucide-react';
|
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw, Printer } from 'lucide-react';
|
||||||
import type { User } from '../../shared/auth/AuthContext';
|
import type { User } from '../../shared/auth/AuthContext';
|
||||||
import { useAuth } from '../../shared/auth/AuthContext';
|
import { useAuth } from '../../shared/auth/AuthContext';
|
||||||
import './UserManagementPage.css';
|
import './UserManagementPage.css';
|
||||||
@ -16,12 +16,14 @@ interface UserFormData {
|
|||||||
position: string;
|
position: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
role: 'supervisor' | 'admin' | 'user';
|
role: 'supervisor' | 'admin' | 'user';
|
||||||
|
session_timeout: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserManagementPage() {
|
export function UserManagementPage() {
|
||||||
const { user: currentUser } = useAuth();
|
const { user: currentUser } = useAuth();
|
||||||
const [users, setUsers] = useState<User[]>([]);
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const fetchedRef = useRef(false);
|
||||||
|
|
||||||
// Modal State
|
// Modal State
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@ -33,10 +35,13 @@ export function UserManagementPage() {
|
|||||||
department: '',
|
department: '',
|
||||||
position: '',
|
position: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
role: 'user'
|
role: 'user',
|
||||||
|
session_timeout: 10
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (fetchedRef.current) return;
|
||||||
|
fetchedRef.current = true;
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -61,7 +66,8 @@ export function UserManagementPage() {
|
|||||||
department: '',
|
department: '',
|
||||||
position: '',
|
position: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
role: 'user'
|
role: 'user',
|
||||||
|
session_timeout: 10
|
||||||
});
|
});
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@ -75,7 +81,8 @@ export function UserManagementPage() {
|
|||||||
department: user.department || '',
|
department: user.department || '',
|
||||||
position: user.position || '',
|
position: user.position || '',
|
||||||
phone: user.phone || '',
|
phone: user.phone || '',
|
||||||
role: user.role
|
role: user.role,
|
||||||
|
session_timeout: (user as any).session_timeout || 10
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
@ -149,7 +156,10 @@ export function UserManagementPage() {
|
|||||||
<h1 className="text-2xl font-bold text-slate-900">시스템 관리 - 사용자 관리</h1>
|
<h1 className="text-2xl font-bold text-slate-900">시스템 관리 - 사용자 관리</h1>
|
||||||
<p className="text-slate-500 mt-1">시스템 접속 권한 및 사용자 정보를 관리합니다.</p>
|
<p className="text-slate-500 mt-1">시스템 접속 권한 및 사용자 정보를 관리합니다.</p>
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}>사용자 등록</Button>
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => window.print()} icon={<Printer size={16} />}>출력</Button>
|
||||||
|
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}>사용자 등록</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="overflow-hidden shadow-sm border-slate-200">
|
<Card className="overflow-hidden shadow-sm border-slate-200">
|
||||||
@ -160,6 +170,7 @@ export function UserManagementPage() {
|
|||||||
<th className="px-6 py-4">아이디 / 권한</th>
|
<th className="px-6 py-4">아이디 / 권한</th>
|
||||||
<th className="px-6 py-4">이름</th>
|
<th className="px-6 py-4">이름</th>
|
||||||
<th className="px-6 py-4">소속 / 직위</th>
|
<th className="px-6 py-4">소속 / 직위</th>
|
||||||
|
<th className="px-6 py-4">세션 (분)</th>
|
||||||
<th className="px-6 py-4">연락처</th>
|
<th className="px-6 py-4">연락처</th>
|
||||||
<th className="px-6 py-4">마지막 접속</th>
|
<th className="px-6 py-4">마지막 접속</th>
|
||||||
<th className="px-6 py-4 text-center">관리</th>
|
<th className="px-6 py-4 text-center">관리</th>
|
||||||
@ -186,6 +197,9 @@ export function UserManagementPage() {
|
|||||||
<div className="text-slate-900 font-bold">{user.department || '-'}</div>
|
<div className="text-slate-900 font-bold">{user.department || '-'}</div>
|
||||||
<div className="text-slate-600 text-[11px] font-medium">{user.position}</div>
|
<div className="text-slate-600 text-[11px] font-medium">{user.position}</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<span className="text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded ring-1 ring-indigo-100">{(user as any).session_timeout || 10}</span>
|
||||||
|
</td>
|
||||||
<td className="px-6 py-4 text-slate-700 font-medium">{user.phone || '-'}</td>
|
<td className="px-6 py-4 text-slate-700 font-medium">{user.phone || '-'}</td>
|
||||||
<td className="px-6 py-4 text-slate-400 text-[11px]">
|
<td className="px-6 py-4 text-slate-400 text-[11px]">
|
||||||
{user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'}
|
{user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'}
|
||||||
@ -265,17 +279,14 @@ export function UserManagementPage() {
|
|||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
||||||
disabled={
|
disabled={
|
||||||
// 1. 현재 관리자(admin)는 최고관리자(supervisor)의 권한을 바꿀 수 없음
|
|
||||||
(isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor' && currentUser?.role !== 'supervisor') ||
|
(isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor' && currentUser?.role !== 'supervisor') ||
|
||||||
// 2. 현재 관리자(admin)는 자기 자신의 권한을 'supervisor'로 올릴 수 없음 (애초에 옵션에서 걸러지겠지만 안전 장치)
|
|
||||||
(formData.role === 'supervisor' && currentUser?.role !== 'supervisor')
|
(formData.role === 'supervisor' && currentUser?.role !== 'supervisor')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="user">일반 사용자</option>
|
<option value="user">사용자</option>
|
||||||
<option value="admin">관리자</option>
|
<option value="admin">관리자</option>
|
||||||
{/* 최고관리자(supervisor) 옵션은 오직 최고관리자만 부여 가능 */}
|
|
||||||
{(currentUser?.role === 'supervisor' || (isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor')) && (
|
{(currentUser?.role === 'supervisor' || (isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor')) && (
|
||||||
<option value="supervisor">최고 관리자 (Supervisor)</option>
|
<option value="supervisor">최고관리자</option>
|
||||||
)}
|
)}
|
||||||
</select>
|
</select>
|
||||||
{currentUser?.role !== 'supervisor' && (
|
{currentUser?.role !== 'supervisor' && (
|
||||||
@ -301,14 +312,29 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">핸드폰 번호</label>
|
<div>
|
||||||
<Input
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">핸드폰 번호</label>
|
||||||
value={formData.phone}
|
<Input
|
||||||
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
|
value={formData.phone}
|
||||||
placeholder="010-0000-0000"
|
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
|
||||||
maxLength={13}
|
placeholder="010-0000-0000"
|
||||||
/>
|
maxLength={13}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
|
||||||
|
세션 만료 (분) <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={formData.session_timeout}
|
||||||
|
onChange={(e) => setFormData({ ...formData, session_timeout: parseInt(e.target.value) || 0 })}
|
||||||
|
min={5}
|
||||||
|
placeholder="기본 10"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-4 flex justify-end gap-2">
|
<div className="pt-4 flex justify-end gap-2">
|
||||||
|
|||||||
@ -113,4 +113,126 @@ button {
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ==========================================
|
||||||
|
Print Styles
|
||||||
|
========================================== */
|
||||||
|
@media print {
|
||||||
|
|
||||||
|
/* Hide UI elements that shouldn't be printed */
|
||||||
|
header,
|
||||||
|
nav,
|
||||||
|
aside,
|
||||||
|
.sidebar,
|
||||||
|
.top-header,
|
||||||
|
.no-print,
|
||||||
|
button,
|
||||||
|
.btn,
|
||||||
|
.ui-button,
|
||||||
|
.table-toolbar,
|
||||||
|
.pagination-bar,
|
||||||
|
.form-actions,
|
||||||
|
.form-actions-footer,
|
||||||
|
.section-divider,
|
||||||
|
.badge-neutral {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset layout for print */
|
||||||
|
body {
|
||||||
|
background-color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.layout-container {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area {
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-container {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-card,
|
||||||
|
.sokuree-card {
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure original layout is kept but scaled to fit */
|
||||||
|
.document-layout {
|
||||||
|
display: flex !important;
|
||||||
|
flex-direction: row !important;
|
||||||
|
/* Keep original row layout */
|
||||||
|
gap: 15pt !important;
|
||||||
|
align-items: flex-start !important;
|
||||||
|
width: 100% !important;
|
||||||
|
zoom: 0.85;
|
||||||
|
/* Scale down slightly to fit Portrait width */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Adjust image area for print to be more compact */
|
||||||
|
.doc-image-area {
|
||||||
|
width: 200pt !important;
|
||||||
|
height: 200pt !important;
|
||||||
|
flex: none !important;
|
||||||
|
border: 1px solid #e2e8f0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-info-area {
|
||||||
|
flex: 1 !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
/* Allow shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-table {
|
||||||
|
width: 100% !important;
|
||||||
|
table-layout: fixed !important;
|
||||||
|
/* Maintain the fixed structure requested */
|
||||||
|
border: 1px solid #cbd5e1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-table th,
|
||||||
|
.doc-table td {
|
||||||
|
padding: 4pt 6pt !important;
|
||||||
|
/* Tighter padding for print */
|
||||||
|
font-size: 10pt !important;
|
||||||
|
height: 35pt !important;
|
||||||
|
/* Reduced height for compactness */
|
||||||
|
word-break: break-all !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reset colgroup if needed specifically for print if still tight */
|
||||||
|
.doc-table col {
|
||||||
|
height: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force background colors and colors to show up */
|
||||||
|
* {
|
||||||
|
-webkit-print-color-adjust: exact !important;
|
||||||
|
print-color-adjust: exact !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Typography adjustments for print */
|
||||||
|
.page-title-text {
|
||||||
|
font-size: 24pt !important;
|
||||||
|
margin-bottom: 20pt !important;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.doc-table th {
|
||||||
|
background-color: #f1f5f9 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -3,6 +3,8 @@ import { apiClient } from './client';
|
|||||||
// Frontend Interface (CamelCase)
|
// Frontend Interface (CamelCase)
|
||||||
export interface Asset {
|
export interface Asset {
|
||||||
id: string;
|
id: string;
|
||||||
|
parentId?: string; // DB: parent_id
|
||||||
|
parentName?: string; // Joined
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
category: string;
|
||||||
model: string; // DB: model_name
|
model: string; // DB: model_name
|
||||||
@ -16,11 +18,15 @@ export interface Asset {
|
|||||||
image?: string; // DB: image_url
|
image?: string; // DB: image_url
|
||||||
calibrationCycle?: string; // DB: calibration_cycle
|
calibrationCycle?: string; // DB: calibration_cycle
|
||||||
specs?: string;
|
specs?: string;
|
||||||
|
quantity?: number;
|
||||||
|
children?: Partial<Asset>[]; // List of sub-assets
|
||||||
}
|
}
|
||||||
|
|
||||||
// DB Interface (SnakeCase) - for internal mapping
|
// DB Interface (SnakeCase) - for internal mapping
|
||||||
interface DBAsset {
|
interface DBAsset {
|
||||||
id: string;
|
id: string;
|
||||||
|
parent_id?: string;
|
||||||
|
parent_name?: string; // Joined or added in response
|
||||||
name: string;
|
name: string;
|
||||||
category: string;
|
category: string;
|
||||||
model_name: string;
|
model_name: string;
|
||||||
@ -34,8 +40,10 @@ interface DBAsset {
|
|||||||
image_url?: string;
|
image_url?: string;
|
||||||
calibration_cycle: string;
|
calibration_cycle: string;
|
||||||
specs: string;
|
specs: string;
|
||||||
|
quantity?: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
children?: any[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MaintenanceRecord {
|
export interface MaintenanceRecord {
|
||||||
@ -81,7 +89,20 @@ export const assetApi = {
|
|||||||
|
|
||||||
getById: async (id: string): Promise<Asset> => {
|
getById: async (id: string): Promise<Asset> => {
|
||||||
const response = await apiClient.get<DBAsset>(`/assets/${id}`);
|
const response = await apiClient.get<DBAsset>(`/assets/${id}`);
|
||||||
return mapDBToAsset(response.data);
|
const asset = mapDBToAsset(response.data);
|
||||||
|
if (response.data.children) {
|
||||||
|
asset.children = response.data.children.map(c => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
category: c.category,
|
||||||
|
status: c.status,
|
||||||
|
location: c.location
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
if (response.data.parent_name) {
|
||||||
|
asset.parentName = response.data.parent_name;
|
||||||
|
}
|
||||||
|
return asset;
|
||||||
},
|
},
|
||||||
|
|
||||||
create: (data: Partial<Asset>) => {
|
create: (data: Partial<Asset>) => {
|
||||||
@ -136,12 +157,27 @@ export const assetApi = {
|
|||||||
|
|
||||||
deleteManual: async (id: number) => {
|
deleteManual: async (id: number) => {
|
||||||
return apiClient.delete(`/manuals/${id}`);
|
return apiClient.delete(`/manuals/${id}`);
|
||||||
|
},
|
||||||
|
|
||||||
|
// Accessories
|
||||||
|
getAccessories: async (assetId: string): Promise<any[]> => {
|
||||||
|
const response = await apiClient.get<any[]>(`/assets/${assetId}/accessories`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
addAccessory: async (assetId: string, data: { name: string, spec: string, quantity: number }) => {
|
||||||
|
return apiClient.post(`/assets/${assetId}/accessories`, data);
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAccessory: async (id: number) => {
|
||||||
|
return apiClient.delete(`/accessories/${id}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper Mappers
|
// Helper Mappers
|
||||||
const mapDBToAsset = (db: DBAsset): Asset => ({
|
const mapDBToAsset = (db: DBAsset): Asset => ({
|
||||||
id: db.id,
|
id: db.id,
|
||||||
|
parentId: db.parent_id,
|
||||||
name: db.name,
|
name: db.name,
|
||||||
category: db.category,
|
category: db.category,
|
||||||
model: db.model_name || '',
|
model: db.model_name || '',
|
||||||
@ -154,11 +190,13 @@ const mapDBToAsset = (db: DBAsset): Asset => ({
|
|||||||
purchasePrice: db.purchase_price,
|
purchasePrice: db.purchase_price,
|
||||||
image: db.image_url,
|
image: db.image_url,
|
||||||
calibrationCycle: db.calibration_cycle || '',
|
calibrationCycle: db.calibration_cycle || '',
|
||||||
specs: db.specs || ''
|
specs: db.specs || '',
|
||||||
|
quantity: db.quantity
|
||||||
});
|
});
|
||||||
|
|
||||||
const mapAssetToDB = (asset: Partial<Asset>): Partial<DBAsset> => {
|
const mapAssetToDB = (asset: Partial<Asset>): Partial<DBAsset> => {
|
||||||
const db: any = { ...asset };
|
const db: any = { ...asset };
|
||||||
|
if (asset.parentId !== undefined) { db.parent_id = asset.parentId; delete db.parentId; }
|
||||||
if (asset.model !== undefined) { db.model_name = asset.model; delete db.model; }
|
if (asset.model !== undefined) { db.model_name = asset.model; delete db.model; }
|
||||||
if (asset.serialNumber !== undefined) { db.serial_number = asset.serialNumber; delete db.serialNumber; }
|
if (asset.serialNumber !== undefined) { db.serial_number = asset.serialNumber; delete db.serialNumber; }
|
||||||
if (asset.purchaseDate !== undefined) { db.purchase_date = asset.purchaseDate; delete db.purchaseDate; }
|
if (asset.purchaseDate !== undefined) { db.purchase_date = asset.purchaseDate; delete db.purchaseDate; }
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, type ReactNode, useEffect } from 'react';
|
import { createContext, useContext, useState, type ReactNode, useEffect, useRef } from 'react';
|
||||||
import { apiClient, setCsrfToken } from '../api/client';
|
import { apiClient, setCsrfToken } from '../api/client';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
@ -8,6 +8,7 @@ export interface User {
|
|||||||
department?: string;
|
department?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
|
session_timeout?: number;
|
||||||
last_login?: string;
|
last_login?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -24,46 +25,46 @@ const AuthContext = createContext<AuthContextType | null>(null);
|
|||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<User | null>(null);
|
const [user, setUser] = useState<User | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const isCheckingRef = useRef(false);
|
||||||
|
const lastActivityRef = useRef(Date.now());
|
||||||
|
const timeoutMsRef = useRef(3600000); // 1 hour default
|
||||||
|
|
||||||
// Check for existing session on mount and periodically
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let lastActivity = Date.now();
|
const checkSession = async (isBackground = false) => {
|
||||||
let timeoutMs = 3600000; // Default 1 hour
|
if (isCheckingRef.current) return;
|
||||||
|
isCheckingRef.current = true;
|
||||||
|
|
||||||
const checkSession = async () => {
|
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/check');
|
const response = await apiClient.get('/check');
|
||||||
if (response.data.isAuthenticated) {
|
if (response.data.isAuthenticated) {
|
||||||
setUser(response.data.user);
|
setUser(response.data.user);
|
||||||
if (response.data.csrfToken) setCsrfToken(response.data.csrfToken);
|
if (response.data.csrfToken) setCsrfToken(response.data.csrfToken);
|
||||||
if (response.data.sessionTimeout) timeoutMs = response.data.sessionTimeout;
|
if (response.data.sessionTimeout) {
|
||||||
|
timeoutMsRef.current = response.data.sessionTimeout;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Safety: only redirect if we were previously logged in
|
// Safety: only redirect if we were previously logged in
|
||||||
setUser(prev => {
|
setUser(prev => {
|
||||||
if (prev) {
|
if (prev || !isBackground) {
|
||||||
setCsrfToken(null);
|
setCsrfToken(null);
|
||||||
window.location.href = '/login?expired=true';
|
if (!window.location.pathname.includes('/login')) {
|
||||||
|
window.location.href = '/login?expired=true';
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return prev;
|
return prev;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setUser(prev => {
|
console.error('Session check failed', error);
|
||||||
if (prev) {
|
|
||||||
setCsrfToken(null);
|
|
||||||
window.location.href = '/login?expired=true';
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return prev;
|
|
||||||
});
|
|
||||||
} finally {
|
} finally {
|
||||||
|
isCheckingRef.current = false;
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateActivity = () => {
|
const updateActivity = () => {
|
||||||
lastActivity = Date.now();
|
lastActivityRef.current = Date.now();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Activity Listeners
|
// Activity Listeners
|
||||||
@ -72,25 +73,19 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
window.addEventListener('scroll', updateActivity);
|
window.addEventListener('scroll', updateActivity);
|
||||||
window.addEventListener('click', updateActivity);
|
window.addEventListener('click', updateActivity);
|
||||||
|
|
||||||
|
// Initial check on mount
|
||||||
checkSession();
|
checkSession();
|
||||||
|
|
||||||
// Check activity status every 30 seconds
|
// Check activity status
|
||||||
const activityInterval = setInterval(() => {
|
const activityInterval = setInterval(() => {
|
||||||
// Functional check to avoid stale user closure
|
const idleTime = Date.now() - lastActivityRef.current;
|
||||||
setUser(current => {
|
if (idleTime >= timeoutMsRef.current) {
|
||||||
if (current) {
|
checkSession(true);
|
||||||
const idleTime = Date.now() - lastActivity;
|
}
|
||||||
if (idleTime >= timeoutMs) {
|
|
||||||
console.log('Idle timeout reached. Checking session...');
|
|
||||||
checkSession();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return current;
|
|
||||||
});
|
|
||||||
}, 30000);
|
}, 30000);
|
||||||
|
|
||||||
// Fallback polling every 5 minutes (for secondary tabs etc)
|
// Fallback polling
|
||||||
const pollInterval = setInterval(checkSession, 300000);
|
const pollInterval = setInterval(() => checkSession(true), 300000);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', updateActivity);
|
window.removeEventListener('mousemove', updateActivity);
|
||||||
@ -100,7 +95,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
clearInterval(activityInterval);
|
clearInterval(activityInterval);
|
||||||
clearInterval(pollInterval);
|
clearInterval(pollInterval);
|
||||||
};
|
};
|
||||||
}, []); // Removed [user] to prevent infinite loop
|
}, []);
|
||||||
|
|
||||||
const login = async (id: string, password: string) => {
|
const login = async (id: string, password: string) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, type ReactNode, useRef } from 'react';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
@ -20,6 +20,7 @@ export function SystemProvider({ children }: { children: ReactNode }) {
|
|||||||
const [modules, setModules] = useState<Record<string, ModuleState>>({});
|
const [modules, setModules] = useState<Record<string, ModuleState>>({});
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const { isAuthenticated } = useAuth(); // Only load if authenticated
|
const { isAuthenticated } = useAuth(); // Only load if authenticated
|
||||||
|
const isFetchingRef = useRef<string | null>(null); // Track if already fetched for current state
|
||||||
|
|
||||||
const refreshModules = async () => {
|
const refreshModules = async () => {
|
||||||
try {
|
try {
|
||||||
@ -40,8 +41,12 @@ export function SystemProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
|
// Only fetch if we haven't fetched for this authenticated state yet
|
||||||
|
if (isFetchingRef.current === 'authed') return;
|
||||||
|
isFetchingRef.current = 'authed';
|
||||||
refreshModules();
|
refreshModules();
|
||||||
} else {
|
} else {
|
||||||
|
isFetchingRef.current = 'guest';
|
||||||
setModules({});
|
setModules({});
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useSystem } from '../../shared/context/SystemContext';
|
import { useSystem } from '../../shared/context/SystemContext';
|
||||||
import { apiClient } from '../../shared/api/client';
|
import { apiClient } from '../../shared/api/client';
|
||||||
import { Check, X, Key, Shield, AlertTriangle, Terminal } from 'lucide-react';
|
import { Check, X, Key, Shield, AlertTriangle, Terminal, Printer } from 'lucide-react';
|
||||||
|
|
||||||
export function LicensePage() {
|
export function LicensePage() {
|
||||||
const { modules, refreshModules } = useSystem();
|
const { modules, refreshModules } = useSystem();
|
||||||
@ -92,10 +92,19 @@ export function LicensePage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
<div className="flex justify-between items-center">
|
||||||
<Shield className="text-blue-600" />
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
라이선스 및 모듈 관리
|
<Shield className="text-blue-600" />
|
||||||
</h2>
|
라이선스 및 모듈 관리
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
onClick={() => window.print()}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 rounded hover:bg-slate-50 transition text-sm font-medium no-print"
|
||||||
|
>
|
||||||
|
<Printer size={16} />
|
||||||
|
출력
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
|
||||||
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||||
|
|||||||
@ -203,11 +203,17 @@
|
|||||||
background-color: var(--sokuree-bg-card);
|
background-color: var(--sokuree-bg-card);
|
||||||
border-bottom: 1px solid var(--sokuree-border-color);
|
border-bottom: 1px solid var(--sokuree-border-color);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: center;
|
||||||
padding: 0 2.5rem;
|
padding: 0 1.5rem;
|
||||||
color: var(--sokuree-text-primary);
|
color: var(--sokuree-text-primary);
|
||||||
justify-content: flex-end;
|
justify-content: space-between;
|
||||||
/* Align to right */
|
}
|
||||||
|
|
||||||
|
.header-portal-root {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-tabs {
|
.header-tabs {
|
||||||
|
|||||||
@ -111,8 +111,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
onClick={() => toggleModule(mod.moduleName)}
|
onClick={() => toggleModule(mod.moduleName)}
|
||||||
>
|
>
|
||||||
<div className="module-title">
|
<div className="module-title">
|
||||||
<Layers size={18} />
|
{mod.moduleName === 'cctv' ? <Video size={18} /> : <Layers size={18} />}
|
||||||
<span>{mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1)} 관리</span>
|
<span>{mod.label || (mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1) + ' 관리')}</span>
|
||||||
</div>
|
</div>
|
||||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
</button>
|
</button>
|
||||||
@ -182,7 +182,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
>
|
>
|
||||||
<div className="module-title">
|
<div className="module-title">
|
||||||
<Video size={18} />
|
<Video size={18} />
|
||||||
<span>{mod.moduleName.split('-')[0].toUpperCase()}</span>
|
<span>{mod.label || mod.moduleName.split('-')[0].toUpperCase()}</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
@ -262,6 +262,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div id="header-portal-root" className="header-portal-root"></div>
|
||||||
</header>
|
</header>
|
||||||
<div className="content-area">
|
<div className="content-area">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user