diff --git a/docs/DIRECTORY_STRUCTURE.md b/docs/design/DIRECTORY_STRUCTURE.md similarity index 87% rename from docs/DIRECTORY_STRUCTURE.md rename to docs/design/DIRECTORY_STRUCTURE.md index ad15fd8..dd2c9bd 100644 --- a/docs/DIRECTORY_STRUCTURE.md +++ b/docs/design/DIRECTORY_STRUCTURE.md @@ -57,8 +57,11 @@ smartims/ ## 4. 기타 주요 디렉토리 - `docs/`: - - `MODULAR_ARCHITECTURE_GUIDE.md`: 모듈화 상세 설계 원칙. - - `DEPLOYMENT_GUIDE.md`: 서버 배포 방법. + - `operation/`: 설치 및 운영 관련 가이드 (환경 설정, 배포, 라이선스 서버 등) + - `modules/`: 모듈별 상세 기술 및 사용자 가이드 (자산 분류 기준 등) + - `design/`: 플랫폼 설계 원칙, 시스템 아키텍처 및 디렉토리 구조 + - `version_control/`: Git 운영 정책 및 버전 관리 규정 + - `roadmap/`: 프로젝트 통합 마스터 플랜 및 단계별 로드맵 (`INTEGRATED_ROADMAP.md`) - `tools/`: - 라이선스 관리 도구, 데이터 마이그레이션 스크립트 등 개발 보조 도구 포함. diff --git a/docs/MODULAR_ARCHITECTURE_GUIDE.md b/docs/design/MODULAR_ARCHITECTURE_GUIDE.md similarity index 100% rename from docs/MODULAR_ARCHITECTURE_GUIDE.md rename to docs/design/MODULAR_ARCHITECTURE_GUIDE.md diff --git a/docs/modules/asset/ASSET_CLASSIFICATION_GUIDE.md b/docs/modules/asset/ASSET_CLASSIFICATION_GUIDE.md new file mode 100644 index 0000000..d2f3760 --- /dev/null +++ b/docs/modules/asset/ASSET_CLASSIFICATION_GUIDE.md @@ -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) 사진을 함께 등록할 것을 권장합니다. diff --git a/docs/CCTV_MODULE_GUIDE.md b/docs/modules/cctv/CCTV_MODULE_GUIDE.md similarity index 100% rename from docs/CCTV_MODULE_GUIDE.md rename to docs/modules/cctv/CCTV_MODULE_GUIDE.md diff --git a/docs/LICENSE_MANAGER_MANUAL.md b/docs/modules/license/LICENSE_MANAGER_MANUAL.md similarity index 100% rename from docs/LICENSE_MANAGER_MANUAL.md rename to docs/modules/license/LICENSE_MANAGER_MANUAL.md diff --git a/docs/DEPLOYMENT_GUIDE.md b/docs/operation/DEPLOYMENT_GUIDE.md similarity index 100% rename from docs/DEPLOYMENT_GUIDE.md rename to docs/operation/DEPLOYMENT_GUIDE.md diff --git a/docs/ENVIRONMENT_SETUP.md b/docs/operation/ENVIRONMENT_SETUP.md similarity index 100% rename from docs/ENVIRONMENT_SETUP.md rename to docs/operation/ENVIRONMENT_SETUP.md diff --git a/docs/LICENSE_SERVER_SETUP.md b/docs/operation/LICENSE_SERVER_SETUP.md similarity index 100% rename from docs/LICENSE_SERVER_SETUP.md rename to docs/operation/LICENSE_SERVER_SETUP.md diff --git a/docs/PRODUCTION_DEPLOYMENT.md b/docs/operation/PRODUCTION_DEPLOYMENT.md similarity index 100% rename from docs/PRODUCTION_DEPLOYMENT.md rename to docs/operation/PRODUCTION_DEPLOYMENT.md diff --git a/docs/IMPLEMENTATION_DETAILS.md b/docs/roadmap/IMPLEMENTATION_DETAILS.md similarity index 100% rename from docs/IMPLEMENTATION_DETAILS.md rename to docs/roadmap/IMPLEMENTATION_DETAILS.md diff --git a/docs/roadmap/INTEGRATED_ROADMAP.md b/docs/roadmap/INTEGRATED_ROADMAP.md new file mode 100644 index 0000000..11aba7d --- /dev/null +++ b/docs/roadmap/INTEGRATED_ROADMAP.md @@ -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) │ +└─────────────────────────────────────────────────────────────────────────┘ + +![alt text](image.png) +![alt text](image-1.png) + +![alt text](image-2.png) + +![alt text](image-3.png) + +https://www.bulums.io/smartfactory-cmms + +![alt text](image-4.png) +https://blog.naver.com/8pmcorp/224146369431 diff --git a/docs/roadmap/image-1.png b/docs/roadmap/image-1.png new file mode 100644 index 0000000..ac3cf19 Binary files /dev/null and b/docs/roadmap/image-1.png differ diff --git a/docs/roadmap/image-2.png b/docs/roadmap/image-2.png new file mode 100644 index 0000000..ee4c977 Binary files /dev/null and b/docs/roadmap/image-2.png differ diff --git a/docs/roadmap/image-3.png b/docs/roadmap/image-3.png new file mode 100644 index 0000000..e00b79e Binary files /dev/null and b/docs/roadmap/image-3.png differ diff --git a/docs/roadmap/image-4.png b/docs/roadmap/image-4.png new file mode 100644 index 0000000..7753000 Binary files /dev/null and b/docs/roadmap/image-4.png differ diff --git a/docs/roadmap/image.png b/docs/roadmap/image.png new file mode 100644 index 0000000..fdaa849 Binary files /dev/null and b/docs/roadmap/image.png differ diff --git a/docs/task.md b/docs/roadmap/task.md similarity index 100% rename from docs/task.md rename to docs/roadmap/task.md diff --git a/docs/git 운영 규칙.md b/docs/version_control/Gitea Operational Rules.md similarity index 100% rename from docs/git 운영 규칙.md rename to docs/version_control/Gitea Operational Rules.md diff --git a/docs/version manage.md b/docs/version_control/version manage.md similarity index 100% rename from docs/version manage.md rename to docs/version_control/version manage.md diff --git a/server/index.js b/server/index.js index 11f005a..6f019bf 100644 --- a/server/index.js +++ b/server/index.js @@ -81,15 +81,21 @@ app.use(session({ app.use(async (req, res, next) => { if (req.session && req.session.user) { // 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')) { return next(); } try { - const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'"); - const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60; + // Priority: User's individual timeout > System default + 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; // Explicitly save session before moving to next middleware @@ -108,12 +114,24 @@ app.use(async (req, res, next) => { // Apply CSRF Protection app.use(csrfProtection); -// Request Logger +// Request Logger with Session Remaining Time app.use((req, res, next) => { const now = new Date(); - // UTC 시간에 9시간을 더한 뒤 ISO 문자열로 변환하고 끝의 'Z'를 제거하여 한국 시간 형식 생성 - const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', ''); - console.log(`[${kstDate}] ${req.method} ${req.url}`); + const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', '').replace('T', ' '); + + 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(); }); @@ -171,6 +189,14 @@ const initTables = async () => { 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 const maintenancePartsTable = ` CREATE TABLE IF NOT EXISTS maintenance_parts ( @@ -196,20 +222,14 @@ const initTables = async () => { position VARCHAR(100), phone VARCHAR(255), role ENUM('supervisor', 'admin', 'user') DEFAULT 'user', + session_timeout INT DEFAULT 10, last_login TIMESTAMP NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; await db.query(usersTableSQL); - - // 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'); + console.log('✅ Users Table Created'); // Default Admin const adminId = 'admin'; @@ -222,12 +242,22 @@ const initTables = async () => { [adminId, hashedPass, '관리자', 'supervisor', 'IT팀', '관리자'] ); 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'); // Create asset_manuals table const manualTable = ` @@ -242,43 +272,105 @@ const initTables = async () => { `; 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 = ` - CREATE TABLE IF NOT EXISTS camera_settings ( + CREATE TABLE IF NOT EXISTS cctv_settings ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL, + zone VARCHAR(50) DEFAULT '기본 구역', ip_address VARCHAR(100) NOT NULL, port INT DEFAULT 554, username VARCHAR(100), - password VARCHAR(100), + password VARCHAR(255), stream_path VARCHAR(200) DEFAULT '/stream1', + is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; `; 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 - 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) { - 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 camera_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 - console.log("✅ Added 'transport_mode', 'quality', and 'rtsp_encoding' columns to camera_settings"); + 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 cctv_settings ADD COLUMN rtsp_encoding BOOLEAN DEFAULT FALSE AFTER transport_mode"); + 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 cctv_settings"); } else { // 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) { - await db.query("ALTER TABLE camera_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); - console.log("✅ Added 'quality' column to camera_settings"); + 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 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 - 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) { - await db.query("ALTER TABLE camera_settings ADD COLUMN display_order INT DEFAULT 0 AFTER quality"); - console.log("✅ Added 'display_order' column to camera_settings"); + await db.query("ALTER TABLE cctv_settings ADD COLUMN display_order INT DEFAULT 0 AFTER quality"); + 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) @@ -348,7 +440,12 @@ const initTables = async () => { const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`; await db.query(insert, ['asset', '자산 관리', true, 'dev']); 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'); diff --git a/server/modules/asset/routes.js b/server/modules/asset/routes.js index 5713ce6..acf205c 100644 --- a/server/modules/asset/routes.js +++ b/server/modules/asset/routes.js @@ -23,7 +23,22 @@ router.get('/assets/:id', async (req, res) => { try { 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' }); - 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) { console.error(err); res.status(500).json({ error: 'Database error' }); @@ -32,14 +47,14 @@ router.get('/assets/:id', async (req, res) => { // Create Asset 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 { const sql = ` - INSERT INTO assets (id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `; - 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 }); } catch (err) { console.error(err); @@ -49,15 +64,15 @@ router.post('/assets', async (req, res) => { // Update Asset 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 { const sql = ` 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=? `; - 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' }); 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; diff --git a/server/modules/cctv/routes.js b/server/modules/cctv/routes.js index dd98f9f..ef8b5a0 100644 --- a/server/modules/cctv/routes.js +++ b/server/modules/cctv/routes.js @@ -3,12 +3,21 @@ const router = express.Router(); const db = require('../../db'); const { isAuthenticated, hasRole } = require('../../middleware/authMiddleware'); const { requireModule } = require('../../middleware/licenseMiddleware'); +const cryptoUtil = require('../../utils/cryptoUtil'); // Get all cameras - Protected by Module License -router.get('/', requireModule('monitoring'), async (req, res) => { +router.get('/', requireModule('cctv'), async (req, res) => { try { - const [rows] = await db.query('SELECT * FROM camera_settings ORDER BY display_order ASC, created_at DESC'); - res.json(rows); + const [rows] = await db.query('SELECT * FROM cctv_settings ORDER BY display_order ASC, created_at DESC'); + + // 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) { console.error(err); 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 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'); @@ -43,13 +52,73 @@ router.put('/reorder', hasRole('admin'), async (req, res) => { 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 router.get('/:id', async (req, res) => { 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' }); - 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) { console.error(err); res.status(500).json({ error: 'Database error' }); @@ -58,10 +127,13 @@ router.get('/:id', async (req, res) => { // Add camera (Admin only) 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 { - const sql = `INSERT INTO camera_settings (name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; - 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 encryptedUser = username ? cryptoUtil.encryptMasterKey(username) : username; + 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 }); } catch (err) { console.error(err); @@ -71,10 +143,13 @@ router.post('/', hasRole('admin'), async (req, res) => { // Update camera (Admin only) 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 { - const sql = `UPDATE camera_settings SET name=?, ip_address=?, port=?, username=?, password=?, stream_path=?, transport_mode=?, rtsp_encoding=?, quality=? WHERE id=?`; - const [result] = await db.query(sql, [name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality, req.params.id]); + const encryptedUser = username ? cryptoUtil.encryptMasterKey(username) : username; + 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' }); // 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) => { const { is_active } = req.body; 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' }); const streamRelay = req.app.get('streamRelay'); @@ -115,7 +190,7 @@ router.patch('/:id/status', hasRole('admin'), async (req, res) => { // Delete camera (Admin only) router.delete('/:id', hasRole('admin'), async (req, res) => { 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' }); // Stop stream @@ -135,10 +210,11 @@ const { exec } = require('child_process'); // ... existing routes ... + // 7. Ping Test (Troubleshooting) router.get('/:id/ping', isAuthenticated, async (req, res) => { 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' }); const ip = rows[0].ip_address; diff --git a/server/modules/cctv/streamRelay.js b/server/modules/cctv/streamRelay.js index dc3a6f9..014c066 100644 --- a/server/modules/cctv/streamRelay.js +++ b/server/modules/cctv/streamRelay.js @@ -3,6 +3,7 @@ const ffmpeg = require('fluent-ffmpeg'); const staticFfmpegBinary = require('ffmpeg-static'); // Renamed for clarity const db = require('../../db'); 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 // (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; 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) { console.error(`Camera ${cameraId} not found`); return; @@ -96,8 +97,10 @@ class StreamRelay { let rtspUrl = 'rtsp://'; if (camera.username && camera.password) { - const user = camera.rtsp_encoding ? encodeURIComponent(camera.username) : camera.username; - const pass = camera.rtsp_encoding ? encodeURIComponent(camera.password) : camera.password; + const decUser = cryptoUtil.decryptMasterKey(camera.username); + 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 += `${camera.ip_address}:${camera.port || 554}${camera.stream_path || '/stream1'}`; diff --git a/server/routes/auth.js b/server/routes/auth.js index 402da7d..c04dbb0 100644 --- a/server/routes/auth.js +++ b/server/routes/auth.js @@ -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) => { try { if (req.session.user) { - // Ensure CSRF token exists, if not generate one (edge case) + // Ensure CSRF token exists if (!req.session.csrfToken) { req.session.csrfToken = generateToken(); } - // Fetch session timeout from settings - const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'"); - const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60; + // Priority: User's individual timeout > System default + let timeoutMinutes = 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({ isAuthenticated: true, user: req.session.user, csrfToken: req.session.csrfToken, - sessionTimeout: timeoutMinutes * 60 * 1000 // Convert to ms + sessionTimeout: timeoutMinutes * 60 * 1000 }); } else { res.json({ isAuthenticated: false }); @@ -130,7 +135,7 @@ router.post('/verify-supervisor', isAuthenticated, async (req, res) => { // 2. List Users (Admin Only) router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => { 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) { return res.json([]); @@ -154,7 +159,7 @@ router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => { // 3. Create User 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) { 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 sql = ` - INSERT INTO users (id, password, name, department, position, phone, role) - VALUES (?, ?, ?, ?, ?, ?, ?) + INSERT INTO users (id, password, name, department, position, phone, role, session_timeout) + 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' }); } catch (err) { @@ -186,41 +191,20 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => { // 4. Update User 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; try { - // Fetch current to keep old values if not provided? Frontend usually sends all. - // We update everything provided. - - // Build query dynamically or just assume full update + // ... (existing logic) let updates = []; let params = []; - - if (password) { - updates.push('password = ?'); - params.push(hashPassword(password)); - } - if (name) { - updates.push('name = ?'); - 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 (password) { updates.push('password = ?'); params.push(hashPassword(password)); } + if (name) { updates.push('name = ?'); 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 (session_timeout !== undefined) { updates.push('session_timeout = ?'); params.push(session_timeout); } 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); 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) { console.error(err); res.status(500).json({ error: 'Database error' }); diff --git a/server/routes/system.js b/server/routes/system.js index 543bc05..ae382bf 100644 --- a/server/routes/system.js +++ b/server/routes/system.js @@ -274,7 +274,7 @@ router.get('/modules', isAuthenticated, async (req, res) => { const [rows] = await db.query('SELECT * FROM system_modules'); const modules = {}; - const defaults = ['asset', 'production', 'monitoring']; + const defaults = ['asset', 'production', 'cctv']; // Get stored 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 = { 'asset': '자산 관리', 'production': '생산 관리', - 'monitoring': 'CCTV' + 'cctv': 'CCTV' }; await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]); diff --git a/server/utils/cryptoUtil.js b/server/utils/cryptoUtil.js index a4f68c9..032eb46 100644 --- a/server/utils/cryptoUtil.js +++ b/server/utils/cryptoUtil.js @@ -63,6 +63,13 @@ const cryptoUtil = { return this._internalProcess(plainKey, true); }, + /** + * 내부 보안으로 암호화된 키를 복호화하는 함수 + */ + decryptMasterKey(encryptedKey) { + return this._internalProcess(encryptedKey, false); + }, + clearCache() { cachedKey = null; }, diff --git a/src/core/types.ts b/src/core/types.ts index 8c5858e..6b740dc 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -18,6 +18,7 @@ export interface IModuleRoute { */ export interface IModuleDefinition { moduleName: string; // 紐⑤뱢 ?앸퀎??(?? 'asset-management') + label?: string; // 紐⑤뱢 ?쒖떆 紐낆묶 (?? 'CCTV') basePath: string; // 紐⑤뱢??湲곕낯 寃쎈줈 (?? '/asset') routes: IModuleRoute[]; // 紐⑤뱢 ?대? ?쇱슦???뺣낫 requiredRoles?: string[]; // 紐⑤뱢 ?묎렐???꾪븳 ?꾩슂 沅뚰븳 (Optional) diff --git a/src/modules/asset/components/AssetBasicInfo.tsx b/src/modules/asset/components/AssetBasicInfo.tsx index a0a5882..a414b8d 100644 --- a/src/modules/asset/components/AssetBasicInfo.tsx +++ b/src/modules/asset/components/AssetBasicInfo.tsx @@ -10,7 +10,7 @@ import { createPortal } from 'react-dom'; import { useAuth } from '../../../shared/auth/AuthContext'; interface AssetBasicInfoProps { - asset: Asset & { image?: string, consumables?: any[] }; + asset: Asset & { image?: string, accessories?: any[] }; onRefresh: () => void; } @@ -21,6 +21,39 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) { const [isEditing, setIsEditing] = useState(false); const [editData, setEditData] = useState(asset); const [isZoomed, setIsZoomed] = useState(false); + const [allAssets, setAllAssets] = useState([]); + const [accessories, setAccessories] = useState([]); + 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 = ( e: React.ChangeEvent @@ -64,6 +97,28 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) { 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 ( <>
@@ -75,7 +130,7 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) { ) : ( canEdit && )} - +
@@ -327,51 +382,202 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) { ) : (
- + {editData.status === 'active' ? '정상 가동' : editData.status === 'disposed' ? '폐기 (말소)' : editData.status === 'maintain' ? '점검 중' : '수리 필요'}
)} + {isFacility && ( + + 상위 설비 + + {isEditing ? ( +
+ +
+ ) : ( +
+ {asset.parentId ? ( + + [{asset.parentId}] {asset.parentName || '상위 설비'} + + ) : ( + 메인 설비 (상위 설비 없음) + )} +
+ )} + + + )} + {/* Sub-Equipment Section (Only for Facilities) */} + {isFacility && ( +
+
+

부속 설비 내역

+ {canEdit && ( + + )} +
+ + + + + + + + + + + + {asset.children && asset.children.length > 0 ? ( + asset.children.map(child => ( + + + + + + + + )) + ) : ( + + + + )} + +
관리번호설비명위치상태관리
{child.id}{child.name}{child.location} + + {child.status === 'active' ? '정상' : '점검/이동'} + + + 이동 +
등록된 부속 설비가 없습니다.
+
+ )} +
- {/* Consumables Section */} -
-
-

관련 소모품 관리

- {canEdit && } -
- - - - - - - - - - - {asset.consumables?.map(item => ( - - - - - + {/* Accessories Section (For non-facility or all?) - User said skip hierarchical for others, use simple accessories */} + {!isFacility && ( +
+
+

부속품 관리

+ {canEdit && ( + + )} +
+
품명규격현재고관리
{item.name}{item.spec}{item.qty}개 - -
+ + + + + + - ))} - -
품명규격수량관리
-
+ + + {accessories.length > 0 ? ( + accessories.map(item => ( + + {item.name} + {item.spec || '-'} + {item.quantity}개 + + + + + )) + ) : ( + + 등록된 부속품이 없습니다. + + )} + + + + )}
+ {/* Accessory Add Modal */} + {showAccModal && createPortal( +
+
+

부속품 추가

+
+
+ + setNewAcc({ ...newAcc, name: e.target.value })} + /> +
+
+ + setNewAcc({ ...newAcc, spec: e.target.value })} + /> +
+
+ + setNewAcc({ ...newAcc, quantity: Number(e.target.value) })} + /> +
+
+
+ + +
+
+
, + document.body + )} + {/* Image Zoom Modal - Moved to Portal */} {isZoomed && editData.image && createPortal(
필터 + {canRegister && ( diff --git a/src/modules/asset/pages/AssetRegisterPage.tsx b/src/modules/asset/pages/AssetRegisterPage.tsx index fe10a3b..e7f3cbf 100644 --- a/src/modules/asset/pages/AssetRegisterPage.tsx +++ b/src/modules/asset/pages/AssetRegisterPage.tsx @@ -19,6 +19,7 @@ export function AssetRegisterPage() { const [formData, setFormData] = useState({ id: '', // Asset ID (Auto-generated) + parentId: '', name: '', categoryId: '', // Use ID for selection model: '', @@ -33,6 +34,20 @@ export function AssetRegisterPage() { manufacturer: '' }); + const [allAssets, setAllAssets] = useState([]); + + 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 useEffect(() => { // If category is required by rule but not selected, can't generate fully @@ -106,6 +121,7 @@ export function AssetRegisterPage() { const payload: Partial = { id: formData.id, + parentId: isFacility ? formData.parentId : undefined, name: formData.name, category: selectedCategory ? selectedCategory.name : '미지정', model: formData.model, @@ -129,6 +145,8 @@ export function AssetRegisterPage() { } }; + const isFacility = categories.find(c => c.id === formData.categoryId)?.name === '설비'; + return (
@@ -155,6 +173,22 @@ export function AssetRegisterPage() { required /> + {isFacility && ( + , label: '실시간 관제' }, + { path: '/manage', element: , label: '장치 관리' }, + { path: '/settings', element: , label: '기본 설정' }, ], requiredRoles: ['admin', 'operator', 'user'] }; diff --git a/src/modules/cctv/pages/CameraManagementPage.tsx b/src/modules/cctv/pages/CameraManagementPage.tsx new file mode 100644 index 0000000..97296b7 --- /dev/null +++ b/src/modules/cctv/pages/CameraManagementPage.tsx @@ -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([]); + const [loading, setLoading] = useState(false); + const [searchTerm, setSearchTerm] = useState(''); + const [availableZones, setAvailableZones] = useState([]); + const fetchedRef = useRef(false); + + // Modal State + const [isModalOpen, setIsModalOpen] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [formData, setFormData] = useState>({ + 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 ( +
+
+
+

+

+

시스템에 등록된 모든 CCTV 장치를 목록 형태로 관리합니다.

+
+
+ + {(user?.role === 'admin' || user?.role === 'supervisor') && ( + + )} +
+
+ + +
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+
+ + +
+ + + + + + + + + + + + {loading ? ( + + + + ) : filteredCameras.map((camera) => { + const isActive = camera.is_active !== 0 && camera.is_active !== false; + return ( + + + + + + + + ); + })} + {!loading && filteredCameras.length === 0 && ( + + + + )} + +
상태카메라 명칭구역네트워크 정보 (IP:Port)관리
데이터를 불러오는 중...
+ + +
{camera.name}
+
ID: {camera.id}
+
+ + {camera.zone || '기본 구역'} + + + {camera.ip_address}:{camera.port} + +
+ {(user?.role === 'admin' || user?.role === 'supervisor') && ( + <> + + + + )} +
+
검색 결과가 없습니다.
+
+
+ + {/* Modal */} + {isModalOpen && ( +
+ +
+

+ + {isEditing ? '장치 설정 수정' : '새 장치 등록'} +

+ +
+
+
+
+ + setFormData({ ...formData, name: e.target.value })} + placeholder="예: 정문 CCTV" + required + /> +
+
+ + +
+
+ + setFormData({ ...formData, ip_address: e.target.value })} + placeholder="192.168.1.100" + required + /> +
+
+ + setFormData({ ...formData, port: parseInt(e.target.value) || 554 })} + placeholder="554" + /> +
+
+ +
+
+ + setFormData({ ...formData, username: e.target.value })} + placeholder="admin" + /> +
+
+ + setFormData({ ...formData, password: e.target.value })} + placeholder="••••••••" + /> +
+
+ +
+
+ + setFormData({ ...formData, stream_path: e.target.value })} + placeholder="/stream1" + /> +
+
+
+ + +
+
+ + +
+
+
+ setFormData({ ...formData, rtsp_encoding: e.target.checked })} + className="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500" + /> + +
+
+ +
+ + +
+
+
+
+ )} +
+ ); +} diff --git a/src/modules/cctv/pages/CctvSettingsPage.tsx b/src/modules/cctv/pages/CctvSettingsPage.tsx new file mode 100644 index 0000000..f49a9fd --- /dev/null +++ b/src/modules/cctv/pages/CctvSettingsPage.tsx @@ -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([]); + 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 ( +
+
+

+ + CCTV 기본 설정 +

+

CCTV 모듈의 전역 설정을 관리합니다.

+
+ + +

설치 구역 및 레이아웃 관리

+

+ 관제 화면에서 사용할 구역과 각 구역별 화면 분할(레이아웃) 방식을 설정합니다. +

+ +
+
+
#
+
구역 명칭
+
분할 레이아웃 (행*열)
+
+
+ {zones.map((zone, index) => ( +
+
+ +
+ handleZoneNameChange(index, e.target.value)} + placeholder="예: 정문, A구역 등" + className="flex-1" + /> + + +
+ ))} + + {zones.length === 0 && ( +
+ 등록된 구역이 없습니다. 구역을 추가해 주세요. +
+ )} +
+ +
+ + +
+
+
+ ); +} diff --git a/src/modules/cctv/pages/MonitoringPage.tsx b/src/modules/cctv/pages/MonitoringPage.tsx index 385deab..e578184 100644 --- a/src/modules/cctv/pages/MonitoringPage.tsx +++ b/src/modules/cctv/pages/MonitoringPage.tsx @@ -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 { 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 { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy } from '@dnd-kit/sortable'; @@ -20,6 +21,7 @@ interface Camera { quality?: 'low' | 'medium' | 'original'; display_order?: number; is_active?: boolean | number; + zone?: string; } // Wrap Camera Card with Sortable @@ -38,7 +40,7 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr }; return ( -
+
{children}
); @@ -47,22 +49,12 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr export function MonitoringPage() { const { user } = useAuth(); const [cameras, setCameras] = useState([]); - const [showForm, setShowForm] = useState(false); - const [editingCamera, setEditingCamera] = useState(null); - const [formData, setFormData] = useState>({ - name: '', - ip_address: '', - port: 554, - username: '', - password: '', - stream_path: '/stream1', - transport_mode: 'tcp', - rtsp_encoding: false, - quality: 'low' - }); - const [loading, setLoading] = useState(false); + const [activeZone, setActiveZone] = useState(''); + const [availableZones, setAvailableZones] = useState<{ name: string, layout: string }[]>([]); + const [showLayoutMenu, setShowLayoutMenu] = useState(false); + const fetchedRef = useRef(false); - const [streamVersions, setStreamVersions] = useState<{ [key: number]: number }>({}); + const [streamVersions] = useState<{ [key: number]: number }>({}); const sensors = useSensors( useSensor(PointerSensor), @@ -72,6 +64,8 @@ export function MonitoringPage() { ); useEffect(() => { + if (fetchedRef.current) return; + fetchedRef.current = true; fetchCameras(); }, []); @@ -84,61 +78,49 @@ export function MonitoringPage() { } }; - const handleInputChange = (e: React.ChangeEvent) => { - const { name, value } = e.target; - setFormData(prev => ({ ...prev, [name]: value })); - }; - - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); + const fetchZones = async () => { try { - if (editingCamera) { - await apiClient.put(`/cameras/${editingCamera.id}`, formData); - // Force stream refresh for this camera - setStreamVersions(prev => ({ ...prev, [editingCamera.id]: Date.now() })); - } else { - await apiClient.post('/cameras', formData); + const res = await apiClient.get('/cameras/zones'); + const zones = res.data.map((z: any) => ({ + name: z.name, + layout: z.layout || '2*2' + })); + 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) { - console.error('Failed to save camera', err); - const errMsg = (err as any).response?.data?.error || (err as any).message || '카메라 저장 실패'; - alert(`오류 발생: ${errMsg}`); - } finally { - setLoading(false); + console.error('Failed to fetch zones', err); } }; - const handleEdit = (camera: Camera) => { - setEditingCamera(camera); - setFormData(camera); - setShowForm(true); - }; + // Determine layout based on active zone's setting + const currentZoneConfig = availableZones.find(z => z.name === activeZone); + const viewLayout = currentZoneConfig ? currentZoneConfig.layout : '2*2'; + + 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 { - await apiClient.delete(`/cameras/${id}`); - fetchCameras(); + await apiClient.patch(`/cameras/zones/${activeZone}/layout`, { layout: newLayout }); } 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) => { // Optimistic UI Update: Calculate new status const currentStatus = camera.is_active !== 0 && camera.is_active !== false; @@ -226,185 +208,186 @@ export function MonitoringPage() { useEffect(() => { fetchCameras(); + fetchZones(); checkServerVersion(); }, []); - return ( -
-
-

-

- {(user?.role === 'admin' || user?.role === 'supervisor') && ( - - )} -
- {/* Camera Grid with DnD */} - - c.id)} - strategy={rectSortingStrategy} + const HeaderDropdown = () => { + const portalRoot = document.getElementById('header-portal-root'); + if (!portalRoot) return null; + + // Tabs: [Zones...] + const zoneTabs = availableZones.map(z => z.name); + const layoutOptions = [ + { id: '1', label: '1개', icon:
}, + { id: '1*2', label: '1*2', icon:
}, + { id: '2*2', label: '2*2', icon:
}, + { id: '3*3', label: '3*3', icon:
}, + { id: '4*4', label: '4*4', icon:
}, + { id: '5*5', label: '5*5', icon:
}, + { id: '6*6', label: '6*6', icon:
} + ]; + + return createPortal( +
+ {/* Left Area: Zone Tabs */} +
+ {zoneTabs.map(name => ( + + ))} +
+ + {/* Spacer to push everything else to the right */} +
+ + {/* Right Area: Layout Selector */} +
+
+ + + {showLayoutMenu && activeZone && ( +
+
+ Monitor Arrangement +
+
+ {layoutOptions.map(opt => ( + + ))} +
+
+ )} +
+
+
, + 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 ( +
+ + +
+ -
- {cameras.map(camera => ( - -
- - {/* Overlay Controls */} - {(user?.role === 'admin' || user?.role === 'supervisor') && ( -
e.stopPropagation()}> - - - - + c.id)} + strategy={rectSortingStrategy} + disabled={true} + > +
layoutInfo.total ? 'overflow-y-auto' : ''}`} + style={{ gridAutoRows: (viewLayout === '1' || viewLayout === '1*2') ? '100%' : `${100 / Math.sqrt(layoutInfo.total)}%` }}> + {slots.map((camera, index) => ( +
+ {camera ? ( + +
+ {/* VMS Slot Header */} +
+
+
+ {camera.name} +
+ + {(user?.role === 'admin' || user?.role === 'supervisor') && ( +
e.stopPropagation()}> + + +
+ )} +
+ + {/* Streaming Video */} +
+ +
+ + {/* Small persistent ID or Name overlay */} +
+ CH {index + 1} | {camera.ip_address} +
+
+
+ ) : ( +
+
)} -
-

- - {camera.name} {(camera.is_active === 0 || camera.is_active === false) && (중지됨)} -

- {/* IP/Port Hidden as requested */} -
- - ))} -
-
- - - {cameras.length === 0 && ( -
-
- )} - - {/* Add/Edit Modal */} - {showForm && ( -
-
-
-

- {editingCamera ? '카메라 설정 수정' : '새 카메라 추가'} -

- + ))}
- -
-
- - -
-
-
-
-
-
-
-
-
-
- - -
- - {/* Advanced Settings */} -
-

- 고급 설정 -

-
-
- - -
-
- -
-
- - -
-
-
- -
- - -
-
-
-
- )} + + +
); } diff --git a/src/platform/pages/BasicSettingsPage.tsx b/src/platform/pages/BasicSettingsPage.tsx index 0462313..2056404 100644 --- a/src/platform/pages/BasicSettingsPage.tsx +++ b/src/platform/pages/BasicSettingsPage.tsx @@ -1,10 +1,10 @@ -import { useState, useEffect } from 'react'; +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 { 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 { session_timeout: number; @@ -65,8 +65,11 @@ export function BasicSettingsPage() { const [verifyPassword, setVerifyPassword] = useState(''); const [verifying, setVerifying] = useState(false); const [verifyError, setVerifyError] = useState(''); + const fetchedRef = useRef(false); useEffect(() => { + if (fetchedRef.current) return; + fetchedRef.current = true; fetchSettings(); fetchEncryptionStatus(); }, []); @@ -236,56 +239,15 @@ export function BasicSettingsPage() {
- {/* Section 1: Security & Session */} - -
-

- - 보안 및 세션 -

-
-
- -
-
- { - const h = parseInt(e.target.value) || 0; - setSettings({ ...settings, session_timeout: (h * 60) + (settings.session_timeout % 60) }); - }} - /> - 시간 -
-
- { - const m = parseInt(e.target.value) || 0; - setSettings({ ...settings, session_timeout: (Math.floor(settings.session_timeout / 60) * 60) + m }); - }} - /> - -
-
-
-
-
- {saveResults.security && ( -
- {saveResults.security.success ? : } - {saveResults.security.message} -
- )} -
-
- - -
+ {/* v0.4.1.0 Info: Session Timeout relocation notice */} + + +
+

세션 관리 정책 변경 안내 (v0.4.1.0)

+

+ 보안 강화 및 개인화 설정을 위해 전역 세션 로그아웃 설정이 개별 사용자 설정으로 이전되었습니다.
+ 좌측 메뉴 하단의 [기본 설정] 메뉴 또는 [사용자 관리] 메뉴에서 사용자별로 시간을 설정하실 수 있습니다. +

diff --git a/src/platform/pages/GeneralPreferencesPage.tsx b/src/platform/pages/GeneralPreferencesPage.tsx index d47535d..ea90809 100644 --- a/src/platform/pages/GeneralPreferencesPage.tsx +++ b/src/platform/pages/GeneralPreferencesPage.tsx @@ -2,14 +2,18 @@ import { useState } from 'react'; import { Card } from '../../shared/ui/Card'; import { Button } from '../../shared/ui/Button'; 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 { apiClient } from '../../shared/api/client'; export function GeneralPreferencesPage() { const { user } = useAuth(); 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 landingPageOptions = [ @@ -19,10 +23,25 @@ export function GeneralPreferencesPage() { { label: '생산 대시보드', value: '/production/dashboard' } ]; - const handleSave = () => { - localStorage.setItem(`landing_page_${user?.id}`, preferences.landingPage); - setIsSaved(true); - setTimeout(() => setIsSaved(false), 3000); + const handleSave = async () => { + setIsSaving(true); + try { + // 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 ( @@ -71,18 +90,65 @@ export function GeneralPreferencesPage() { 소속 부서 {user?.department || '미지정'}
+
+ 현재 세션 만료 + {user?.session_timeout}분 +
+ +
+ +

보안 및 세션 설정

+
+ +
+

+ 활동이 없을 경우 자동으로 로그아웃되는 시간을 설정합니다. (단위: 분) +

+
+ setPreferences({ ...preferences, sessionTimeout: parseInt(e.target.value) || 0 })} + min={5} + max={1440} + className="!w-32" + /> + 분 후 자동 로그아웃 +
+

+ * 최소 5분에서 최대 1440분(24시간)까지 설정 가능합니다. +

+
+
+ + {user?.role !== 'user' && ( + +
+ +
+

관리자 안내

+

+ 기존 '시스템 관리 - 기본 설정'에 있던 전역 세션 설정은 v0.4.1.0 업데이트로 인해 폐지되었습니다.
+ 이제 모든 사용자의 세션 시간은 각 사용자의 개별 설정 또는 사용자 관리 메뉴에서 개별적으로 관리됩니다. +

+
+
+
+ )} +
{isSaved && ✓ 설정이 저장되었습니다.}
diff --git a/src/platform/pages/UserManagementPage.tsx b/src/platform/pages/UserManagementPage.tsx index ab493cf..5bb57c2 100644 --- a/src/platform/pages/UserManagementPage.tsx +++ b/src/platform/pages/UserManagementPage.tsx @@ -1,9 +1,9 @@ -import { useState, useEffect } from 'react'; +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, 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 { useAuth } from '../../shared/auth/AuthContext'; import './UserManagementPage.css'; @@ -16,12 +16,14 @@ interface UserFormData { position: string; phone: string; role: 'supervisor' | 'admin' | 'user'; + session_timeout: number; } export function UserManagementPage() { const { user: currentUser } = useAuth(); const [users, setUsers] = useState([]); const [loading, setLoading] = useState(false); + const fetchedRef = useRef(false); // Modal State const [isModalOpen, setIsModalOpen] = useState(false); @@ -33,10 +35,13 @@ export function UserManagementPage() { department: '', position: '', phone: '', - role: 'user' + role: 'user', + session_timeout: 10 }); useEffect(() => { + if (fetchedRef.current) return; + fetchedRef.current = true; fetchUsers(); }, []); @@ -61,7 +66,8 @@ export function UserManagementPage() { department: '', position: '', phone: '', - role: 'user' + role: 'user', + session_timeout: 10 }); setIsEditing(false); setIsModalOpen(true); @@ -75,7 +81,8 @@ export function UserManagementPage() { department: user.department || '', position: user.position || '', phone: user.phone || '', - role: user.role + role: user.role, + session_timeout: (user as any).session_timeout || 10 }); setIsEditing(true); setIsModalOpen(true); @@ -149,7 +156,10 @@ export function UserManagementPage() {

시스템 관리 - 사용자 관리

시스템 접속 권한 및 사용자 정보를 관리합니다.

- +
+ + +
@@ -160,6 +170,7 @@ export function UserManagementPage() { 아이디 / 권한 이름 소속 / 직위 + 세션 (분) 연락처 마지막 접속 관리 @@ -186,6 +197,9 @@ export function UserManagementPage() {
{user.department || '-'}
{user.position}
+ + {(user as any).session_timeout || 10} + {user.phone || '-'} {user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'} @@ -265,17 +279,14 @@ export function UserManagementPage() { value={formData.role} onChange={(e) => setFormData({ ...formData, role: e.target.value as any })} disabled={ - // 1. 현재 관리자(admin)는 최고관리자(supervisor)의 권한을 바꿀 수 없음 (isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor' && currentUser?.role !== 'supervisor') || - // 2. 현재 관리자(admin)는 자기 자신의 권한을 'supervisor'로 올릴 수 없음 (애초에 옵션에서 걸러지겠지만 안전 장치) (formData.role === 'supervisor' && currentUser?.role !== 'supervisor') } > - + - {/* 최고관리자(supervisor) 옵션은 오직 최고관리자만 부여 가능 */} {(currentUser?.role === 'supervisor' || (isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor')) && ( - + )} {currentUser?.role !== 'supervisor' && ( @@ -301,14 +312,29 @@ export function UserManagementPage() {
-
- - setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })} - placeholder="010-0000-0000" - maxLength={13} - /> +
+
+ + setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })} + placeholder="010-0000-0000" + maxLength={13} + /> +
+
+ + setFormData({ ...formData, session_timeout: parseInt(e.target.value) || 0 })} + min={5} + placeholder="기본 10" + required + /> +
diff --git a/src/platform/styles/global.css b/src/platform/styles/global.css index ab6842e..73045aa 100644 --- a/src/platform/styles/global.css +++ b/src/platform/styles/global.css @@ -113,4 +113,126 @@ button { opacity: 1; 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; + } } \ No newline at end of file diff --git a/src/shared/api/assetApi.ts b/src/shared/api/assetApi.ts index 2aefcbe..e4c2aa7 100644 --- a/src/shared/api/assetApi.ts +++ b/src/shared/api/assetApi.ts @@ -3,6 +3,8 @@ import { apiClient } from './client'; // Frontend Interface (CamelCase) export interface Asset { id: string; + parentId?: string; // DB: parent_id + parentName?: string; // Joined name: string; category: string; model: string; // DB: model_name @@ -16,11 +18,15 @@ export interface Asset { image?: string; // DB: image_url calibrationCycle?: string; // DB: calibration_cycle specs?: string; + quantity?: number; + children?: Partial[]; // List of sub-assets } // DB Interface (SnakeCase) - for internal mapping interface DBAsset { id: string; + parent_id?: string; + parent_name?: string; // Joined or added in response name: string; category: string; model_name: string; @@ -34,8 +40,10 @@ interface DBAsset { image_url?: string; calibration_cycle: string; specs: string; + quantity?: number; created_at: string; updated_at: string; + children?: any[]; } export interface MaintenanceRecord { @@ -81,7 +89,20 @@ export const assetApi = { getById: async (id: string): Promise => { const response = await apiClient.get(`/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) => { @@ -136,12 +157,27 @@ export const assetApi = { deleteManual: async (id: number) => { return apiClient.delete(`/manuals/${id}`); + }, + + // Accessories + getAccessories: async (assetId: string): Promise => { + const response = await apiClient.get(`/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 const mapDBToAsset = (db: DBAsset): Asset => ({ id: db.id, + parentId: db.parent_id, name: db.name, category: db.category, model: db.model_name || '', @@ -154,11 +190,13 @@ const mapDBToAsset = (db: DBAsset): Asset => ({ purchasePrice: db.purchase_price, image: db.image_url, calibrationCycle: db.calibration_cycle || '', - specs: db.specs || '' + specs: db.specs || '', + quantity: db.quantity }); const mapAssetToDB = (asset: Partial): Partial => { 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.serialNumber !== undefined) { db.serial_number = asset.serialNumber; delete db.serialNumber; } if (asset.purchaseDate !== undefined) { db.purchase_date = asset.purchaseDate; delete db.purchaseDate; } diff --git a/src/shared/auth/AuthContext.tsx b/src/shared/auth/AuthContext.tsx index d5d292f..9f1672d 100644 --- a/src/shared/auth/AuthContext.tsx +++ b/src/shared/auth/AuthContext.tsx @@ -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'; export interface User { @@ -8,6 +8,7 @@ export interface User { department?: string; position?: string; phone?: string; + session_timeout?: number; last_login?: string; } @@ -24,46 +25,46 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [user, setUser] = useState(null); 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(() => { - let lastActivity = Date.now(); - let timeoutMs = 3600000; // Default 1 hour + const checkSession = async (isBackground = false) => { + if (isCheckingRef.current) return; + isCheckingRef.current = true; - const checkSession = async () => { try { const response = await apiClient.get('/check'); if (response.data.isAuthenticated) { setUser(response.data.user); 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 { // Safety: only redirect if we were previously logged in setUser(prev => { - if (prev) { + if (prev || !isBackground) { setCsrfToken(null); - window.location.href = '/login?expired=true'; + if (!window.location.pathname.includes('/login')) { + window.location.href = '/login?expired=true'; + } return null; } return prev; }); } } catch (error) { - setUser(prev => { - if (prev) { - setCsrfToken(null); - window.location.href = '/login?expired=true'; - return null; - } - return prev; - }); + console.error('Session check failed', error); } finally { + isCheckingRef.current = false; setIsLoading(false); } }; const updateActivity = () => { - lastActivity = Date.now(); + lastActivityRef.current = Date.now(); }; // Activity Listeners @@ -72,25 +73,19 @@ export function AuthProvider({ children }: { children: ReactNode }) { window.addEventListener('scroll', updateActivity); window.addEventListener('click', updateActivity); + // Initial check on mount checkSession(); - // Check activity status every 30 seconds + // Check activity status const activityInterval = setInterval(() => { - // Functional check to avoid stale user closure - setUser(current => { - if (current) { - const idleTime = Date.now() - lastActivity; - if (idleTime >= timeoutMs) { - console.log('Idle timeout reached. Checking session...'); - checkSession(); - } - } - return current; - }); + const idleTime = Date.now() - lastActivityRef.current; + if (idleTime >= timeoutMsRef.current) { + checkSession(true); + } }, 30000); - // Fallback polling every 5 minutes (for secondary tabs etc) - const pollInterval = setInterval(checkSession, 300000); + // Fallback polling + const pollInterval = setInterval(() => checkSession(true), 300000); return () => { window.removeEventListener('mousemove', updateActivity); @@ -100,7 +95,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { clearInterval(activityInterval); clearInterval(pollInterval); }; - }, []); // Removed [user] to prevent infinite loop + }, []); const login = async (id: string, password: string) => { try { diff --git a/src/shared/context/SystemContext.tsx b/src/shared/context/SystemContext.tsx index 72ed3aa..1484897 100644 --- a/src/shared/context/SystemContext.tsx +++ b/src/shared/context/SystemContext.tsx @@ -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 { useAuth } from '../auth/AuthContext'; @@ -20,6 +20,7 @@ export function SystemProvider({ children }: { children: ReactNode }) { const [modules, setModules] = useState>({}); const [isLoading, setIsLoading] = useState(true); const { isAuthenticated } = useAuth(); // Only load if authenticated + const isFetchingRef = useRef(null); // Track if already fetched for current state const refreshModules = async () => { try { @@ -40,8 +41,12 @@ export function SystemProvider({ children }: { children: ReactNode }) { useEffect(() => { if (isAuthenticated) { + // Only fetch if we haven't fetched for this authenticated state yet + if (isFetchingRef.current === 'authed') return; + isFetchingRef.current = 'authed'; refreshModules(); } else { + isFetchingRef.current = 'guest'; setModules({}); setIsLoading(false); } diff --git a/src/system/pages/LicensePage.tsx b/src/system/pages/LicensePage.tsx index a63f45d..9b68183 100644 --- a/src/system/pages/LicensePage.tsx +++ b/src/system/pages/LicensePage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useSystem } from '../../shared/context/SystemContext'; 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() { const { modules, refreshModules } = useSystem(); @@ -92,10 +92,19 @@ export function LicensePage() { return (
-

- - 라이선스 및 모듈 관리 -

+
+

+ + 라이선스 및 모듈 관리 +

+ +

diff --git a/src/widgets/layout/MainLayout.css b/src/widgets/layout/MainLayout.css index 27634a5..396d929 100644 --- a/src/widgets/layout/MainLayout.css +++ b/src/widgets/layout/MainLayout.css @@ -203,11 +203,17 @@ background-color: var(--sokuree-bg-card); border-bottom: 1px solid var(--sokuree-border-color); display: flex; - align-items: flex-end; - padding: 0 2.5rem; + align-items: center; + padding: 0 1.5rem; color: var(--sokuree-text-primary); - justify-content: flex-end; - /* Align to right */ + justify-content: space-between; +} + +.header-portal-root { + flex: 1; + display: flex; + align-items: center; + height: 100%; } .header-tabs { diff --git a/src/widgets/layout/MainLayout.tsx b/src/widgets/layout/MainLayout.tsx index d50cdb1..bcf6fcc 100644 --- a/src/widgets/layout/MainLayout.tsx +++ b/src/widgets/layout/MainLayout.tsx @@ -111,8 +111,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) { onClick={() => toggleModule(mod.moduleName)} >
- - {mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1)} 관리 + {mod.moduleName === 'cctv' ?
{isExpanded ? : } @@ -182,7 +182,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) { >
)} @@ -262,6 +262,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {

)}
+