Compare commits

..

40 Commits
v0.3.4 ... main

Author SHA1 Message Date
choibk
ff7d4e4b9a [STABLE] v0.4.2.7 - Accurate version detection and project synchronization 2026-01-26 10:03:56 +09:00
choibk
c0c4cf3ab2 [UI] Re-align View/Change buttons in Basic Settings for consistency 2026-01-26 09:57:59 +09:00
choibk
9f81c0d03e [FIX] Resolve path issues for Git in Dev and re-enable auto update check 2026-01-26 09:55:58 +09:00
choibk
ead5ff0fdf [STABLE] v0.4.2.4 - Robust .env protection during updates and manual update check UI 2026-01-26 09:49:00 +09:00
choibk
2f210d3b34 [FIX] Resolve ER_DUP_ENTRY during cctv module migration 2026-01-26 09:39:44 +09:00
choibk
0efe144b09 [STABLE] v0.4.2.2 - Critical stability fixes for production (Synology/NAS) 2026-01-26 09:38:32 +09:00
choibk
3aa442e0fa [FIX] Enhanced DB diagnostic messages for connection testing 2026-01-26 09:33:53 +09:00
choibk
576880c5bd [RELEASE] v0.4.2.0 - License management security, environment isolation, and auto-fill prevention 2026-01-26 09:25:36 +09:00
choibk
ea82919bfe hotfix: remove server/.env from git and implement backup/restore logic in update script to enforce dev/prod separation 2026-01-26 08:40:07 +09:00
choibk
ab9aecd7d5 fix(system): decouple update process into standalone detached script 2026-01-26 08:30:52 +09:00
choibk
37ab4680c5 fix(system): implement dynamic versioning using git tags to follow update rules 2026-01-26 00:59:04 +09:00
choibk
af849ba8ec feat(cctv): optimize real-time monitoring immediate video feed and layout sync 2026-01-26 00:44:35 +09:00
choibk
da6d30a718 버전 정정: package.json 내 버전 텍스트를 v0.4.0.1로 실질적 업데이트 2026-01-25 02:28:47 +09:00
choibk
2c99291575 버전 루프 해결: 서버와 배포 버전 간의 package.json 참조 경로 일치화 2026-01-25 02:23:46 +09:00
choibk
a540539df8 버전 관리 최종 안정화: 동적 버전 참조 및 캐시 방지 로직 적용 (v0.4.0.1) 2026-01-25 02:14:11 +09:00
choibk
d2a2ddfbda 버그 수정: 업데이트 서버 참조 오류(ReferenceError) 해결 및 스크립트 실행력 복구 2026-01-25 02:07:01 +09:00
choibk
254cc34daf 버전 관리 시스템 안정화: 업데이트 스크립트 실행력 강화 및 윈도우 호환성 개선 2026-01-25 02:04:44 +09:00
choibk
5e740a39a9 UI 최종 정리: 배너 요약 제목 중복 괄호 제거 (v0.4.0.1) 2026-01-25 02:00:42 +09:00
choibk
9531418010 파싱 로직 보정: BUILD 타입 인식 기능 추가 (v0.4.0.1) 2026-01-25 01:57:24 +09:00
choibk
8037f2b8cc UI 보완: 배너 내 업데이트 상세 내역 표시 기능 복구 및 디버그 정보 제거 (v0.4.0.1) 2026-01-25 01:53:28 +09:00
choibk
68c25928e1 버전 관리 시스템 최종 안정화: 안내 배너 로직 및 가시성 문제 해결 (v0.4.0.1) 2026-01-25 01:47:14 +09:00
choibk
76f5bdc263 버전 업데이트: v0.4.0.1 - 히스토리 페이지네이션 및 UI 최적화 완료 2026-01-25 01:37:42 +09:00
choibk
dc68ccd7b9 운영 문서 최신화: 4자리 버전 관리 체계 지침 반영 2026-01-25 01:27:48 +09:00
choibk
cd61726b8e UI 개선: 업데이트 히스토리 페이지네이션 및 최대 표기 제한(50개) 적용 2026-01-25 01:24:12 +09:00
choibk
0ee02e68ee UI 개선: 업데이트 히스토리 상세 내역 너비 확장 및 가독성 개선 2026-01-25 01:21:01 +09:00
choibk
586e09c8af 버전 관리 시스템 혁신: 4자리 버전 체계(MAJOR.MINOR.PATCH.BUILD) 도입, 규정 문서 작성 및 서버 시각 KST 적용 2026-01-25 01:18:42 +09:00
choibk
3ba4139eb6 버전 관리 시스템 개선: 히스토리 필터링 로직 도입, 하드코딩 제거 및 업데이트 안내 가시성 강화 2026-01-25 00:35:56 +09:00
choibk
bc16a84db5 릴리즈 v0.4.0: 프리미엄 랜딩 페이지 및 사용자 환경 설정 기능 도입 2026-01-25 00:27:40 +09:00
choibk
9614f8d56b 플랫폼 고도화: 프리미엄 랜딩 페이지 및 사용자별 기본 설정(랜딩 페이지 선택) 기능 추가 2026-01-25 00:25:08 +09:00
choibk
ff45871d22 운영 가이드 강화: 안전 중심의 통합 공정(Checklist) 구조로 개편 및 경고 문구 강화 2026-01-25 00:15:11 +09:00
choibk
11fe30a683 운영 가이드 대개편: 실측 DB명(sokuree_platform_prod) 반영 및 수동 업데이트 절차 내 백업/복구 단계 통합 2026-01-25 00:11:19 +09:00
choibk
aa12234bd1 운영 가이드 수정: 시놀로지 MariaDB 10 권한 오류 해결을 위해 전용 계정(choibk) 및 포트(3307) 정보 반영 2026-01-25 00:09:29 +09:00
choibk
cb8d9713b3 운영 가이드 최종 업데이트: 시놀로지 전용 백업/복구 경로(/volume1/backup/smart_ims) 및 절차 명시 2026-01-25 00:04:16 +09:00
choibk
812e7b8504 운영 가이드 고도화: 시놀로지 환경 데이터 백업 절차 및 자동 업데이트 안전성 안내 추가 2026-01-25 00:03:46 +09:00
choibk
7ccd9e933c 릴리즈 v0.3.5: 이미지 유실 방지 가이드 및 데이터 유지관리 정책 추가 2026-01-24 23:59:26 +09:00
choibk
ee30c45ca9 문서 업데이트: 운영 서버 수동 업데이트 시 원격 태그 확인 명령어 추가 2026-01-24 23:49:21 +09:00
choibk
d0eb280e06 문서 업데이트: 개발 서버와 운영 서버의 구체적인 작업 경로 명시 2026-01-24 23:48:31 +09:00
choibk
c2e0a7804a 문서 업데이트: 명령어 실행 위치(Root/Server) 명시 및 가이드 보강 2026-01-24 23:47:10 +09:00
choibk
d6784807f2 문서 업데이트: git 태그 작성 규칙 -> git 운영 규칙 (수동 업데이트 가이드 추가) 2026-01-24 23:44:06 +09:00
choibk
9aa535278d 릴리즈 v0.3.5: 원격 태그 동기화 안정화 및 API 캐시 방지 패치 2026-01-24 23:41:28 +09:00
56 changed files with 3373 additions and 661 deletions

4
.gitignore vendored
View File

@ -39,6 +39,10 @@ ehthumbs.db
Desktop.ini
# Project Specific - Server
server/.env
server/.env.backup*
server/*.tmp
server/backups/
server/uploads/*
!server/uploads/.gitkeep
server/server.zip

View File

@ -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/`:
- 라이선스 관리 도구, 데이터 마이그레이션 스크립트 등 개발 보조 도구 포함.

View File

@ -0,0 +1,49 @@
# 📋 자산 분류 및 등록 기준 가이드 (Asset Classification Guide)
이 문서는 SmartIMS 자산 관리 시스템에서 각 메뉴(카테고리)별로 등록해야 할 자산의 구분 기준과 대표적인 품목들을 정의합니다. 자산 등록 시 올바른 카테고리를 선택하는 기준으로 활용하시기 바랍니다.
---
## 1. 🏗️ 시설물 관리 (Facilities)
**구분**: 생산 설비, 건축물 부속 설비 등 사업장의 유지 운영에 필요한 규모가 큰 고정 자산
- **생산 설비**: CNC 머시닝 센터, 사출 성형기, 프레스기, 산업용 로봇, 생산 라인 컨베이어 등
- **유틸리티 설비**: 공기 압축기(컴프레셔), 변압기, 배전반, 산업용 보일러, 항온항습기, 대형 펌프 등
- **건축 부속**: 소방 설비 시스템, 보안 시스템(CCTV 본체 등), 엘리베이터, 중앙 제어 조명 등
## 2. 🔧 공구 관리 (Tools)
**구분**: 생산 및 정비 작업에 사용되는 도구 중 비교적 내구성이 있고 개별 관리가 필요한 품목
- **전동/에어 공구**: 충전 드릴, 그라인더, 임팩트 렌치, 에어 커터, 전기 톱 등
- **수공구 및 일반**: 토크 렌치, 대형 바이스, 특수 렌치 세트, 공구함 등
- **치공구/금형**: 전용 지그(Jig), 성형 금형(Die), 툴 홀더, 척(Chuck) 등
## 3. 📏 계측기 관리 (Instruments)
**구분**: 정밀도 유지가 중요하며 법적 또는 사내 규정에 따라 **정기적인 검교정(Calibration)**이 필요한 장비
- **길이/정밀 측정**: 버니어 캘리퍼스, 마이크로미터, 다이얼 게이지, 하이트 게이지, 3차원 측정기 등
- **전기/전자 측정**: 디지털 멀티미터, 오실로스코프, 절연 저항계, 전력 분석기 등
- **물리/환경 측정**: 정밀 저울, 온도/습도 기록계, 압력계, 소음계, 가스 분석기 등
## 4. 🚚 차량/운반 (Vehicles)
**구분**: 인원 이동, 자재 수송 및 상하역 작업을 수행하는 모빌리티 장비
- **운반/하역 장비**: 지게차(Forklift), 전동 스태커, 핸드 자키, 전동 대차(Cart) 등
- **사내외 차량**: 법인 승용차, 화물 트럭(1톤~5톤), 통근 버스 등
- **특수 장비**: 고소 작업대, 이동식 크레인, 트랙터 등
## 5. 💻 일반 자산 (General Assets)
**구분**: 사무 환경 조성 및 지원 업무에 사용되는 집기류 및 IT 기기
- **IT/사무 기기**: PC 본체, 모니터, 노트북, 복합기, 레이저 프린터, 서버 서버랙 등
- **사무 가구**: 책상, 사무용 의자, 회의용 테이블, 문서 보관함(캐비닛), 금고 등
- **생활/환경 비품**: 공용 냉장고, 정수기, 공기 청정기, 사내 공지용 TV(DID) 등
## 6. 📦 소모품 관리 (Consumables)
**구분**: 사용 시 가치가 즉시 소멸되거나 교체 주기가 매우 짧아 재고 수량 관리가 중요한 물품
- **가공 소모품**: 드릴 비트, 엔드밀, 절단석, 사포, 용접봉 등
- **유지보수 부자재**: 산업용 오일(윤활유), 에어 필터, 구동 벨트, 베어링, 전구/LED 램프 등
- **사무/안전 용품**: 프린터 토너/카트리지, 복사 용지, 장갑, 마스크, 안전화 등
---
## 💡 등록 시 주의사항
1. **관리번호 자동 생성**: 각 카테고리 선택 시 설정된 규칙(예: `FAC-2024-001`)에 따라 관리번호가 부여됩니다.
2. **검교정 대상 확인**: 계측기 카테고리 품목은 등록 시 반드시 **교정 주기**를 설정하여 알림을 관리하십시오.
3. **위치 정보**: 자산 이동 발생 시 시스템의 **설치 위치** 정보를 업데이트하여 실물과 일치시키십시오.
4. **사진 첨부**: 외관 확인 및 식별을 위해 자산 정면과 명판(Nameplate) 사진을 함께 등록할 것을 권장합니다.

View File

@ -0,0 +1,236 @@
# 🚀 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`
- [ ] **라이선스 관리 오류 수정**
- [ ] **시스템 관리 기본설정 오류 수정**
- [ ] **시스템 업데이트 오류 수정**
#### 🏷️ Tag: `v0.4.3.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

BIN
docs/roadmap/image-1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

BIN
docs/roadmap/image-2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

BIN
docs/roadmap/image-3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

BIN
docs/roadmap/image-4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

BIN
docs/roadmap/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

@ -0,0 +1,158 @@
# 🏷️ Git 운영 및 통합 업데이트 관리 규칙
이 문서는 Smart IMS 플랫폼의 **Git 운영 정책**, **태그 작성 규칙**, 그리고 **운영 서버 수동 업데이트 방법**을 설명합니다.
---
## 1. 태그 메시지 작성 규칙 (Changelog 자동 생성)
플랫폼의 업데이트 엔진은 태그 메시지의 **머리말(Prefix)**과 **리스트 기호(`-`, `*`)**를 분석하여 관리 페이지의 업데이트 히스토리와 배너를 생성합니다. 상세 버전 번호 부여 방식은 `docs/version manage.md`의 규정을 따릅니다.
### 📋 메시지 구조
```text
[TYPE] 업데이트 제목 (한 줄 요약)
- 상세 변경 내용 1
- 상세 변경 내용 2
* 상세 변경 내용 3
```
### 🏷️ 업데이트 타입 (TYPE) 정의
| 키워드 | 업데이트 유형 | 시각적 표시 | 비고 |
|:--- |:--- |:--- |:--- |
| `[URGENT]` | **긴급/보안** | 빨간색 배너 | 최우선 업데이트 권장 |
| `[HOTFIX]` | **긴급 수정** | 빨간색 배너 | 버그 즉시 수정 건 |
| `[FEATURE]` | **기능 추가** | 남색(Indigo) | 새로운 기능 도입 |
| `[FIX]` | **일반 수정** | 회색(Slate) | 일반적인 버그 수정 |
| `[PATCH]` | **패치/보정** | 회색(Slate) | 마이너 품질 개선 |
---
## 2. 개발 및 배포 절차 (개발 PC)
개발 작업 및 태그 생성은 아래 경로에서 진행합니다.
* **작업 경로**: `d:\antigravity\smartims` (윈도우 개발 환경 기반)
### STEP 1: 코드 변경 사항 커밋 및 푸시
```powershell
# [위치: d:\antigravity\smartims (루트)]
git add .
git commit -m "릴리즈 준비: v0.4.0.0 패치"
git push origin main
```
### STEP 2: 어노테이티드 태그(Annotated Tag) 생성
```powershell
# [위치: d:\antigravity\smartims (루트)]
git tag -a v0.4.0.0 -m "[PATCH] 시스템 안정화 패치
- 4자리 버전 관리 체계(MAJOR.MINOR.PATCH.BUILD) 도입
- 원격 태그 동기화 로직 및 UI 가시성 강화"
```
### STEP 3: 원격 저장소에 태그 푸시
```powershell
# [위치: d:\antigravity\smartims (루트)]
git push origin v0.4.0.0
```
---
## 3. 운영 서버 수동 업데이트 통합 공정 [작업 전 필수 확인]
운영 환경에서 작업을 수행할 때는 **반드시 아래 순서를 1번부터 차례대로 수행**하십시오. 단계를 건너뛸 경우 데이터 유실 시 복구가 불가능합니다.
---
### 🚨 [STEP 1] 데이터 백업 (무조건 수행 - 예외 없음)
업데이트 명령을 입력하기 전, 현재의 이미지 파일과 데이터베이스를 안전 경로(`/volume1/backup/smart_ims`)로 격리합니다.
#### 1-1. 업로드 파일(이미지/매뉴얼) 백업
```bash
# [실행 위치: /volume1/web/smartims]
# 이 명령은 등록된 모든 자산 이미지를 압축하여 보관합니다.
tar -czvf /volume1/backup/smart_ims/backup_images_$(date +%Y%m%d).tar.gz server/uploads/
```
#### 1-2. 데이터베이스(DB) 전체 백업
```bash
# [실행 위치: /volume1/web/smartims]
# DB명: sokuree_platform_prod, 포트: 3307 사용
/usr/local/mariadb10/bin/mysqldump -u choibk -p --port 3307 sokuree_platform_prod > /volume1/backup/smart_ims/backup_db_$(date +%Y%m%d).sql
```
> **주의**: 비밀번호 요청 시 `.env` 파일의 `DB_PASSWORD`(`^Ocean...`)를 입력하세요.
---
### 🚀 [STEP 2] 버전 동기화 및 코드 반영
백업이 완료된 것이 확인된 경우에만 아래 명령어를 실행하여 소스를 업데이트합니다.
```bash
# [실행 위치: /volume1/web/smartims]
# 1. 원격 저장소의 태그 목록 확인 (배포된 최신 버전이 무엇인지 체크)
git ls-remote --tags origin
# 2. 로컬 태그 정보를 원격과 완벽히 일치 (강제 동기화)
git fetch origin +refs/tags/*:refs/tags/* --force --prune --prune-tags
# 3. 목표 버전으로 소스 코드 강제 전환 (예: v0.4.0.0)
# [주의] 이 명령 실행 시 추적되지 않은 소스 수정사항은 사라집니다.
git checkout -f v0.4.0.0
```
---
### 🏗️ [STEP 3] 시스템 빌드 및 서비스 재시작
코드가 반영되었다면 변경된 내용을 적용하기 위해 빌드와 프로세스를 갱신합니다.
#### 3-1. 프론트엔드 빌드
```bash
# [실행 위치: /volume1/web/smartims]
npm install && npm run build
```
#### 3-2. 백엔드 반영 및 PM2 재시작
```bash
# [실행 위치: /volume1/web/smartims/server]
cd server
npm install
pm2 reload smartims-api
```
---
### 🆘 [STEP 4] 비상시 복구 절차 (Emergency Restore)
만약 업데이트 후 시스템이 작동하지 않거나 데이터가 보이지 않을 경우에만 수행합니다.
#### 4-1. 이미지 파일 복원
```bash
# [실행 위치: /volume1/web/smartims]
# 작업 당일 생성한 압축파일 이름을 확인하여 입력하세요.
tar -xzvf /volume1/backup/smart_ims/backup_images_[날짜].tar.gz -C ./
```
#### 4-2. 데이터베이스(DB) 복원
```bash
# [실행 위치: /volume1/web/smartims]
/usr/local/mariadb10/bin/mysql -u choibk -p --port 3307 sokuree_platform_prod < /volume1/backup/smart_ims/backup_db_[날짜].sql
```
---
## 4. 유용한 Git 명령어 레퍼런스
* **태그 목록 확인**: `git tag -l` (모든 환경의 루트 폴더)
* **최신 태그 정보 확인**: `git describe --tags --abbrev=0`
* **원격 태그 강제 덮어쓰기 (메시지 수정 시)**:
```powershell
# [위치: 개발 PC 루트]
git tag -a v0.4.0.0 -f -m "수정된 메시지..."
git push origin v0.4.0.0 -f
```
---
## 💡 최종 주의사항 (Must Read)
1. **DB 계정 확인**: `-u choibk` 계정은 본 시스템 전용 계정입니다. 운영 서버 접속 시 `root` 권한이더라도 DB 작업은 `choibk`로 수행해야 권한 꼬임이 없습니다.
2. **경로 이동 주의**: `cd server` 이동 후 빌드가 끝나면 `cd ..`를 통해 반드시 루트로 돌아오십시오.
3. **수동 설치 주의**: `git clone`을 새로 받아 설치할 때는 기존 `uploads` 폴더가 포함되어 있지 않으므로 반드시 위 **STEP 4-1 복구 절차**를 통해 이미지를 채워넣어야 합니다.
4. **자동 업데이트 정책**: 시스템 관리 메뉴의 **[자동 업데이트]**는 내부적으로 소스 코드만 갱신하며 `uploads` 폴더는 건드리지 않습니다. 따라서 자동 업데이트 시에는 별도의 이미지 복구가 필요 없습니다.

View File

@ -0,0 +1,54 @@
# 📘 SMART IMS 버전 관리 규정 (Versioning Rules)
SMART IMS 플랫폼의 모든 배포와 업데이트는 아래의 **MAJOR.MINOR.PATCH.BUILD** 4자리 버전 관리 체계를 엄격히 준수합니다.
---
## 1⃣ MAJOR (첫 번째 자리)
**대규모 변경, 하위 호환성 붕괴**
- **의미**: 시스템의 근간이 바뀌거나 기존 데이터/연동 체계와의 호환성이 파괴될 때 증가합니다.
- **상황**: API 구조 전면 개편, 데이터베이스 아키텍처 변경, 통신 프로토콜 교체 등.
- **규칙**:
- 절대 자주 올리지 않으며, "제품의 세대 교체" 수준에서만 사용합니다.
- 사용자는 업데이트 전 반드시 호환성 여부를 검토해야 합니다.
- **예시**: `1.x.x.x``2.0.0.0`
## 2⃣ MINOR (두 번째 자리)
**기능 추가, 호환성 유지**
- **의미**: 기존 시스템과 호환성을 유지하면서 새로운 기능이 추가되거나 UI가 대폭 개선될 때 증가합니다.
- **상황**: 신규 모듈 추가, 기존 기능 확장, 설정 옵션 대량 추가 등.
- **규칙**:
- 기능 단위의 주요 배포 시 사용합니다.
- 고객에게 "업데이트 권장" 수준의 변화를 의미합니다.
- **예시**: `1.2.x.x``1.3.0.0`
## 3⃣ PATCH (세 번째 자리)
**버그 수정, 품질 개선**
- **의미**: 새로운 기능 추가 없이 기존 기능의 버그를 수정하거나 성능을 개선할 때 증가합니다.
- **상황**: 보안 패치, 성능 최적화, 로직 오류 수정 등.
- **규칙**:
- 기능 추가는 엄격히 금지됩니다.
- 긴급한 오류 수정 및 정기 유지보수 배포에 주로 사용됩니다.
- **예시**: `1.2.3.x``1.2.4.0`
## 4⃣ BUILD / REVISION (네 번째 자리)
**빌드 번호 또는 미세 변경**
- **의미**: 사용자에게 기능적/시각적 변화를 거의 주지 않는 기술적 수정 시 증가합니다.
- **상황**:
- **빌드 번호**: CI/CD 파이프라인의 빌드 식별자.
- **핫픽스**: 오타 수정, 로그 문구 변경, 주석 업데이트 등.
- **규칙**:
- 사용자에게 "버전 변경 체감"을 주지 않는 수준의 작업입니다.
- 릴리스 노트(Changelog) 작성을 생략할 수 있습니다.
- **예시**: `1.2.3.45` (45번째 빌드)
---
## 💡 버전 비교 및 업데이트 공지 원칙
1. 플랫폼은 원격 저장소의 최신 태그 버전과 현재 설치된 버전의 **4자리를 순차적으로 비교**합니다.
2. 상위 자리수가 더 높을 경우 즉시 시스템 업데이트 안내를 발생시킵니다.
3. 모든 버전은 `v` 접두사를 포함하여 관리하나, 비교 시에는 숫자로 정규화하여 처리합니다.

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "smartims",
"version": "0.1.0",
"version": "0.4.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "smartims",
"version": "0.1.0",
"version": "0.4.0.1",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",

View File

@ -1,7 +1,7 @@
{
"name": "smartims",
"private": true,
"version": "0.3.3",
"version": "0.4.2.7",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -51,6 +51,9 @@ const sessionStoreOptions = {
};
const sessionStore = new MySQLStore(sessionStoreOptions);
sessionStore.on('error', (err) => {
console.error('Session Store Error:', err);
});
// Middleware
app.use(cors({
@ -81,15 +84,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 {
// 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'");
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
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 +117,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 +192,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 +225,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 +245,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 +275,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)
@ -346,9 +441,20 @@ const initTables = async () => {
const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1');
if (existingModules.length === 0) {
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`;
await db.query(insert, ['asset', '자산 관리', true, 'dev']);
await db.query(insert, ['asset', '자산 관리', false, null]);
await db.query(insert, ['production', '생산 관리', false, null]);
await db.query(insert, ['monitoring', 'CCTV', false, null]);
await db.query(insert, ['cctv', 'CCTV', false, null]);
} else {
// One-time update: Rename 'monitoring' code to 'cctv' (migration)
// Use subquery or check if cctv exists to avoid ER_DUP_ENTRY
const [cctvExists] = await db.query("SELECT 1 FROM system_modules WHERE code = 'cctv'");
if (cctvExists.length > 0) {
// If cctv already exists, just remove monitoring if it's there
await db.query("DELETE FROM system_modules WHERE code = 'monitoring'");
} else {
// If cctv doesn't exist, try renaming monitoring
await db.query("UPDATE system_modules SET code = 'cctv' WHERE code = 'monitoring'");
}
}
console.log('✅ Tables Initialized');
@ -361,13 +467,29 @@ initTables();
const packageJson = require('./package.json');
app.get('/api/health', (req, res) => {
// Dynamic version check (Light-weight)
const kstOffset = 9 * 60 * 60 * 1000;
const kstDate = new Date(Date.now() + kstOffset);
let version = packageJson.version;
try {
const { execSync } = require('child_process');
// Check git tag in parent directory (Project root)
version = execSync('git describe --tags --abbrev=0', {
cwd: path.join(__dirname, '..'),
stdio: ['ignore', 'pipe', 'ignore']
}).toString().trim().replace(/^v/, '');
} catch (e) {
// Safe fallback to package.json
}
res.json({
status: 'ok',
version: packageJson.version,
version: version,
node_version: process.version,
platform: process.platform,
arch: process.arch,
timestamp: new Date().toISOString().replace('T', ' ').split('.')[0]
timestamp: kstDate.toISOString().replace('T', ' ').split('.')[0]
});
});

View File

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

View File

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

View File

@ -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'}`;

View File

@ -1,12 +1,12 @@
{
"name": "server",
"version": "0.1.0",
"version": "0.4.0.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "server",
"version": "0.1.0",
"version": "0.4.0.1",
"license": "ISC",
"dependencies": {
"axios": "^1.13.2",

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.3.3",
"version": "0.4.2.7",
"description": "",
"main": "index.js",
"scripts": {

View File

@ -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
// 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'");
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
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' });

View File

@ -153,6 +153,20 @@ router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
let conn;
try {
// 1. Try to connect without specifying database first to see if credentials/host are OK
try {
const basicConn = await mysql.createConnection({
host, user, password, port: parseInt(port) || 3306, connectTimeout: 3000
});
await basicConn.end();
} catch (basicErr) {
return res.status(400).json({
success: false,
error: `서버 접속 실패: 계정 정보나 호스트/포트를 확인하세요. (${basicErr.message})`
});
}
// 2. Try to connect with database
conn = await mysql.createConnection({
host,
user,
@ -164,7 +178,13 @@ router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
await conn.query('SELECT 1');
res.json({ success: true, message: '연결 성공: 데이터베이스에 성공적으로 접속되었습니다.' });
} catch (err) {
res.status(400).json({ success: false, error: err.message });
let msg = err.message;
if (err.code === 'ER_BAD_DB_ERROR') {
msg = `데이터베이스 '${database}'가 존재하지 않습니다. MariaDB에서 스키마를 먼저 생성해 주세요.`;
} else if (err.code === 'ER_ACCESS_DENIED_ERROR') {
msg = '사용자 계정 또는 비밀번호가 일치하지 않거나, 해당 DB에 대한 접근 권한이 없습니다.';
}
res.status(400).json({ success: false, error: msg });
} finally {
if (conn) await conn.end();
}
@ -274,11 +294,12 @@ 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'");
const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null;
// Ensure we return null or empty string if not found, DO NOT use any hardcoded fallback
const serverSubscriberId = (subRows.length > 0 && subRows[0].setting_value) ? subRows[0].setting_value : '';
defaults.forEach(code => {
const found = rows.find(r => r.code === code);
@ -322,7 +343,10 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
}
// 2. Check Module match
if (result.module !== code) {
// Allow legacy 'monitoring' licenses to activate 'cctv' module
const isMatch = result.module === code || (code === 'cctv' && result.module === 'monitoring');
if (!isMatch) {
return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` });
}
@ -342,7 +366,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
}
} else {
if (result.subscriberId !== serverSubscriberId) {
return res.status(403).json({
return res.status(400).json({
error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.`
});
}
@ -375,7 +399,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]);
@ -465,22 +489,36 @@ const getGiteaAuth = async () => {
// 5. Get Version Info (Current, Remote & History from Tags)
router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => {
try {
const packageJsonPath = path.join(__dirname, '../package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const currentVersion = packageJson.version;
// Prepare git fetch command with auth if available
const auth = await getGiteaAuth();
let fetchCmd = 'git fetch --tags --force';
if (auth.user && auth.pass) {
const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
fetchCmd = `git fetch ${authenticatedUrl} --tags --force`;
} else {
fetchCmd = `git fetch ${auth.url} --tags --force`;
// Local version detection (Dynamic & Robust)
const projectRoot = path.join(__dirname, '../..');
let currentVersion = '0.0.0.0';
try {
const { execSync } = require('child_process');
currentVersion = execSync('git describe --tags --abbrev=0', {
cwd: projectRoot,
stdio: ['ignore', 'pipe', 'ignore']
}).toString().trim().replace(/^v/, '');
} catch (e) {
try {
const rootPkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
currentVersion = rootPkg.version;
} catch (err) {
currentVersion = '0.4.2.7';
}
}
exec(fetchCmd, (err, stdout, stderr) => {
// Prepare git fetch command
const auth = await getGiteaAuth();
let fetchCmd = 'git fetch --tags';
if (auth.user && auth.pass && auth.url && auth.url.includes('https://')) {
const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
fetchCmd = `git fetch ${authenticatedUrl} --tags --force --prune`;
} else if (auth.url) {
fetchCmd = `git fetch ${auth.url} --tags --force --prune`;
}
exec(fetchCmd, { cwd: projectRoot }, (err, stdout, stderr) => {
if (err) {
console.error('Git fetch failed:', err);
const sanitizedError = stderr.replace(/:[^@]+@/g, ':****@');
@ -492,30 +530,29 @@ router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res
});
}
// Get last 10 tags with their annotation messages and dates
// Format: tag|subject|body|date
// Also ensure we are looking at the remote tags directly if possible for the 'latest' check
// but for history we still use the fetched local tags
const format = '%(refname:short)|%(contents:subject)|%(contents:body)|%(creatordate:iso8601)';
const historyCmd = `git for-each-ref refs/tags --sort=-creatordate --format="${format}" --count=10`;
const historyCmd = `git for-each-ref refs/tags --sort=-creatordate --format="${format}" --count=50`;
exec(historyCmd, (err, stdout, stderr) => {
exec(historyCmd, { cwd: projectRoot }, (err, stdout, stderr) => {
const lines = stdout ? stdout.trim().split('\n') : [];
const history = lines.map(line => {
const allTags = lines.map(line => {
const [tag, subject, body, date] = line.split('|');
if (!tag) return null;
// Parse Type from subject: e.g. "[FEATURE] Title"
let type = 'patch';
let title = subject || '';
const typeMatch = (subject || '').match(/^\[(URGENT|FEATURE|FIX|PATCH|HOTFIX)\]\s*(.*)$/i);
const typeMatch = (subject || '').match(/^\[(URGENT|FEATURE|FIX|PATCH|HOTFIX|BUILD)\]\s*(.*)$/i);
if (typeMatch) {
const rawType = typeMatch[1].toUpperCase();
if (rawType === 'URGENT' || rawType === 'HOTFIX') type = 'urgent';
else if (rawType === 'FEATURE') type = 'feature';
else if (rawType === 'BUILD') type = 'patch';
else type = 'fix';
title = typeMatch[2];
}
// Parse changes from body (split by newlines and clean up)
const changes = (body || '')
.split('\n')
.map(c => c.trim())
@ -531,13 +568,34 @@ router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res
};
}).filter(Boolean);
const latest = history[0] || null;
// Version Comparison Helper
const parseV = (v) => (v || '').replace(/^v/, '').split('.').map(n => parseInt(n) || 0);
const compare = (v1, v2) => {
const p1 = parseV(v1);
const p2 = parseV(v2);
// Compare up to 4 parts (MAJOR.MINOR.PATCH.BUILD)
for (let i = 0; i < 4; i++) {
const n1 = p1[i] || 0;
const n2 = p2[i] || 0;
if (n1 > n2) return 1;
if (n1 < n2) return -1;
}
return 0;
};
// Filter 1: History is ONLY versions <= current
const history = allTags.filter(t => compare(t.version, currentVersion) <= 0);
const latestFromRemote = allTags[0] || null;
const needsUpdate = latestFromRemote ? (compare(latestFromRemote.version, currentVersion) > 0) : false;
console.log(`[VersionCheck] Current: "${currentVersion}", LatestTag: "${latestFromRemote?.version}", NeedsUpdate: ${needsUpdate}`);
res.json({
current: currentVersion,
latest: latest ? `v${latest.version}` : null,
needsUpdate: latest ? (latest.version !== currentVersion.replace(/^v/, '')) : false,
latestInfo: latest,
latest: latestFromRemote ? `v${latestFromRemote.version}` : null,
needsUpdate: needsUpdate,
latestInfo: needsUpdate ? latestFromRemote : null,
history: history
});
});
@ -557,42 +615,165 @@ router.post('/version/update', isAuthenticated, hasRole('admin'), async (req, re
return res.status(400).json({ error: '업데이트할 대상 태그가 지정되지 않았습니다.' });
}
// This operation is asynchronous. We start it and return a message.
// In a real production, we might want to log this to a terminal-like view.
const auth = await getGiteaAuth();
let authPrefix = '';
if (auth.user && auth.pass) {
const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
authPrefix = `git remote set-url origin ${authenticatedUrl} && `;
} else {
authPrefix = `git remote set-url origin ${auth.url} && `;
}
const updateScript = `${authPrefix} git fetch --tags --force && git checkout -f ${targetTag} && npm install && npm run build && cd server && npm install && pm2 reload smartims-api`;
// Note: On Windows, use cmd.exe /c which supports '&&' better than default PowerShell
const env = readEnv();
const isWindows = process.platform === 'win32';
const shellCommand = isWindows ? `cmd.exe /c "${updateScript}"` : updateScript;
const backupDir = isWindows ? './backup' : '/volume1/backup/smart_ims';
console.log(`🚀 Starting system update to ${targetTag}...`);
console.log(`Executing: ${shellCommand}`);
exec(shellCommand, { cwd: path.join(__dirname, '../..') }, (err, stdout, stderr) => {
if (err) {
console.error('❌ Update Failed:', err);
// Sanitize output for logs
const sanitizedErr = stderr.replace(/:[^@]+@/g, ':****@');
console.error(sanitizedErr);
return;
// Build auth URL for git commands
let remoteUrl = auth.url;
if (auth.user && auth.pass && auth.url.includes('https://')) {
remoteUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
}
console.log('✅ Update completed successfully.');
console.log(stdout);
});
const timestamp = new Date().toISOString().replace(/[:T]/g, '-').split('.')[0];
const dbName = env.DB_NAME || 'sokuree_platform_prod';
const dbUser = env.DB_USER || 'choibk';
const dbPass = env.DB_PASSWORD || '';
const dbPort = env.DB_PORT || '3307';
// 1. Generate Standalone Update Script Content
let scriptContent = '';
const scriptName = isWindows ? 'update_system.bat' : 'update_system.sh';
const scriptPath = path.join(__dirname, '../..', scriptName);
if (isWindows) {
scriptContent = `
@echo off
echo [Update] Starting update to ${targetTag}...
REM Ensure backup directory
set BACKUP_PATH=${backupDir}
if not exist "%BACKUP_PATH%" mkdir "%BACKUP_PATH%" 2>nul
if not exist "%BACKUP_PATH%" (
echo [Warning] Global backup failed, using local backup.
set BACKUP_PATH=.\\server\\backups
if not exist ".\\server\\backups" mkdir ".\\server\\backups"
)
echo [Update] Backing up Config...
if exist "server\\.env" (
copy /Y "server\\.env" "%BACKUP_PATH%\\.env.backup.${timestamp}"
copy /Y "server\\.env" "server\\.env.tmp"
)
echo [Update] Syncing Source Code...
git fetch "${remoteUrl}" --tags --force --prune
git checkout -f ${targetTag}
echo [Update] Restoring Config...
if exist "server\\.env.tmp" (
copy /Y "server\\.env.tmp" "server\\.env"
del "server\\.env.tmp"
) else if exist "%BACKUP_PATH%\\.env.backup.${timestamp}" (
copy /Y "%BACKUP_PATH%\\.env.backup.${timestamp}" "server\\.env"
)
echo [Update] Installing & Building...
call npm install
call npm run build
cd server
call npm install
echo [Update] Done.
`;
} else {
// Linux/Synology Script
const dumpTool = '/usr/local/mariadb10/bin/mysqldump';
scriptContent = `#!/bin/bash
exec > >(tee -a update.log) 2>&1
echo "[Update] Starting update to ${targetTag}..."
# Ensure backup directory
BACKUP_DIR="${backupDir}"
mkdir -p "$BACKUP_DIR" || {
echo "[Warning] Global backup failed, using local backup."
BACKUP_DIR="./server/backups"
mkdir -p "$BACKUP_DIR"
}
echo "[Update] Backing up Database..."
if [ -f "${dumpTool}" ]; then
echo "[Info] MySQL dump tool found. Attempting database backup..."
${dumpTool} -u ${dbUser} --password='${dbPass}' --port ${dbPort} ${dbName} > "$BACKUP_DIR/backup_db_${timestamp}.sql" 2>/dev/null
if [ $? -eq 0 ]; then
echo "[Info] Database backup successful."
else
echo "[Warning] Database backup failed, continuing..."
fi
else
echo "[Warning] MySQL dump tool not found. Skipping DB backup."
fi
echo "[Update] Backing up Config..."
if [ -f "server/.env" ]; then
echo "[Info] Backing up 'server/.env' to '$BACKUP_DIR/.env.backup.${timestamp}' and 'server/.env.tmp'."
cp "server/.env" "$BACKUP_DIR/.env.backup.${timestamp}"
cp "server/.env" "server/.env.tmp"
else
echo "[Warning] 'server/.env' not found. Skipping config backup."
fi
echo "[Update] Syncing Source Code..."
git remote set-url origin "${remoteUrl}"
git fetch origin --tags --force --prune
if [ $? -ne 0 ]; then
echo "[Error] Git fetch failed. Exiting update."
exit 1
fi
echo "[Info] Git fetch successful."
git checkout -f ${targetTag}
if [ $? -ne 0 ]; then
echo "[Error] Git checkout to ${targetTag} failed. Exiting update."
exit 1
fi
echo "[Info] Git checkout to ${targetTag} successful."
echo "[Update] Restoring Config..."
if [ -f "server/.env.tmp" ]; then
cp "server/.env.tmp" "server/.env"
rm "server/.env.tmp"
elif [ -f "$BACKUP_DIR/.env.backup.${timestamp}" ]; then
cp "$BACKUP_DIR/.env.backup.${timestamp}" "server/.env"
fi
echo "[Update] Installing & Building..."
npm install
npm run build
cd server
npm install
echo "[Update] Reloading PM2..."
pm2 reload smartims-api || echo "PM2 not found"
echo "[Update] Done."
`;
}
// 2. Write Script to File
try {
fs.writeFileSync(scriptPath, scriptContent, { encoding: 'utf8', mode: 0o755 });
} catch (err) {
console.error('Failed to create update script:', err);
return res.status(500).json({ error: '업데이트 스크립트 생성 실패' });
}
// 3. Execute Script Detached
console.log(`🚀 [Update] Spawning independent update process: ${scriptPath}`);
const child = require('child_process').spawn(
isWindows ? 'cmd.exe' : 'bash',
isWindows ? ['/c', scriptName] : [scriptName],
{
cwd: path.join(__dirname, '../..'),
detached: true,
stdio: 'ignore'
}
);
child.unref(); // Allow node process to exit/reload without killing this child
res.json({
success: true,
message: '업데이트 프로세스가 백그라운드에서 시작되었습니다. 약 1~3분 후 시스템이 재시작됩니다.'
message: '업데이트 프로세스가 독립적으로 실행되었습니다. 시스템이 곧 재시작됩니다.'
});
});

View File

@ -63,6 +63,13 @@ const cryptoUtil = {
return this._internalProcess(plainKey, true);
},
/**
* 내부 보안으로 암호화된 키를 복호화하는 함수
*/
decryptMasterKey(encryptedKey) {
return this._internalProcess(encryptedKey, false);
},
clearCache() {
cachedKey = null;
},

View File

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

View File

@ -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<Asset[]>([]);
const [accessories, setAccessories] = useState<any[]>([]);
const [showAccModal, setShowAccModal] = useState(false);
const [newAcc, setNewAcc] = useState({ name: '', spec: '', quantity: 1 });
React.useEffect(() => {
loadAccessories();
}, [asset.id]);
const loadAccessories = async () => {
try {
const data = await assetApi.getAccessories(asset.id);
setAccessories(data);
} catch (err) {
console.error("Failed to load accessories", err);
}
};
React.useEffect(() => {
if (isEditing) {
const loadAllAssets = async () => {
try {
const data = await assetApi.getAll();
setAllAssets(data);
} catch (err) {
console.error("Failed to load assets", err);
}
};
loadAllAssets();
}
}, [isEditing]);
const isFacility = asset.category === '설비';
const handleChange = (
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
@ -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 (
<>
<div className="flex items-center mb-4 gap-1" style={{ justifyContent: 'flex-end' }}>
@ -75,7 +130,7 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
) : (
canEdit && <Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}></Button>
)}
<Button variant="secondary" size="sm" icon={<Printer size={16} />}></Button>
<Button variant="secondary" size="sm" icon={<Printer size={16} />} onClick={() => window.print()}></Button>
</div>
<Card className="content-card print-friendly">
@ -334,44 +389,195 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
)}
</td>
</tr>
{isFacility && (
<tr style={{ height: '70px' }}>
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center"> </th>
<td colSpan={5} className="border border-slate-300 p-2">
{isEditing ? (
<div className="relative w-full">
<select
name="parentId"
value={editData.parentId || ''}
onChange={handleChange}
className="block px-3 py-1 bg-transparent border border-slate-300 rounded-none !text-lg !font-medium text-slate-700 outline-none focus:bg-slate-50 transition-colors appearance-none shadow-sm"
style={{ WebkitAppearance: 'none', MozAppearance: 'none', appearance: 'none', backgroundImage: `url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e")`, backgroundPosition: 'right 0.5rem center', backgroundRepeat: 'no-repeat', backgroundSize: '1.5em 1.5em', paddingRight: '2.5rem' }}
>
<option value="">( - )</option>
{allAssets
.filter(a => a.category === '설비' && a.id !== asset.id)
.map(a => (
<option key={a.id} value={a.id}>[{a.id}] {a.name}</option>
))
}
</select>
</div>
) : (
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
{asset.parentId ? (
<a href={`/asset/detail/${asset.parentId}`} className="text-blue-600 hover:underline">
[{asset.parentId}] {asset.parentName || '상위 설비'}
</a>
) : (
<span className="text-slate-400"> ( )</span>
)}
</div>
)}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Sub-Equipment Section (Only for Facilities) */}
{isFacility && (
<div className="sub-equipment-section mt-8">
<div className="flex justify-between items-center mb-2">
<h3 className="section-title text-lg font-bold"> </h3>
{canEdit && (
<Button
size="sm"
variant="secondary"
icon={<Plus size={14} />}
onClick={() => {
// Navigate to register with pre-filled parent
// Note: Assuming navigation or handling via state
window.location.href = `/asset/register?parentId=${asset.id}&categoryId=${allAssets.find(a => a.category === '설비')?.id || ''}`;
}}
>
</Button>
)}
</div>
<table className="doc-table w-full text-center border-collapse border border-slate-300">
<thead>
<tr>
<th className="border border-slate-300 p-2 bg-slate-50"></th>
<th className="border border-slate-300 p-2 bg-slate-50"></th>
<th className="border border-slate-300 p-2 bg-slate-50"></th>
<th className="border border-slate-300 p-2 bg-slate-50"></th>
<th className="border border-slate-300 p-2 bg-slate-50"></th>
</tr>
</thead>
<tbody>
{asset.children && asset.children.length > 0 ? (
asset.children.map(child => (
<tr key={child.id}>
<td className="border border-slate-300 p-2 font-mono">{child.id}</td>
<td className="border border-slate-300 p-2 font-medium">{child.name}</td>
<td className="border border-slate-300 p-2">{child.location}</td>
<td className="border border-slate-300 p-2">
<span className={`badge ${child.status === 'active' ? 'badge-success' : 'badge-warning'}`}>
{child.status === 'active' ? '정상' : '점검/이동'}
</span>
</td>
<td className="border border-slate-300 p-2">
<a href={`/asset/detail/${child.id}`} className="text-blue-600 underline"></a>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="p-8 text-slate-400"> .</td>
</tr>
)}
</tbody>
</table>
</div>
)}
<div className="section-divider my-6 border-b border-slate-200"></div>
{/* Consumables Section */}
<div className="consumables-section">
{/* Accessories Section (For non-facility or all?) - User said skip hierarchical for others, use simple accessories */}
{!isFacility && (
<div className="accessories-section">
<div className="flex justify-between items-center mb-2">
<h3 className="section-title text-lg font-bold"> </h3>
{canEdit && <Button size="sm" variant="secondary" icon={<Plus size={14} />}> </Button>}
<h3 className="section-title text-lg font-bold"> </h3>
{canEdit && (
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => setShowAccModal(true)}>
</Button>
)}
</div>
<table className="doc-table w-full text-center border-collapse border border-slate-300">
<thead>
<tr>
<th className="border border-slate-300 p-2 bg-slate-50"></th>
<th className="border border-slate-300 p-2 bg-slate-50"></th>
<th className="border border-slate-300 p-2 bg-slate-50"></th>
<th className="border border-slate-300 p-2 bg-slate-50"></th>
<th className="border border-slate-300 p-2 bg-slate-50"></th>
</tr>
</thead>
<tbody>
{asset.consumables?.map(item => (
{accessories.length > 0 ? (
accessories.map(item => (
<tr key={item.id}>
<td className="border border-slate-300 p-2">{item.name}</td>
<td className="border border-slate-300 p-2">{item.spec}</td>
<td className="border border-slate-300 p-2">{item.qty}</td>
<td className="border border-slate-300 p-2">{item.spec || '-'}</td>
<td className="border border-slate-300 p-2">{item.quantity}</td>
<td className="border border-slate-300 p-2">
<button className="text-slate-400 hover:text-red-500 text-sm underline"></button>
<button
onClick={() => handleDeleteAccessory(item.id)}
className="text-slate-400 hover:text-red-500 text-sm underline"
>
</button>
</td>
</tr>
))}
))
) : (
<tr>
<td colSpan={4} className="p-8 text-slate-400"> .</td>
</tr>
)}
</tbody>
</table>
</div>
)}
</Card>
{/* Accessory Add Modal */}
{showAccModal && createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="bg-white rounded-lg shadow-xl w-[400px] p-6">
<h3 className="text-xl font-bold mb-4"> </h3>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> *</label>
<input
className="w-full border p-2 rounded"
value={newAcc.name}
onChange={e => setNewAcc({ ...newAcc, name: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> / </label>
<input
className="w-full border p-2 rounded"
value={newAcc.spec}
onChange={e => setNewAcc({ ...newAcc, spec: e.target.value })}
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label>
<input
type="number"
className="w-full border p-2 rounded"
value={newAcc.quantity}
onChange={e => setNewAcc({ ...newAcc, quantity: Number(e.target.value) })}
/>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<Button variant="secondary" onClick={() => setShowAccModal(false)}></Button>
<Button onClick={handleAddAccessory}></Button>
</div>
</div>
</div>,
document.body
)}
{/* Image Zoom Modal - Moved to Portal */}
{isZoomed && editData.image && createPortal(
<div

View File

@ -3,7 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { Card } from '../../../shared/ui/Card';
import { Button } from '../../../shared/ui/Button';
import { Input } from '../../../shared/ui/Input';
import { Search, Plus, Filter, Download, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight } from 'lucide-react';
import { Search, Plus, Filter, Download, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight, Printer } from 'lucide-react';
import { useAuth } from '../../../shared/auth/AuthContext';
import './AssetListPage.css';
@ -280,6 +280,7 @@ export function AssetListPage() {
>
</Button>
<Button variant="secondary" icon={<Printer size={16} />} onClick={() => window.print()}></Button>
<Button variant="secondary" icon={<Download size={16} />} onClick={handleExcelDownload}> </Button>
{canRegister && (
<Button onClick={() => navigate('/asset/register')} icon={<Plus size={16} />}> </Button>

View File

@ -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<Asset[]>([]);
useEffect(() => {
const loadAllAssets = async () => {
try {
const data = await assetApi.getAll();
setAllAssets(data);
} catch (err) {
console.error("Failed to load assets for parent selection", err);
}
};
loadAllAssets();
}, []);
// Auto-generate Asset ID
useEffect(() => {
// If category is required by rule but not selected, can't generate fully
@ -106,6 +121,7 @@ export function AssetRegisterPage() {
const payload: Partial<Asset> = {
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 (
<div className="page-container">
<div className="page-header-right">
@ -155,6 +173,22 @@ export function AssetRegisterPage() {
required
/>
{isFacility && (
<Select
label="상위 설비 (메인 설비)"
name="parentId"
value={formData.parentId}
onChange={handleChange}
options={[
{ label: '(없음 - 메인 설비로 등록)', value: '' },
...allAssets
.filter(a => a.category === '설비' && a.id !== formData.id)
.map(a => ({ label: `[${a.id}] ${a.name}`, value: a.id }))
]}
placeholder="메인 설비가 있는 경우 선택"
/>
)}
<Input
label="자산 관리 번호 (자동 생성)"
name="id"

View File

@ -1,11 +1,16 @@
import type { IModuleDefinition } from '../../core/types';
import { MonitoringPage } from './pages/MonitoringPage';
import { CameraManagementPage } from './pages/CameraManagementPage';
import { CctvSettingsPage } from './pages/CctvSettingsPage';
export const cctvModule: IModuleDefinition = {
moduleName: 'monitoring-cctv',
moduleName: 'cctv',
label: 'CCTV',
basePath: '/monitoring',
routes: [
{ path: '/live', element: <MonitoringPage />, label: '실시간 관제' },
{ path: '/manage', element: <CameraManagementPage />, label: '장치 관리' },
{ path: '/settings', element: <CctvSettingsPage />, label: '기본 설정' },
],
requiredRoles: ['admin', 'operator', 'user']
};

View File

@ -0,0 +1,392 @@
import { useState, useEffect, useRef } from 'react';
import { Card } from '../../../shared/ui/Card';
import { Button } from '../../../shared/ui/Button';
import { Input } from '../../../shared/ui/Input';
import { apiClient } from '../../../shared/api/client';
import {
Plus, Edit2, Trash2, X, Check, Search,
Settings, Video, RefreshCcw, Power,
PowerOff, LayoutGrid
} from 'lucide-react';
import { useAuth } from '../../../shared/auth/AuthContext';
import { useNavigate } from 'react-router-dom';
interface Camera {
id: number;
name: string;
ip_address: string;
port: number;
username?: string;
password?: string;
stream_path?: string;
transport_mode?: 'tcp' | 'udp' | 'auto';
rtsp_encoding?: boolean;
quality?: 'low' | 'medium' | 'original';
is_active?: boolean | number;
display_order?: number;
zone?: string;
}
export function CameraManagementPage() {
const { user } = useAuth();
const navigate = useNavigate();
const [cameras, setCameras] = useState<Camera[]>([]);
const [loading, setLoading] = useState(false);
const [searchTerm, setSearchTerm] = useState('');
const [availableZones, setAvailableZones] = useState<string[]>([]);
const fetchedRef = useRef(false);
// Modal State
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState<Partial<Camera>>({
name: '',
ip_address: '',
port: 554,
username: '',
password: '',
stream_path: '/stream1',
transport_mode: 'tcp',
rtsp_encoding: false,
quality: 'low',
zone: '기본 구역'
});
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
fetchCameras();
fetchZones();
}, []);
const fetchZones = async () => {
try {
const res = await apiClient.get('/cameras/zones');
setAvailableZones(res.data.map((z: any) => z.name));
} catch (err) {
console.error('Failed to fetch zones', err);
}
};
const fetchCameras = async () => {
setLoading(true);
try {
const res = await apiClient.get('/cameras');
setCameras(res.data);
} catch (error) {
console.error('Failed to fetch cameras', error);
} finally {
setLoading(false);
}
};
const handleOpenAdd = () => {
setFormData({
name: '',
ip_address: '',
port: 554,
username: '',
password: '',
stream_path: '/stream1',
transport_mode: 'tcp',
rtsp_encoding: false,
quality: 'low',
zone: '기본 구역'
});
setIsEditing(false);
setIsModalOpen(true);
};
const handleOpenEdit = (camera: Camera) => {
setFormData(camera);
setIsEditing(true);
setIsModalOpen(true);
};
const handleDelete = async (id: number) => {
if (!confirm('정말 이 카메라를 삭제하시겠습니까?')) return;
try {
await apiClient.delete(`/cameras/${id}`);
fetchCameras();
} catch (error) {
console.error('Failed to delete camera', error);
alert('삭제 실패');
}
};
const handleToggleStatus = async (camera: Camera) => {
const newStatus = !(camera.is_active !== 0 && camera.is_active !== false);
try {
await apiClient.patch(`/cameras/${camera.id}/status`, { is_active: newStatus });
fetchCameras();
} catch (err) {
console.error('Failed to toggle status', err);
alert('상태 변경 실패');
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (isEditing) {
await apiClient.put(`/cameras/${formData.id}`, formData);
alert('수정되었습니다.');
} else {
await apiClient.post('/cameras', formData);
alert('등록되었습니다.');
}
setIsModalOpen(false);
fetchCameras();
} catch (error: any) {
alert(`오류: ${error.response?.data?.error || error.message}`);
}
};
const filteredCameras = cameras.filter(cam =>
cam.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
cam.ip_address.includes(searchTerm)
);
return (
<div className="page-container p-6 max-w-7xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Video className="text-indigo-600" />
CCTV
</h1>
<p className="text-slate-500 mt-1"> CCTV .</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => navigate('/monitoring/live')} icon={<LayoutGrid size={16} />}> </Button>
{(user?.role === 'admin' || user?.role === 'supervisor') && (
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}> </Button>
)}
</div>
</div>
<Card className="mb-6 p-4 border-slate-200">
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<Input
className="pl-10"
placeholder="카메라 이름 또는 IP 주소로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<Button variant="secondary" onClick={fetchCameras} icon={<RefreshCcw size={16} className={loading ? 'animate-spin' : ''} />}>
</Button>
</div>
</Card>
<Card className="overflow-hidden shadow-sm border-slate-200">
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="text-xs text-slate-500 uppercase bg-slate-50/80 border-b border-slate-200">
<tr>
<th className="px-6 py-4"></th>
<th className="px-6 py-4"> </th>
<th className="px-6 py-4"></th>
<th className="px-6 py-4"> (IP:Port)</th>
<th className="px-6 py-4 text-center"></th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 bg-white">
{loading ? (
<tr>
<td colSpan={5} className="px-6 py-12 text-center text-slate-400"> ...</td>
</tr>
) : filteredCameras.map((camera) => {
const isActive = camera.is_active !== 0 && camera.is_active !== false;
return (
<tr key={camera.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-4">
<button
onClick={() => handleToggleStatus(camera)}
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-bold ring-1 transition-all ${isActive
? 'bg-green-50 text-green-700 ring-green-200 hover:bg-green-100'
: 'bg-slate-100 text-slate-500 ring-slate-200 hover:bg-slate-200'
}`}
>
{isActive ? <Power size={12} /> : <PowerOff size={12} />}
{isActive ? '운영 중' : '중지됨'}
</button>
</td>
<td className="px-6 py-4">
<div className="font-bold text-slate-900">{camera.name}</div>
<div className="text-[11px] text-slate-500 mt-0.5">ID: {camera.id}</div>
</td>
<td className="px-6 py-4">
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded-md text-[11px] font-medium border border-slate-200">
{camera.zone || '기본 구역'}
</span>
</td>
<td className="px-6 py-4 font-mono text-slate-700">
{camera.ip_address}:{camera.port}
</td>
<td className="px-6 py-4">
<div className="flex justify-center gap-1">
{(user?.role === 'admin' || user?.role === 'supervisor') && (
<>
<button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(camera)}>
<Edit2 size={16} />
</button>
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(camera.id)}>
<Trash2 size={16} />
</button>
</>
)}
</div>
</td>
</tr>
);
})}
{!loading && filteredCameras.length === 0 && (
<tr className="no-result-row">
<td colSpan={5} className="px-6 py-12 text-center text-slate-400"> .</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<Card className="w-full max-w-lg shadow-2xl border-slate-200 overflow-hidden">
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Settings size={20} className="text-indigo-600" />
{isEditing ? '장치 설정 수정' : '새 장치 등록'}
</h2>
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
<div className="grid grid-cols-2 gap-4">
<div className="col-span-2">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> <span className="text-red-500">*</span></label>
<Input
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="예: 정문 CCTV"
required
/>
</div>
<div className="col-span-2">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> <span className="text-red-500">*</span></label>
<select
className="w-full bg-white border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all appearance-none"
value={formData.zone}
onChange={(e) => setFormData({ ...formData, zone: e.target.value })}
required
>
<option value="" disabled> </option>
{availableZones.map(zone => (
<option key={zone} value={zone}>{zone}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">IP <span className="text-red-500">*</span></label>
<Input
value={formData.ip_address}
onChange={(e) => setFormData({ ...formData, ip_address: e.target.value })}
placeholder="192.168.1.100"
required
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> (RTSP)</label>
<Input
type="number"
value={formData.port}
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 554 })}
placeholder="554"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4 pt-2 border-t border-slate-50">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">RTSP </label>
<Input
value={formData.username}
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
placeholder="admin"
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">RTSP </label>
<Input
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder="••••••••"
/>
</div>
</div>
<div className="space-y-4 pt-4 border-t border-slate-50">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> </label>
<Input
value={formData.stream_path}
onChange={(e) => setFormData({ ...formData, stream_path: e.target.value })}
placeholder="/stream1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> </label>
<select
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-indigo-500/20"
value={formData.transport_mode}
onChange={(e) => setFormData({ ...formData, transport_mode: e.target.value as any })}
>
<option value="tcp">TCP ()</option>
<option value="udp">UDP</option>
<option value="auto">Auto</option>
</select>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> </label>
<select
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-indigo-500/20"
value={formData.quality}
onChange={(e) => setFormData({ ...formData, quality: e.target.value as any })}
>
<option value="low">Low (640p)</option>
<option value="medium">Medium (HD)</option>
<option value="original">Original</option>
</select>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="rtsp_enc"
checked={formData.rtsp_encoding}
onChange={(e) => setFormData({ ...formData, rtsp_encoding: e.target.checked })}
className="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"
/>
<label htmlFor="rtsp_enc" className="text-xs font-medium text-slate-600 cursor-pointer"> ( @ )</label>
</div>
</div>
<div className="pt-4 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}></Button>
<Button type="submit" className="bg-indigo-600" icon={<Check size={16} />}>{isEditing ? '변경사항 저장' : '등록 완료'}</Button>
</div>
</form>
</Card>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,147 @@
import { useState, useEffect } from 'react';
import { Card } from '../../../shared/ui/Card';
import { Button } from '../../../shared/ui/Button';
import { Input } from '../../../shared/ui/Input';
import { apiClient } from '../../../shared/api/client';
import { Plus, Trash2, Save, GripVertical, Settings } from 'lucide-react';
interface ZoneConfig {
name: string;
layout: '1' | '1*2' | '2*2';
}
export function CctvSettingsPage() {
const [zones, setZones] = useState<ZoneConfig[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
fetchZones();
}, []);
const fetchZones = async () => {
try {
const res = await apiClient.get('/cameras/zones');
setZones(res.data.map((z: any) => ({
name: z.name,
layout: z.layout || '2*2'
})));
} catch (err) {
console.error('Failed to fetch zones', err);
}
};
const handleAddZone = () => {
setZones([...zones, { name: '', layout: '2*2' }]);
};
const handleRemoveZone = (index: number) => {
const newZones = [...zones];
newZones.splice(index, 1);
setZones(newZones);
};
const handleZoneNameChange = (index: number, value: string) => {
const newZones = [...zones];
newZones[index].name = value;
setZones(newZones);
};
const handleZoneLayoutChange = (index: number, value: '1' | '1*2' | '2*2') => {
const newZones = [...zones];
newZones[index].layout = value;
setZones(newZones);
};
const handleSave = async () => {
if (loading) return;
setLoading(true);
try {
const filteredZones = zones.filter(z => z.name.trim() !== '');
if (filteredZones.length === 0) {
alert('최소 하나 이상의 구역이 필요합니다.');
setLoading(false);
return;
}
await apiClient.put('/cameras/zones', { zones: filteredZones });
alert('구역 및 레이아웃 설정이 저장되었습니다.');
fetchZones();
} catch (err) {
console.error(err);
alert('저장 실패');
} finally {
setLoading(false);
}
};
return (
<div className="page-container p-6 max-w-5xl mx-auto">
<div className="mb-6 text-indigo-600">
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
<Settings className="text-indigo-600" />
CCTV
</h1>
<p className="text-slate-500 mt-1">CCTV .</p>
</div>
<Card className="p-6 border-slate-200">
<h2 className="text-lg font-bold text-slate-800 mb-2 font-noto"> </h2>
<p className="text-sm text-slate-500 mb-6 font-medium">
() .
</p>
<div className="space-y-3 mb-8">
<div className="grid grid-cols-[40px_1fr_200px_40px] gap-4 px-2 mb-2 text-xs font-bold text-slate-400 uppercase tracking-wider">
<div className="text-center">#</div>
<div> </div>
<div> (*)</div>
<div></div>
</div>
{zones.map((zone, index) => (
<div key={index} className="grid grid-cols-[40px_1fr_200px_40px] gap-4 items-center animate-in slide-in-from-left-2 duration-200" style={{ animationDelay: `${index * 50}ms` }}>
<div className="p-2 text-slate-300 flex justify-center">
<GripVertical size={20} />
</div>
<Input
value={zone.name}
onChange={(e) => handleZoneNameChange(index, e.target.value)}
placeholder="예: 정문, A구역 등"
className="flex-1"
/>
<select
value={zone.layout}
onChange={(e) => handleZoneLayoutChange(index, e.target.value as any)}
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 transition-all font-medium"
>
<option value="1">1 (1x1)</option>
<option value="1*2">1*2 (1x2)</option>
<option value="2*2">2*2 (2x2)</option>
</select>
<button
onClick={() => handleRemoveZone(index)}
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all"
title="삭제"
>
<Trash2 size={20} />
</button>
</div>
))}
{zones.length === 0 && (
<div className="text-center py-12 text-slate-400 border-2 border-dashed border-slate-100 rounded-xl bg-slate-50/50">
. .
</div>
)}
</div>
<div className="flex justify-between items-center pt-6 border-t border-slate-100">
<Button variant="secondary" onClick={handleAddZone} icon={<Plus size={18} />}>
</Button>
<Button onClick={handleSave} disabled={loading} icon={<Save size={18} />}>
{loading ? '저장 중...' : '설정 저장'}
</Button>
</div>
</Card>
</div>
);
}

View File

@ -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 (
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="bg-white rounded-xl shadow-lg overflow-hidden border border-slate-200">
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className={`relative bg-black overflow-hidden border ${disabled ? 'border-slate-800' : 'border-slate-700 hover:border-indigo-500'} transition-colors group h-full w-full`}>
{children}
</div>
);
@ -47,22 +49,12 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr
export function MonitoringPage() {
const { user } = useAuth();
const [cameras, setCameras] = useState<Camera[]>([]);
const [showForm, setShowForm] = useState(false);
const [editingCamera, setEditingCamera] = useState<Camera | null>(null);
const [formData, setFormData] = useState<Partial<Camera>>({
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<string>('');
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<HTMLInputElement>) => {
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 (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Video className="text-blue-600" />
CCTV <span className="text-xs text-slate-400 font-normal">({user?.role})</span>
</h1>
{(user?.role === 'admin' || user?.role === 'supervisor') && (
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: <div className="w-4 h-4 border border-white/40" /> },
{ id: '1*2', label: '1*2', icon: <div className="w-4 h-4 border border-white/40 flex"><div className="w-1/2 border-r border-white/40" /></div> },
{ id: '2*2', label: '2*2', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-2 grid-rows-2"><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /><div className="border-r border-white/40" /></div> },
{ id: '3*3', label: '3*3', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-3 grid-rows-3"><div className="border-r border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /></div> },
{ id: '4*4', label: '4*4', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-4 grid-rows-4" /> },
{ id: '5*5', label: '5*5', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-5 grid-rows-5" /> },
{ id: '6*6', label: '6*6', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-6 grid-rows-6" /> }
];
return createPortal(
<div className="flex items-center w-full h-full pointer-events-none">
{/* Left Area: Zone Tabs */}
<div className="flex gap-1 h-full items-end pb-1 overflow-x-auto scrollbar-hide pointer-events-auto border-l border-slate-200 ml-4 pl-4">
{zoneTabs.map(name => (
<button
onClick={() => {
setEditingCamera(null);
setFormData({
name: '',
ip_address: '',
port: 554,
username: '',
password: '',
stream_path: '/stream1'
});
setShowForm(true);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 transition"
key={name}
onClick={() => setActiveZone(name)}
className={`px-4 py-2 text-[13px] font-bold transition-all border-b-2 whitespace-nowrap ${activeZone === name
? 'text-indigo-600 border-indigo-600 bg-indigo-50/20'
: 'text-slate-400 border-transparent hover:text-slate-600'
}`}
>
<Plus size={18} />
{name}
</button>
)}
))}
</div>
{/* Camera Grid with DnD */}
{/* Spacer to push everything else to the right */}
<div className="flex-1" />
{/* Right Area: Layout Selector */}
<div className="px-3 pointer-events-auto flex items-center">
<div className="relative">
<button
onClick={() => setShowLayoutMenu(!showLayoutMenu)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border ${showLayoutMenu ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-slate-50 text-slate-600 border-slate-200 hover:border-indigo-400'} ${!activeZone ? 'opacity-50 grayscale cursor-not-allowed' : ''}`}
disabled={!activeZone}
>
<LayoutGrid size={14} />
: {!activeZone ? '--' : viewLayout}
<ChevronDown size={14} className={`transition-transform duration-200 ${showLayoutMenu ? 'rotate-180' : ''}`} />
</button>
{showLayoutMenu && activeZone && (
<div className="absolute right-0 top-full mt-2 w-48 bg-[#1a1a1a] rounded-xl shadow-2xl border border-white/10 p-2 z-[100] animate-in fade-in zoom-in duration-200">
<div className="px-2 py-1.5 text-[9px] font-bold text-white/30 uppercase tracking-[0.2em] border-b border-white/5 mb-1">
Monitor Arrangement
</div>
<div className="grid grid-cols-2 gap-1">
{layoutOptions.map(opt => (
<button
key={opt.id}
onClick={() => handleLayoutChange(opt.id)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-[11px] font-medium transition-all ${viewLayout === opt.id ? 'bg-indigo-600 text-white' : 'text-white/60 hover:bg-white/5 hover:text-white'}`}
>
<div className="w-4 flex justify-center opacity-60">{opt.icon}</div>
{opt.label}
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>,
portalRoot
);
};
const getLayoutInfo = () => {
if (viewLayout === '1') return { cols: 'grid-cols-1', total: 1 };
if (viewLayout === '1*2') return { cols: 'grid-cols-2', total: 2 };
// Multi-grid like 2*2, 3*3, 4*4
const parts = viewLayout.split('*');
if (parts.length === 2) {
const n = parseInt(parts[0]);
return { cols: `grid-cols-${n}`, total: n * n };
}
return { cols: 'grid-cols-2', total: 4 };
};
const layoutInfo = getLayoutInfo();
const totalSlots = Math.max(layoutInfo.total, filteredCameras.length);
// Fill slots: actual cameras + empty placeholders if needed to complete the grid rows
const slots = Array.from({ length: totalSlots }).map((_, i) => filteredCameras[i] || null);
return (
<div className="w-full h-full bg-[#0a0a0a] flex flex-col min-h-0">
<HeaderDropdown />
<div className="flex-1 p-2 overflow-hidden flex flex-col min-h-0">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={cameras.map(c => c.id)}
items={filteredCameras.map(c => c.id)}
strategy={rectSortingStrategy}
disabled={true}
>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
{cameras.map(camera => (
<SortableCamera key={camera.id} camera={camera} disabled={user?.role !== 'admin' && user?.role !== 'supervisor'}>
<div className="relative aspect-video bg-black group">
<div className={`grid h-full w-full gap-1 ${layoutInfo.cols} ${totalSlots > layoutInfo.total ? 'overflow-y-auto' : ''}`}
style={{ gridAutoRows: (viewLayout === '1' || viewLayout === '1*2') ? '100%' : `${100 / Math.sqrt(layoutInfo.total)}%` }}>
{slots.map((camera, index) => (
<div key={camera ? camera.id : `empty-${index}`} className="h-full w-full">
{camera ? (
<SortableCamera camera={camera} disabled={true}>
<div className="relative w-full h-full flex flex-col group text-white/90">
{/* VMS Slot Header */}
<div className="absolute top-0 left-0 right-0 z-10 bg-black/40 backdrop-blur-sm border-b border-white/5 px-3 py-1.5 flex justify-between items-center opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></div>
<span className="text-[11px] font-bold truncate max-w-[150px]">{camera.name}</span>
</div>
{(user?.role === 'admin' || user?.role === 'supervisor') && (
<div className="flex gap-1" onPointerDown={(e) => e.stopPropagation()}>
<button
onClick={() => handlePing(camera.id)}
className="p-1 text-white/50 hover:text-green-400 transition-colors"
title="Ping"
>
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3L15.5 7.5z" /></svg>
</button>
<button
onClick={() => handleToggleStatus(camera)}
className={`p-1 transition-colors ${(camera.is_active !== 0 && camera.is_active !== false) ? 'text-white/50 hover:text-orange-400' : 'text-white/50 hover:text-green-400'}`}
title={(camera.is_active !== 0 && camera.is_active !== false) ? "Pause" : "Play"}
>
{(camera.is_active !== 0 && camera.is_active !== false) ? (
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
)}
</button>
</div>
)}
</div>
{/* Streaming Video */}
<div className="flex-1 bg-black flex items-center justify-center">
<JSMpegPlayer
key={`${camera.id}-${streamVersions[camera.id] || 0}`}
url={getStreamUrl(camera.id)}
className="w-full h-full"
className="w-full h-full object-contain"
/>
{/* Overlay Controls */}
{(user?.role === 'admin' || user?.role === 'supervisor') && (
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2" onPointerDown={(e) => e.stopPropagation()}>
<button
onClick={() => handleToggleStatus(camera)}
className={`${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-orange-500/80 hover:bg-orange-600' : 'bg-green-600/80 hover:bg-green-700'} text-white p-2 rounded-full`}
title={(camera.is_active !== 0 && camera.is_active !== false) ? "스트리밍 중지 (Pause)" : "스트리밍 시작 (Play)"}
>
{(camera.is_active !== 0 && camera.is_active !== false) ? (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
) : (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
)}
</button>
<button
onClick={() => handlePing(camera.id)}
className="bg-green-600/80 text-white p-2 rounded-full hover:bg-green-700"
title="연결 테스트 (Ping)"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3L15.5 7.5z" /></svg>
</button>
<button
onClick={() => handleEdit(camera)}
className="bg-black/50 text-white p-2 rounded-full hover:bg-black/70"
title="설정"
>
<Settings size={16} />
</button>
<button
onClick={() => handleDelete(camera.id)}
className="bg-red-500/80 text-white p-2 rounded-full hover:bg-red-600"
title="삭제"
>
<Trash2 size={16} />
</button>
</div>
)}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<h3 className="text-white font-medium flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></span>
{camera.name} {(camera.is_active === 0 || camera.is_active === false) && <span className="text-xs text-slate-400">()</span>}
</h3>
{/* IP/Port Hidden as requested */}
{/* Small persistent ID or Name overlay */}
<div className="absolute bottom-2 left-2 px-2 py-0.5 bg-black/60 rounded text-[10px] text-white/30 font-mono pointer-events-none border border-white/5">
CH {index + 1} | {camera.ip_address}
</div>
</div>
</SortableCamera>
) : (
<div className="h-full w-full bg-[#121212] border border-slate-900/50 flex flex-col items-center justify-center text-slate-700 select-none">
<Video size={32} className="opacity-10 mb-2" />
<span className="text-[11px] font-bold opacity-20 uppercase tracking-widest">No Buffer</span>
</div>
)}
</div>
))}
</div>
</SortableContext>
</DndContext>
{cameras.length === 0 && (
<div className="text-center py-20 text-slate-500">
<Video size={48} className="mx-auto mb-4 opacity-50" />
<p> . .</p>
</div>
)}
{/* Add/Edit Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6 animate-in fade-in zoom-in duration-200">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold">
{editingCamera ? '카메라 설정 수정' : '새 카메라 추가'}
</h2>
<button onClick={() => setShowForm(false)} className="text-slate-400 hover:text-slate-600">
<X size={24} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> </label>
<input type="text" name="name" value={formData.name} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="예: 정문 CCTV" />
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-slate-700 mb-1">IP </label><input type="text" name="ip_address" value={formData.ip_address} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="192.168.1.100" /></div>
<div><label className="block text-sm font-medium text-slate-700 mb-1"></label><input type="number" name="port" value={formData.port} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="554" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP </label><input type="text" name="username" value={formData.username} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="admin" /></div>
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP </label><input type="password" name="password" value={formData.password} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" /></div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> </label>
<input type="text" name="stream_path" value={formData.stream_path} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="/stream1" />
</div>
{/* Advanced Settings */}
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
<h3 className="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
<Settings size={14} />
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1"> (Transport)</label>
<select name="transport_mode" value={formData.transport_mode || 'tcp'} onChange={(e) => setFormData(prev => ({ ...prev, transport_mode: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
<option value="tcp">TCP ( - )</option>
<option value="udp">UDP ( - )</option>
<option value="auto">Auto</option>
</select>
</div>
<div className="flex items-center">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="rtsp_encoding" checked={!!formData.rtsp_encoding} onChange={(e) => setFormData(prev => ({ ...prev, rtsp_encoding: e.target.checked }))} className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500" />
<span className="text-sm text-slate-600"> </span>
</label>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-slate-600 mb-1"> (Quality)</label>
<select name="quality" value={formData.quality || 'low'} onChange={(e) => setFormData(prev => ({ ...prev, quality: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
<option value="low">Low (640px) - ()</option>
<option value="medium">Medium (1280px) - HD </option>
<option value="original">Original - ( )</option>
</select>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition"></button>
<button type="submit" disabled={loading} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50">{loading ? '저장 중...' : '저장하기'}</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -61,6 +61,8 @@ export function LoginPage() {
placeholder="아이디를 입력하세요"
required
autoComplete="one-time-code"
readOnly
onFocus={(e) => e.target.readOnly = false}
/>
</div>
</div>
@ -77,6 +79,8 @@ export function LoginPage() {
placeholder="비밀번호를 입력하세요"
required
autoComplete="new-password"
readOnly
onFocus={(e) => e.target.readOnly = false}
/>
</div>
</div>

View File

@ -1,5 +1,5 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from '../shared/auth/AuthContext';
import { AuthProvider, useAuth } from '../shared/auth/AuthContext';
import { SystemProvider } from '../shared/context/SystemContext';
import { AuthGuard } from '../shared/auth/AuthGuard';
import { MainLayout } from '../widgets/layout/MainLayout';
@ -16,11 +16,19 @@ import { UserManagementPage } from './pages/UserManagementPage';
import { BasicSettingsPage } from './pages/BasicSettingsPage';
import { VersionPage } from './pages/VersionPage';
import { LicensePage } from '../system/pages/LicensePage';
import { LandingPage } from './pages/LandingPage';
import { GeneralPreferencesPage } from './pages/GeneralPreferencesPage';
import './styles/global.css';
const modules = [assetModule, cctvModule, productionModule];
const DefaultRedirect = () => {
const { user } = useAuth();
const preferredPath = localStorage.getItem(`landing_page_${user?.id}`) || '/home';
return <Navigate to={preferredPath} replace />;
};
export const App = () => {
return (
<AuthProvider>
@ -36,11 +44,17 @@ export const App = () => {
<MainLayout modulesList={modules} />
</AuthGuard>
}>
{/* Home / Platform Introduction */}
<Route path="/home" element={<LandingPage />} />
{/* User Preferences (Accessible by everyone authenticated) */}
<Route path="/preferences" element={<GeneralPreferencesPage />} />
{/* Dynamic Module Routes */}
<Route path="/*" element={<ModuleLoader modules={modules} />} />
{/* Navigation Fallback within Layout */}
<Route index element={<Navigate to="/asset/dashboard" replace />} />
<Route index element={<DefaultRedirect />} />
{/* Platform Admin Routes (Could also be moved to an Admin module later) */}
<Route path="/admin/users" element={<UserManagementPage />} />

View File

@ -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();
}, []);
@ -148,7 +151,11 @@ export function BasicSettingsPage() {
setTestResult({ success: true, message: res.data.message });
setIsDbVerified(true);
} catch (error: any) {
setTestResult({ success: false, message: error.response?.data?.error || '접속 테스트에 실패했습니다.' });
const detailedError = error.response?.data?.error || error.message || '알 수 없는 오류가 발생했습니다.';
setTestResult({
success: false,
message: `접속 실패: ${detailedError}`
});
setIsDbVerified(false);
} finally {
setTesting(false);
@ -236,56 +243,15 @@ export function BasicSettingsPage() {
</div>
<div className="space-y-8">
{/* Section 1: Security & Session */}
<Card className="overflow-hidden border-slate-200 shadow-sm">
<div className="p-6 border-b border-slate-50 bg-slate-50/50">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<Clock size={20} className="text-blue-600" />
</h2>
</div>
<div className="p-6">
<label className="block text-sm font-medium text-slate-700 mb-3"> </label>
<div className="flex items-center gap-3 text-sm text-slate-600">
<div className="flex items-center gap-1">
<Input
type="number"
className="!w-20 text-center"
value={Math.floor((settings.session_timeout || 60) / 60)}
onChange={e => {
const h = parseInt(e.target.value) || 0;
setSettings({ ...settings, session_timeout: (h * 60) + (settings.session_timeout % 60) });
}}
/>
<span className="font-medium text-slate-900 mx-1"></span>
</div>
<div className="flex items-center gap-1">
<Input
type="number"
className="!w-20 text-center"
value={(settings.session_timeout || 60) % 60}
onChange={e => {
const m = parseInt(e.target.value) || 0;
setSettings({ ...settings, session_timeout: (Math.floor(settings.session_timeout / 60) * 60) + m });
}}
/>
<span className="font-medium text-slate-900 mx-1"></span>
</div>
</div>
</div>
<div className="px-6 py-4 bg-slate-50/30 border-t border-slate-50 flex items-center justify-between">
<div className="flex-1">
{saveResults.security && (
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.security.success ? 'text-green-600' : 'text-red-600'}`}>
{saveResults.security.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
{saveResults.security.message}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={fetchSettings}></Button>
<Button size="sm" onClick={() => handleSaveSection('security')} disabled={saving} icon={<Save size={14} />}> </Button>
</div>
{/* v0.4.1.0 Info: Session Timeout relocation notice */}
<Card className="p-4 bg-indigo-50 border-indigo-100 flex items-start gap-3">
<Clock className="text-indigo-600 mt-1" size={20} />
<div>
<h3 className="text-sm font-bold text-indigo-900"> (v0.4.1.0)</h3>
<p className="text-xs text-indigo-700 mt-1 leading-relaxed">
<strong> </strong> .<br />
<strong>[ ]</strong> <strong>[ ]</strong> .
</p>
</div>
</Card>
@ -367,7 +333,7 @@ export function BasicSettingsPage() {
{/* Section 2.5: Gitea Repository Update Settings */}
<Card className="overflow-hidden border-slate-200 shadow-sm">
<div className="p-6 border-b border-slate-50 bg-slate-50/50">
<div className="p-6 border-b border-slate-50 bg-slate-50/50 flex justify-between items-center">
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
<RefreshCcw size={20} className="text-emerald-600" />
(Gitea)
@ -490,7 +456,14 @@ export function BasicSettingsPage() {
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"></label>
<Input type="password" value={settings.db_config.password} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }} />
<Input
type="password"
autoComplete="new-password"
readOnly
onFocus={(e) => e.target.readOnly = false}
value={settings.db_config.password}
onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }}
/>
</div>
</div>
</div>
@ -566,6 +539,9 @@ export function BasicSettingsPage() {
<label className="block text-sm font-medium text-slate-700 mb-2"> </label>
<Input
type="password"
autoComplete="new-password"
readOnly
onFocus={(e) => e.target.readOnly = false}
value={verifyPassword}
onChange={e => setVerifyPassword(e.target.value)}
placeholder="비밀번호를 입력하세요"

View File

@ -0,0 +1,157 @@
import { useState } from 'react';
import { Card } from '../../shared/ui/Card';
import { Button } from '../../shared/ui/Button';
import { Select } from '../../shared/ui/Select';
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',
sessionTimeout: user?.session_timeout || 10
});
const [isSaving, setIsSaving] = useState(false);
const [isSaved, setIsSaved] = useState(false);
const landingPageOptions = [
{ label: '플랫폼 소개 (기본)', value: '/home' },
{ label: '자산 대시보드', value: '/asset/dashboard' },
{ label: 'CCTV 모니터링', value: '/cctv/monitoring' },
{ label: '생산 대시보드', value: '/production/dashboard' }
];
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 (
<div className="page-container p-6 max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
<div className="grid grid-cols-1 gap-6">
<Card className="p-6">
<div className="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
<Home className="text-blue-600" size={24} />
<h2 className="text-lg font-bold"> </h2>
</div>
<div className="max-w-md">
<p className="text-sm text-slate-600 mb-4">
.
</p>
<Select
label="접속 시작 페이지"
value={preferences.landingPage}
onChange={(e) => setPreferences({ ...preferences, landingPage: e.target.value })}
options={landingPageOptions}
/>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
<User className="text-indigo-600" size={24} />
<h2 className="text-lg font-bold"> </h2>
</div>
<div className="grid grid-cols-2 gap-6 text-sm">
<div>
<span className="text-slate-500 block mb-1"> </span>
<span className="font-bold text-slate-900">{user?.name}</span>
</div>
<div>
<span className="text-slate-500 block mb-1"> </span>
<span className="font-bold text-indigo-600 capitalize">{user?.role}</span>
</div>
<div>
<span className="text-slate-500 block mb-1"> </span>
<span className="font-medium text-slate-700">{user?.department || '미지정'}</span>
</div>
<div>
<span className="text-slate-500 block mb-1"> </span>
<span className="font-bold text-blue-600">{user?.session_timeout}</span>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
<Clock className="text-orange-600" size={24} />
<h2 className="text-lg font-bold"> </h2>
</div>
<div className="max-w-md">
<p className="text-sm text-slate-600 mb-4">
. (단위: )
</p>
<div className="flex items-center gap-3">
<Input
type="number"
value={preferences.sessionTimeout}
onChange={(e) => setPreferences({ ...preferences, sessionTimeout: parseInt(e.target.value) || 0 })}
min={5}
max={1440}
className="!w-32"
/>
<span className="text-sm font-bold text-slate-700"> </span>
</div>
<p className="text-[11px] text-slate-400 mt-2">
* 5 1440(24) .
</p>
</div>
</Card>
{user?.role !== 'user' && (
<Card className="p-4 bg-blue-50/50 border-blue-100">
<div className="flex items-start gap-3">
<ShieldAlert className="text-blue-500 mt-0.5" size={18} />
<div>
<h3 className="text-sm font-bold text-blue-900"> </h3>
<p className="text-xs text-blue-700 mt-1 leading-relaxed">
'시스템 관리 - 기본 설정' v0.4.1.0 .<br />
<strong> </strong> <strong> </strong> .
</p>
</div>
</div>
</Card>
)}
<div className="flex items-center justify-between bg-white p-4 rounded-xl border border-slate-200 shadow-sm mt-4">
<div className="text-sm">
{isSaved && <span className="text-green-600 font-bold animate-pulse"> .</span>}
</div>
<Button
onClick={handleSave}
disabled={isSaving}
icon={isSaving ? <RefreshCcw size={18} className="animate-spin" /> : <Save size={18} />}
>
{isSaving ? '저장 중...' : '설정 저장'}
</Button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,275 @@
.landing-container {
padding: 2rem;
max-width: 1400px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 4rem;
}
/* Hero Section */
.hero-section {
display: grid;
grid-template-cols: 1.2fr 0.8fr;
gap: 4rem;
align-items: center;
padding: 2rem 0;
}
.premium-badge {
background: linear-gradient(90deg, #6366f1, #8b5cf6, #ec4899);
color: white;
padding: 0.4rem 1rem;
border-radius: 99px;
font-size: 0.75rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
display: inline-block;
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
margin-bottom: 1.5rem;
}
.hero-title {
font-size: 3.5rem;
line-height: 1.1;
font-weight: 800;
color: var(--sokuree-text-main);
margin-bottom: 1.5rem;
}
.gradient-text {
background: linear-gradient(135deg, #2563eb, #7c3aed);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
}
.hero-description {
font-size: 1.15rem;
color: var(--sokuree-text-muted);
line-height: 1.6;
margin-bottom: 2.5rem;
max-width: 600px;
}
.hero-actions .primary-btn {
background: #2563eb;
color: white;
padding: 1rem 2rem;
border-radius: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.75rem;
border: none;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.2);
}
.hero-actions .primary-btn:hover {
transform: translateY(-2px);
box-shadow: 0 15px 30px rgba(37, 99, 235, 0.3);
background: #1d4ed8;
}
/* Hero Visual */
.hero-visual {
position: relative;
display: flex;
justify-content: center;
align-items: center;
}
.visual-core {
width: 280px;
height: 280px;
background: white;
border-radius: 40px;
display: flex;
justify-content: center;
align-items: center;
position: relative;
z-index: 2;
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.08), inset 0 0 40px rgba(255, 255, 255, 0.8);
transform: rotate(-10deg);
}
.core-icon {
color: #2563eb;
}
.core-glow {
position: absolute;
width: 300px;
height: 300px;
background: radial-gradient(circle, rgba(37, 99, 235, 0.15) 0%, transparent 70%);
border-radius: 50%;
z-index: 1;
filter: blur(20px);
}
.floating-card {
position: absolute;
background: white;
padding: 0.75rem 1.25rem;
border-radius: 12px;
display: flex;
align-items: center;
gap: 0.75rem;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
z-index: 3;
font-weight: 600;
font-size: 0.85rem;
border: 1px solid rgba(0, 0, 0, 0.05);
}
.floating-card.c1 {
top: -20px;
right: 40px;
animation: float 4s ease-in-out infinite;
}
.floating-card.c2 {
bottom: 20px;
left: 40px;
animation: float 5s ease-in-out infinite reverse;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-15px);
}
}
/* Features Grid */
.features-grid {
display: grid;
grid-template-cols: repeat(4, 1fr);
gap: 1.5rem;
}
.feature-card {
padding: 2rem;
transition: all 0.3s ease;
}
.feature-card:hover {
transform: translateY(-8px);
}
.feature-icon-box {
width: 48px;
height: 48px;
background: var(--sokuree-bg-light);
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 1.25rem;
}
.feature-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--sokuree-text-main);
margin-bottom: 0.75rem;
}
.feature-desc {
font-size: 0.9rem;
color: var(--sokuree-text-muted);
line-height: 1.5;
}
/* Quick Access */
.quick-access {
background: var(--sokuree-bg-light);
padding: 3rem;
border-radius: 24px;
border: 1px solid rgba(0, 0, 0, 0.03);
}
.section-title {
font-size: 1.75rem;
font-weight: 800;
color: var(--sokuree-text-main);
margin-bottom: 2rem;
text-align: center;
}
.access-cards {
display: grid;
grid-template-cols: repeat(3, 1fr);
gap: 1.5rem;
}
.access-card {
background: white;
padding: 1.75rem;
border-radius: 16px;
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
transition: all 0.2s ease;
border: 1px solid transparent;
}
.access-card:hover {
border-color: #2563eb;
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05);
}
.access-card:hover .access-arrow {
transform: translateX(4px);
color: #2563eb;
}
.access-info h4 {
font-size: 1.1rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.access-info p {
font-size: 0.85rem;
color: var(--sokuree-text-muted);
}
.access-arrow {
color: #cbd5e1;
transition: all 0.2s ease;
}
@media (max-width: 1024px) {
.features-grid {
grid-template-cols: repeat(2, 1fr);
}
.hero-section {
grid-template-cols: 1fr;
gap: 2rem;
}
.hero-visual {
display: none;
}
}
@media (max-width: 768px) {
.access-cards {
grid-template-cols: 1fr;
}
.hero-title {
font-size: 2.5rem;
}
}

View File

@ -0,0 +1,118 @@
import { Card } from '../../shared/ui/Card';
import { Box, CheckCircle2, Shield, Zap, BarChart3, Globe, ArrowRight } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import './LandingPage.css';
export function LandingPage() {
const navigate = useNavigate();
const features = [
{
icon: <Shield className="text-blue-500" size={24} />,
title: "보안 인프라 관리",
description: "강력한 암호화 엔진과 권한 계층 구조를 통해 기업의 핵심 자산을 안전하게 보호합니다."
},
{
icon: <Zap className="text-amber-500" size={24} />,
title: "실시간 모니터링",
description: "CCTV 연동 및 자산 상태 추적을 통해 현장의 모든 상황을 실시간으로 파악합니다."
},
{
icon: <BarChart3 className="text-emerald-500" size={24} />,
title: "데이터 시각화",
description: "복잡한 자산 및 정비 이력을 직관적인 대시보드와 보고서로 변환하여 의사결정을 지원합니다."
},
{
icon: <Globe className="text-indigo-500" size={24} />,
title: "통합 제어 플랫폼",
description: "자산, 생산, 보안을 하나의 플랫폼에서 통합 제어하여 운영 효율성을 극대화합니다."
}
];
return (
<div className="landing-container animate-fade-in">
{/* Hero Section */}
<div className="hero-section">
<div className="hero-content">
<div className="badge-wrapper">
<span className="premium-badge">Next-Gen Agentic Platform</span>
</div>
<h1 className="hero-title">
SMART IMS <br />
<span className="gradient-text">Intelligent Management System</span>
</h1>
<p className="hero-description">
Smart IMS에 . <br />
.
</p>
<div className="hero-actions">
<button className="primary-btn" onClick={() => navigate('/asset/dashboard')}>
<ArrowRight size={18} />
</button>
</div>
</div>
<div className="hero-visual">
<div className="floating-card c1">
<CheckCircle2 className="text-green-500" size={20} />
<span>System Online</span>
</div>
<div className="floating-card c2">
<Zap className="text-amber-500" size={20} />
<span>Real-time Sync</span>
</div>
<div className="visual-core">
<Box size={120} className="core-icon" />
<div className="core-glow"></div>
</div>
</div>
</div>
{/* Features Grid */}
<div className="features-grid">
{features.map((feature, idx) => (
<Card key={idx} className="feature-card">
<div className="feature-icon-box">
{feature.icon}
</div>
<h3 className="feature-title">{feature.title}</h3>
<p className="feature-desc">{feature.description}</p>
</Card>
))}
</div>
{/* Quick Access Section */}
<div className="quick-access">
<h2 className="section-title"> </h2>
<div className="access-cards">
<div className="access-card" onClick={() => navigate('/asset/list')}>
<div className="access-info">
<h4> </h4>
<p> </p>
</div>
<div className="access-arrow">
<ArrowRight size={20} />
</div>
</div>
<div className="access-card" onClick={() => navigate('/cctv/monitoring')}>
<div className="access-info">
<h4>CCTV </h4>
<p> </p>
</div>
<div className="access-arrow">
<ArrowRight size={20} />
</div>
</div>
<div className="access-card" onClick={() => navigate('/production/dashboard')}>
<div className="access-info">
<h4> </h4>
<p> </p>
</div>
<div className="access-arrow">
<ArrowRight size={20} />
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -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<User[]>([]);
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,8 +156,11 @@ export function UserManagementPage() {
<h1 className="text-2xl font-bold text-slate-900"> - </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => window.print()} icon={<Printer size={16} />}></Button>
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}> </Button>
</div>
</div>
<Card className="overflow-hidden shadow-sm border-slate-200">
<div className="overflow-x-auto">
@ -160,6 +170,7 @@ export function UserManagementPage() {
<th className="px-6 py-4"> / </th>
<th className="px-6 py-4"></th>
<th className="px-6 py-4"> / </th>
<th className="px-6 py-4"> ()</th>
<th className="px-6 py-4"></th>
<th className="px-6 py-4"> </th>
<th className="px-6 py-4 text-center"></th>
@ -186,6 +197,9 @@ export function UserManagementPage() {
<div className="text-slate-900 font-bold">{user.department || '-'}</div>
<div className="text-slate-600 text-[11px] font-medium">{user.position}</div>
</td>
<td className="px-6 py-4">
<span className="text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded ring-1 ring-indigo-100">{(user as any).session_timeout || 10}</span>
</td>
<td className="px-6 py-4 text-slate-700 font-medium">{user.phone || '-'}</td>
<td className="px-6 py-4 text-slate-400 text-[11px]">
{user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'}
@ -228,6 +242,10 @@ export function UserManagementPage() {
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> <span className="text-red-500">*</span></label>
<Input
name="user_id_new"
autoComplete="off"
readOnly={!isEditing}
onFocus={(e) => e.target.readOnly = false}
value={formData.id}
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
disabled={isEditing}
@ -241,6 +259,10 @@ export function UserManagementPage() {
<span className="text-red-500">{!isEditing && '*'}</span>
</label>
<Input
name="user_password_new"
autoComplete="new-password"
readOnly={!isEditing}
onFocus={(e) => e.target.readOnly = false}
type="password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
@ -265,17 +287,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')
}
>
<option value="user"> </option>
<option value="user"></option>
<option value="admin"></option>
{/* 최고관리자(supervisor) 옵션은 오직 최고관리자만 부여 가능 */}
{(currentUser?.role === 'supervisor' || (isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor')) && (
<option value="supervisor"> (Supervisor)</option>
<option value="supervisor"></option>
)}
</select>
{currentUser?.role !== 'supervisor' && (
@ -301,6 +320,7 @@ export function UserManagementPage() {
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> </label>
<Input
@ -310,6 +330,20 @@ export function UserManagementPage() {
maxLength={13}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
() <span className="text-red-500">*</span>
</label>
<Input
type="number"
value={formData.session_timeout}
onChange={(e) => setFormData({ ...formData, session_timeout: parseInt(e.target.value) || 0 })}
min={5}
placeholder="기본 10"
required
/>
</div>
</div>
<div className="pt-4 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}></Button>

View File

@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { Card } from '../../shared/ui/Card';
import { apiClient } from '../../shared/api/client';
import { Info, Cpu, Database, Server, Hash, Calendar, RefreshCw, AlertTriangle, CheckCircle2 } from 'lucide-react';
import { Info, Cpu, Database, Server, Hash, Calendar, RefreshCw, AlertTriangle, CheckCircle2, ChevronLeft, ChevronRight } from 'lucide-react';
interface VersionInfo {
status: string;
@ -36,6 +36,8 @@ export function VersionPage() {
const [checkingRemote, setCheckingRemote] = useState(false);
const [updating, setUpdating] = useState(false);
const [updateResult, setUpdateResult] = useState<{ success: boolean; message: string } | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 5;
const fetchVersion = async () => {
setLoading(true);
@ -53,12 +55,13 @@ export function VersionPage() {
const fetchRemoteVersion = async () => {
setCheckingRemote(true);
try {
const res = await apiClient.get('/system/version/remote');
const res = await apiClient.get(`/system/version/remote?t=${Date.now()}`);
setRemoteInfo(res.data);
} catch (err) {
console.error('Failed to fetch remote version info', err);
} finally {
setCheckingRemote(false);
setCurrentPage(1);
}
};
@ -109,13 +112,12 @@ export function VersionPage() {
}
};
// Client/Frontend version fixed at build time
const frontendVersion = '0.3.3';
const buildDate = '2026-01-24';
// Source of truth for versioning comes from the API
const currentVersion = remoteInfo?.current || healthIcon?.version || '0.4.0.0';
const buildDate = '2026-01-25';
// Check if update is needed based on frontend version vs remote tag
const needsUpdate = remoteInfo?.latest ?
(remoteInfo.latest.replace(/^v/, '') !== frontendVersion) : false;
// IMPORTANT: needsUpdate should be true if remote has a higher version than local
const needsUpdate = !!remoteInfo?.needsUpdate;
return (
<div className="page-container p-6 max-w-4xl mx-auto">
@ -134,52 +136,50 @@ export function VersionPage() {
</button>
</div>
{/* Update Alert Banner - Based on Frontend Version comparison */}
{needsUpdate && !updateResult && (
<div className={`mb-8 p-0 border rounded-xl overflow-hidden animate-in fade-in slide-in-from-top-4 duration-500 shadow-lg ${remoteInfo?.latestInfo?.type === 'urgent' ? 'border-red-200 bg-red-50' : 'border-amber-200 bg-amber-50'}`}>
<div className="p-5 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div className="flex items-start gap-3">
<div className={`p-2 rounded-lg mt-0.5 ${remoteInfo?.latestInfo?.type === 'urgent' ? 'bg-red-100 text-red-600' : 'bg-amber-100 text-amber-600'}`}>
<AlertTriangle size={20} />
{/* Update Alert Banner - Enhanced Visibility with Details */}
{needsUpdate && !updateResult && remoteInfo?.latestInfo && (
<div className={`mb-8 p-6 border-2 rounded-2xl shadow-xl flex flex-col md:flex-row items-center justify-between gap-6 ${remoteInfo.latestInfo.type === 'urgent' ? 'border-red-400 bg-red-50' : 'border-indigo-400 bg-indigo-50'}`}>
<div className="flex items-start gap-4 flex-1">
<div className={`p-3 rounded-xl shrink-0 ${remoteInfo.latestInfo.type === 'urgent' ? 'bg-red-500 text-white' : 'bg-indigo-600 text-white'}`}>
<AlertTriangle size={24} />
</div>
<div>
<div className="flex items-center gap-2 mb-1">
<h4 className={`font-black text-lg ${remoteInfo?.latestInfo?.type === 'urgent' ? 'text-red-900' : 'text-amber-900'}`}>
{remoteInfo?.latestInfo?.type === 'urgent' ? '🚨 긴급 보안/시스템 업데이트 발견' : '✨ 새로운 업데이트가 가능합니다'}
<div className="space-y-1">
<div className="flex items-center gap-2">
<h4 className="text-xl font-black text-slate-900 leading-tight">
{remoteInfo.latestInfo.type === 'urgent' ? '신속한 시스템 업데이트가 필요합니다' : '새로운 플랫폼 기능이 준비되었습니다'}
</h4>
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${remoteInfo?.latestInfo?.type === 'urgent' ? 'bg-red-600 text-white' : 'bg-amber-600 text-white'}`}>
{remoteInfo?.latestInfo?.type || 'patch'}
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${remoteInfo.latestInfo.type === 'urgent' ? 'bg-red-600 text-white' : 'bg-indigo-600 text-white'}`}>
{remoteInfo.latestInfo.type}
</span>
</div>
<p className={`text-sm font-medium ${remoteInfo?.latestInfo?.type === 'urgent' ? 'text-red-700' : 'text-amber-700'}`}>
버전: v{frontendVersion} : <span className="font-black underline underline-offset-4">{remoteInfo?.latest}</span>
<p className="text-slate-600 font-medium text-sm">
현재: v{currentVersion} <span className="mx-2"></span> : <span className="font-black text-indigo-700 underline underline-offset-4">{remoteInfo.latest}</span>
</p>
{remoteInfo?.latestInfo && (
<div className={`mt-3 p-3 rounded-lg border text-xs leading-relaxed ${remoteInfo?.latestInfo?.type === 'urgent' ? 'bg-white/50 border-red-100 text-red-800' : 'bg-white/50 border-amber-100 text-amber-800'}`}>
<p className="font-bold mb-1">[{remoteInfo.latestInfo.title}]</p>
<ul className="space-y-1 opacity-80">
{remoteInfo.latestInfo.changes.slice(0, 3).map((c, i) => (
<li key={i} className="flex items-start gap-1.5">
<span className="mt-1 w-1 h-1 rounded-full bg-current opacity-50 shrink-0"></span>
{/* Detailed Changes from Tag Message */}
<div className="mt-4 p-4 bg-white/80 backdrop-blur-sm rounded-xl border border-white/50 shadow-sm overflow-hidden text-ellipsis">
<p className="font-bold text-slate-800 text-sm mb-2">{remoteInfo.latestInfo.title}</p>
<ul className="space-y-1.5">
{remoteInfo.latestInfo.changes.map((c, i) => (
<li key={i} className="flex items-start gap-2 text-xs text-slate-600">
<div className="mt-1.5 w-1 h-1 rounded-full bg-slate-400 shrink-0"></div>
<span>{c}</span>
</li>
))}
{remoteInfo.latestInfo.changes.length > 3 && <li>... {remoteInfo.latestInfo.changes.length - 3}</li>}
</ul>
</div>
)}
</div>
</div>
<button
onClick={handleUpdate}
disabled={updating}
className={`px-6 py-3 rounded-xl font-black text-base transition-all shadow-md active:scale-95 disabled:opacity-50 flex items-center justify-center gap-2 whitespace-nowrap min-w-[160px] ${remoteInfo?.latestInfo?.type === 'urgent' ? 'bg-red-600 text-white hover:bg-red-700' : 'bg-amber-600 text-white hover:bg-amber-700'}`}
className={`px-8 py-4 rounded-xl font-black text-lg transition-all shadow-lg hover:scale-105 active:scale-95 disabled:opacity-50 flex items-center gap-2 whitespace-nowrap min-w-[180px] shadow-indigo-200 ${remoteInfo.latestInfo.type === 'urgent' ? 'bg-red-600 text-white hover:bg-red-700' : 'bg-indigo-600 text-white hover:bg-slate-900'}`}
>
{updating ? <RefreshCw size={20} className="animate-spin" /> : null}
{updating ? '업데이트 중...' : '지금 업데이트'}
{updating ? <RefreshCw size={22} className="animate-spin" /> : null}
{updating ? '설치 중...' : '지금 업데이트'}
</button>
</div>
</div>
)}
{/* Update Result Message */}
@ -209,7 +209,7 @@ export function VersionPage() {
<div className="space-y-4">
<div className="flex justify-between items-center py-2 border-b border-slate-50">
<span className="text-slate-500 text-sm flex items-center gap-2"><Info size={14} /> </span>
<span className="font-bold text-indigo-600 bg-indigo-50 px-3 py-1 rounded-full text-xs">v{frontendVersion}</span>
<span className="font-bold text-indigo-600 bg-indigo-50 px-3 py-1 rounded-full text-xs">v{currentVersion}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-slate-50">
<span className="text-slate-500 text-sm flex items-center gap-2"><Calendar size={14} /> </span>
@ -287,8 +287,11 @@ export function VersionPage() {
<div className="space-y-4">
{remoteInfo?.history && remoteInfo.history.length > 0 ? (
remoteInfo.history.map((entry, idx) => (
<Card key={entry.version} className={`p-6 border-slate-200 shadow-sm transition-all hover:border-indigo-200 ${idx === 0 ? 'bg-indigo-50/20 border-indigo-100 ring-2 ring-indigo-50/50 shadow-indigo-100/50' : ''}`}>
<>
{remoteInfo.history
.slice((currentPage - 1) * ITEMS_PER_PAGE, currentPage * ITEMS_PER_PAGE)
.map((entry, idx) => (
<Card key={entry.version} className={`p-6 border-slate-200 shadow-sm transition-all hover:border-indigo-200 ${idx === 0 && currentPage === 1 ? 'bg-indigo-50/20 border-indigo-100 ring-2 ring-indigo-50/50 shadow-indigo-100/50' : ''}`}>
<div className="flex flex-col md:flex-row md:items-center gap-4 mb-4">
<div className="flex items-center gap-2">
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${entry.type === 'feature' ? 'bg-indigo-600 text-white' : entry.type === 'urgent' ? 'bg-red-600 text-white' : 'bg-slate-200 text-slate-700'}`}>
@ -302,7 +305,7 @@ export function VersionPage() {
</div>
<div className="text-xs text-slate-400 font-medium px-2 py-1 bg-slate-50 rounded italic">{entry.date}</div>
</div>
<ul className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
<ul className="space-y-2">
{entry.changes.map((change, i) => (
<li key={i} className="flex items-start gap-2 text-[13px] text-slate-600 leading-relaxed">
<div className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500/50 flex-shrink-0 animate-pulse"></div>
@ -311,7 +314,41 @@ export function VersionPage() {
))}
</ul>
</Card>
))
))}
{/* Pagination Controls */}
{remoteInfo.history.length > ITEMS_PER_PAGE && (
<div className="flex justify-center items-center gap-4 mt-8 pt-4 border-t border-slate-100">
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-2 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
>
<ChevronLeft size={20} />
</button>
<div className="flex items-center gap-1">
{Array.from({ length: Math.ceil(Math.min(50, remoteInfo.history.length) / ITEMS_PER_PAGE) }).map((_, i) => (
<button
key={i}
onClick={() => setCurrentPage(i + 1)}
className={`w-8 h-8 rounded-lg text-sm font-bold transition-all ${currentPage === i + 1 ? 'bg-indigo-600 text-white shadow-md shadow-indigo-200' : 'text-slate-400 hover:text-indigo-600 hover:bg-indigo-50'}`}
>
{i + 1}
</button>
))}
</div>
<button
onClick={() => setCurrentPage(p => Math.min(Math.ceil(remoteInfo.history.length / ITEMS_PER_PAGE), p + 1))}
disabled={currentPage === Math.ceil(remoteInfo.history.length / ITEMS_PER_PAGE)}
className="p-2 rounded-lg border border-slate-200 text-slate-500 hover:bg-slate-50 disabled:opacity-30 disabled:hover:bg-transparent transition-colors"
>
<ChevronRight size={20} />
</button>
</div>
)}
</>
) : (
<div className="text-center py-12 text-slate-400 italic bg-slate-50 rounded-xl border border-dashed border-slate-200">
.

View File

@ -97,3 +97,142 @@ button {
padding: var(--sokuree-spacing-md);
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
/* Utility Animations */
.animate-fade-in {
animation: fadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
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;
}
}

View File

@ -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<Asset>[]; // 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<Asset> => {
const response = await apiClient.get<DBAsset>(`/assets/${id}`);
return mapDBToAsset(response.data);
const asset = mapDBToAsset(response.data);
if (response.data.children) {
asset.children = response.data.children.map(c => ({
id: c.id,
name: c.name,
category: c.category,
status: c.status,
location: c.location
}));
}
if (response.data.parent_name) {
asset.parentName = response.data.parent_name;
}
return asset;
},
create: (data: Partial<Asset>) => {
@ -136,12 +157,27 @@ export const assetApi = {
deleteManual: async (id: number) => {
return apiClient.delete(`/manuals/${id}`);
},
// Accessories
getAccessories: async (assetId: string): Promise<any[]> => {
const response = await apiClient.get<any[]>(`/assets/${assetId}/accessories`);
return response.data;
},
addAccessory: async (assetId: string, data: { name: string, spec: string, quantity: number }) => {
return apiClient.post(`/assets/${assetId}/accessories`, data);
},
deleteAccessory: async (id: number) => {
return apiClient.delete(`/accessories/${id}`);
}
};
// Helper Mappers
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<Asset>): Partial<DBAsset> => {
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; }

View File

@ -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<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(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);
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();
const idleTime = Date.now() - lastActivityRef.current;
if (idleTime >= timeoutMsRef.current) {
checkSession(true);
}
}
return current;
});
}, 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 {

View File

@ -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<Record<string, ModuleState>>({});
const [isLoading, setIsLoading] = useState(true);
const { isAuthenticated } = useAuth(); // Only load if authenticated
const isFetchingRef = useRef<string | null>(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);
}

View File

@ -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();
@ -13,7 +13,7 @@ export function LicensePage() {
const moduleInfo: Record<string, { title: string, desc: string }> = {
'asset': { title: '자산 관리 모듈', desc: '자산 등록, 조회, 수정 및 유지보수 이력 관리' },
'production': { title: '생산 관리 모듈', desc: '생산 계획, 실적 및 공정 관리' },
'monitoring': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' }
'cctv': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' }
};
// Subscriber Configuration State
@ -21,6 +21,13 @@ export function LicensePage() {
const [inputSubscriberId, setInputSubscriberId] = useState('');
const [isConfigSaving, setIsConfigSaving] = useState(false);
// Supervisor Verification State
const [isConfigUnlocked, setIsConfigUnlocked] = useState(false);
const [showVerifyModal, setShowVerifyModal] = useState(false);
const [verifyPassword, setVerifyPassword] = useState('');
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState('');
// Initial Load for Subscriber ID
useState(() => {
fetchSubscriberId();
@ -86,23 +93,72 @@ export function LicensePage() {
}
};
// ... Generator logic omitted for brevity as it's dev-only ...
const handleOpenVerify = () => {
setVerifyError('');
setVerifyPassword('');
setShowVerifyModal(true);
};
const handleVerifySupervisor = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
setVerifying(true);
setVerifyError('');
try {
const res = await apiClient.post('/verify-supervisor', { password: verifyPassword });
if (res.data.success) {
setIsConfigUnlocked(true);
setShowVerifyModal(false);
setVerifyPassword('');
}
} catch (error: any) {
setVerifyError(error.response?.data?.message || '인증 실패');
} finally {
setVerifying(false);
}
};
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<h2 className="text-xl font-bold flex items-center gap-2">
<Shield className="text-blue-600" />
</h2>
<button
onClick={() => window.print()}
className="flex items-center gap-2 px-4 py-2 bg-white border border-slate-200 rounded hover:bg-slate-50 transition text-sm font-medium no-print"
>
<Printer size={16} />
</button>
</div>
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8 overflow-hidden">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold flex items-center gap-2">
<Terminal size={20} className="text-slate-600" />
</h3>
<div className="max-w-xl">
{!isConfigUnlocked && (
<button
onClick={handleOpenVerify}
className="text-xs bg-slate-200 hover:bg-slate-300 px-3 py-1 rounded font-bold text-slate-700 flex items-center gap-1 transition"
>
<Shield size={12} /> /
</button>
)}
{isConfigUnlocked && (
<span className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded font-bold flex items-center gap-1">
<Shield size={12} />
</span>
)}
</div>
{isConfigUnlocked ? (
<div className="max-w-xl animate-in fade-in duration-300">
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-1">
@ -119,15 +175,22 @@ export function LicensePage() {
<button
onClick={handleSaveConfig}
disabled={isConfigSaving}
className="bg-slate-800 text-white px-6 py-2 rounded hover:bg-slate-900 transition disabled:opacity-50 h-[42px]"
className="bg-slate-800 text-white px-6 py-2 rounded hover:bg-slate-900 transition disabled:opacity-50 h-[42px] whitespace-nowrap"
>
{isConfigSaving ? '저장 중...' : '설정 저장'}
</button>
</div>
<p className="text-xs text-slate-500 mt-1">
* ID와 .
<p className="text-xs text-slate-500 mt-2">
* 주의: 구독자 ID를 .<br />
* ID와 .
</p>
</div>
) : (
<div className="p-8 text-center bg-slate-100 rounded border border-slate-200 border-dashed">
<p className="text-slate-500 text-sm mb-2"> .</p>
<p className="text-slate-400 text-xs"> <strong>(Supervisor)</strong> .</p>
</div>
)}
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
@ -251,6 +314,50 @@ export function LicensePage() {
{/* Supervisor Verification Modal */}
{showVerifyModal && (
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-[60] animate-in fade-in duration-200">
<div className="bg-white rounded-lg shadow-2xl w-full max-w-sm overflow-hidden border border-slate-200">
<div className="bg-slate-800 p-4 text-white">
<h3 className="text-lg font-bold flex items-center gap-2">
<Shield size={20} />
</h3>
<p className="text-slate-300 text-xs mt-1"> .</p>
</div>
<form onSubmit={handleVerifySupervisor} className="p-6">
<div className="mb-4">
<label className="block text-sm font-bold text-slate-700 mb-2"></label>
<input
type="password"
autoComplete="off"
autoFocus
className="w-full border border-slate-300 rounded px-3 py-2 focus:ring-2 focus:ring-slate-500 outline-none"
placeholder="최고관리자 비밀번호 입력"
value={verifyPassword}
onChange={(e) => setVerifyPassword(e.target.value)}
/>
{verifyError && <p className="text-red-500 text-xs mt-2 font-bold">{verifyError}</p>}
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => { setShowVerifyModal(false); setVerifyPassword(''); }}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded text-sm font-medium"
>
</button>
<button
type="submit"
disabled={verifying}
className="px-4 py-2 bg-slate-800 text-white rounded hover:bg-slate-900 text-sm font-bold"
>
{verifying ? '인증 중...' : '확인'}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -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 {

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../shared/auth/AuthContext';
import { useSystem } from '../../shared/context/SystemContext';
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info } from 'lucide-react';
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info, Home, UserCog } from 'lucide-react';
import type { IModuleDefinition } from '../../core/types';
import './MainLayout.css';
@ -46,7 +46,15 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
</div>
<nav className="sidebar-nav">
{/* Module: System Management (Platform Core) */}
{/* Platform Home */}
<div className="module-group mb-4">
<Link to="/home" className={`nav-item ${location.pathname === '/home' ? 'active' : ''}`}>
<Home size={18} />
<span> </span>
</Link>
</div>
{/* System Management (Platform Core) */}
{isAdmin && (
<div className="module-group">
<button
@ -103,8 +111,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
onClick={() => toggleModule(mod.moduleName)}
>
<div className="module-title">
<Layers size={18} />
<span>{mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1)} </span>
{mod.moduleName === 'cctv' ? <Video size={18} /> : <Layers size={18} />}
<span>{mod.label || (mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1) + ' 관리')}</span>
</div>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
@ -174,13 +182,23 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
>
<div className="module-title">
<Video size={18} />
<span>{mod.moduleName.split('-')[0].toUpperCase()}</span>
<span>{mod.label || mod.moduleName.split('-')[0].toUpperCase()}</span>
</div>
</Link>
)}
</div>
);
})}
<div className="sidebar-divider my-4 border-t border-slate-100 opacity-50"></div>
{/* User Preferences - Accessible to all roles */}
<div className="module-group">
<Link to="/preferences" className={`nav-item ${location.pathname === '/preferences' ? 'active' : ''}`}>
<UserCog size={18} />
<span> </span>
</Link>
</div>
</nav>
<div className="sidebar-footer">
@ -244,6 +262,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
</div>
)}
</div>
<div id="header-portal-root" className="header-portal-root"></div>
</header>
<div className="content-area">
<Outlet />

39
update_system.bat Normal file
View File

@ -0,0 +1,39 @@
@echo off
echo [Update] Starting update to v0.4.2.6...
REM Ensure backup directory
set BACKUP_PATH=./backup
if not exist "%BACKUP_PATH%" mkdir "%BACKUP_PATH%" 2>nul
if not exist "%BACKUP_PATH%" (
echo [Warning] Global backup failed, using local backup.
set BACKUP_PATH=.\server\backups
if not exist ".\server\backups" mkdir ".\server\backups"
)
echo [Update] Backing up Config...
if exist "server\.env" (
copy /Y "server\.env" "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22"
copy /Y "server\.env" "server\.env.tmp"
)
echo [Update] Syncing Source Code...
git fetch "https://gitea.qideun.com/SOKUREE/smart_ims.git" --tags --force --prune
git checkout -f v0.4.2.6
echo [Update] Restoring Config...
if exist "server\.env.tmp" (
copy /Y "server\.env.tmp" "server\.env"
del "server\.env.tmp"
) else if exist "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22" (
copy /Y "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22" "server\.env"
)
echo [Update] Installing & Building...
call npm install
call npm run build
cd server
call npm install
echo [Update] Done.