Compare commits

..

No commits in common. "main" and "v0.2.6" have entirely different histories.
main ... v0.2.6

60 changed files with 723 additions and 3704 deletions

13
.gitignore vendored
View File

@ -22,6 +22,14 @@ dist-ssr
build
out
# Environment Variables (CRITICAL)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.production
# Editor directories and files
.vscode/*
!.vscode/extensions.json
@ -39,13 +47,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
server/public_key.pem
server/private_key.pem
# Project Specific - Camera/Stream

View File

@ -1,23 +0,0 @@
# ==============================================
# [Common Settings]
# ==============================================
DB_HOST=sokuree.com
DB_USER=choibk
DB_PASSWORD=^Ocean1472bk
PORT=3005
# ==============================================
# [Development Environment] - Local Windows
# ==============================================
# 로컬 개발용 DB (분리됨: sokuree_platform_dev)
DB_NAME=sokuree_platform_dev
DB_PORT=3307
# Windows 환경 호환성 (tcp는 권한 오류 발생 가능)
CCTV_TRANSPORT_OVERRIDE=auto
# ==============================================
# [Production Environment] - Synology NAS
# ==============================================
# DB_NAME=sokuree_platform_prod
# DB_PORT=3307

View File

@ -57,11 +57,8 @@ smartims/
## 4. 기타 주요 디렉토리
- `docs/`:
- `operation/`: 설치 및 운영 관련 가이드 (환경 설정, 배포, 라이선스 서버 등)
- `modules/`: 모듈별 상세 기술 및 사용자 가이드 (자산 분류 기준 등)
- `design/`: 플랫폼 설계 원칙, 시스템 아키텍처 및 디렉토리 구조
- `version_control/`: Git 운영 정책 및 버전 관리 규정
- `roadmap/`: 프로젝트 통합 마스터 플랜 및 단계별 로드맵 (`INTEGRATED_ROADMAP.md`)
- `MODULAR_ARCHITECTURE_GUIDE.md`: 모듈화 상세 설계 원칙.
- `DEPLOYMENT_GUIDE.md`: 서버 배포 방법.
- `tools/`:
- 라이선스 관리 도구, 데이터 마이그레이션 스크립트 등 개발 보조 도구 포함.

View File

@ -22,22 +22,19 @@ SmartIMS 배포는 **Git Tag 기반 배포** 방식을 권장합니다. 이는
### 2.2. 설치 단계
1. **소스 클론**:
`/volume1/web` 위치에서 아래 명령어를 실행하면 `smartims` 폴더가 생성되며 그 안에 소스가 들어갑니다.
```bash
cd /volume1/web
# 저장소 URL 뒤에 'smartims'를 붙여 폴더명을 지정합니다.
git clone https://gitea.qideun.com/SOKUREE/smart_ims.git smartims
git clone [저장소_URL] smartims
cd smartims
```
2. **안정 버전(Tag) 전환**:
```bash
git fetch --all --tags
# v0.2.6 버전으로 전환
git checkout v0.2.6
# 현재 배포 가능한 최신 태그로 전환 (예: v0.2.5)
git checkout v0.2.5
```
3. **환경 설정**:
* `server/.env` 파일을 환경에 맞게 생성(DB 정보 등 입력).
* `server/config/public_key.pem` 파일이 존재하는지 확인 (라이선스 검증용).
* `server/public_key.pem` 파일이 존재하는지 확인 (라이선스 검증용).
4. **패키지 설치 및 빌드**:
```bash
# 전체 의존성 설치 및 프론트엔드 빌드
@ -74,19 +71,17 @@ npm run build
pm2 reload smartims-api
```
### 3.2. 시스템 관리 메뉴를 통한 업데이트 (지원됨)
시스템의 **"시스템 관리 > 버전 정보"** 화면에서 신규 버전을 감지하고 원 클릭으로 업데이트할 수 있습니다.
* 참고: 이 기능을 사용하려면 **[기본 설정]** 메뉴에서 Gitea 원격 저장소 URL과 (필요 시) 계정 정보를 먼저 설정해야 합니다.
### 3.2. 시스템 관리 메뉴를 통한 업데이트 (검토 중)
시스템의 "버전 정보" 화면에서 신규 버전을 감지하고 업데이트하는 기능을 검토 중입니다.
* **동작 원리**:
1. 서버가 설정된 원격 저장소의 최신 Tag 리스트를 확인합니다.
2. `package.json`의 현재 버전과 원격의 최신 Tag를 비교합니다.
3. 업데이트 실행 시 서버가 백그라운드에서 `git checkout` -> `npm install` -> `npm run build` -> `pm2 reload` 과정을 자동로 수행합니다.
1. 서버가 원격 저장소의 최신 Tag 리스트를 정기적으로 또는 요청 시 확인합니다.
2. `package.json`의 현재 버전과 원격의 최신 Tag를 비교하여 업데이트 버튼을 활성화합니다.
3. 업데이트 실행 시 서버 내부적으로 `git checkout` -> `npm install` -> `npm run build` -> `pm2 reload` 과정을 자동화된 스크립트로 수행합니다.
* **기대 효과**:
* 터미널(SSH) 접속 없이 관리자 화면에서 즉시 최신 기능 반영 가능.
* 운영 서버 환경에서도 편리한 버전 관리.
* 버전 불일치 문제 예방 및 운영 편의성 증대.
---

View File

@ -1,49 +0,0 @@
# 📋 자산 분류 및 등록 기준 가이드 (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

@ -1,236 +0,0 @@
# 🚀 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

View File

@ -1,158 +0,0 @@
# 🏷️ 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

@ -1,54 +0,0 @@
# 📘 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.4.0.1",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "smartims",
"version": "0.4.0.1",
"version": "0.1.0",
"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.4.2.7",
"version": "0.2.6",
"type": "module",
"scripts": {
"dev": "vite",

View File

@ -51,9 +51,6 @@ const sessionStoreOptions = {
};
const sessionStore = new MySQLStore(sessionStoreOptions);
sessionStore.on('error', (err) => {
console.error('Session Store Error:', err);
});
// Middleware
app.use(cors({
@ -84,21 +81,15 @@ 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'");
timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 10; // New default fallback 10 as requested
}
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;
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
// Explicitly save session before moving to next middleware
@ -117,24 +108,12 @@ app.use(async (req, res, next) => {
// Apply CSRF Protection
app.use(csrfProtection);
// Request Logger with Session Remaining Time
// Request Logger
app.use((req, res, next) => {
const now = new Date();
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}`);
// UTC 시간에 9시간을 더한 뒤 ISO 문자열로 변환하고 끝의 'Z'를 제거하여 한국 시간 형식 생성
const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', '');
console.log(`[${kstDate}] ${req.method} ${req.url}`);
next();
});
@ -192,14 +171,6 @@ 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 (
@ -225,14 +196,20 @@ 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);
console.log('✅ Users Table Created');
// Update existing table if needed
try {
await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'");
} catch (e) {
// Ignore if it fails (e.g. column doesn't exist yet handled by SQL above)
}
console.log('✅ Users Table Initialized with Supervisor role');
// Default Admin
const adminId = 'admin';
@ -245,22 +222,12 @@ 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 = `
@ -275,105 +242,43 @@ const initTables = async () => {
`;
await db.query(manualTable);
// 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
// Create camera_settings table
const cameraTable = `
CREATE TABLE IF NOT EXISTS cctv_settings (
CREATE TABLE IF NOT EXISTS camera_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(255),
password VARCHAR(100),
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 cctv_settings LIKE 'transport_mode'");
const [camColumns] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'transport_mode'");
if (camColumns.length === 0) {
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");
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");
} else {
// Check if quality exists (for subsequent updates)
const [qualCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'quality'");
const [qualCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'quality'");
if (qualCol.length === 0) {
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");
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");
}
}
// 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 cctv_settings LIKE 'display_order'");
const [orderCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'display_order'");
if (orderCol.length === 0) {
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");
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");
}
// Create system_settings table (Key-Value store)
@ -441,20 +346,9 @@ 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', '자산 관리', false, null]);
await db.query(insert, ['asset', '자산 관리', true, 'dev']);
await db.query(insert, ['production', '생산 관리', 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'");
}
await db.query(insert, ['monitoring', 'CCTV', false, null]);
}
console.log('✅ Tables Initialized');
@ -467,29 +361,10 @@ 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: version,
node_version: process.version,
platform: process.platform,
arch: process.arch,
timestamp: kstDate.toISOString().replace('T', ' ').split('.')[0]
version: packageJson.version,
timestamp: new Date().toISOString().replace('T', ' ').split('.')[0]
});
});
@ -541,10 +416,6 @@ app.use(express.static(distPath));
// The "catchall" handler: for any request that doesn't
// match one above, send back React's index.html file.
app.get(/(.*)/, (req, res) => {
// Prevent caching for index.html to ensure updates are detected immediately
res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Expires', '0');
res.sendFile(path.join(distPath, 'index.html'));
});

View File

@ -23,22 +23,7 @@ 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' });
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);
res.json(rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
@ -47,14 +32,14 @@ router.get('/assets/:id', async (req, res) => {
// Create Asset
router.post('/assets', async (req, res) => {
const { id, parent_id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity } = req.body;
const { id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body;
try {
const sql = `
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO assets (id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
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]);
await db.query(sql, [id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url]);
res.status(201).json({ message: 'Asset created', id });
} catch (err) {
console.error(err);
@ -64,15 +49,15 @@ router.post('/assets', async (req, res) => {
// Update Asset
router.put('/assets/:id', async (req, res) => {
const { parent_id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity } = req.body;
const { name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body;
try {
const sql = `
UPDATE assets
SET parent_id=?, name=?, category=?, model_name=?, serial_number=?, manufacturer=?, location=?, purchase_date=?, manager=?, status=?, specs=?, purchase_price=?, image_url=?, quantity=?
SET name=?, category=?, model_name=?, serial_number=?, manufacturer=?, location=?, purchase_date=?, manager=?, status=?, specs=?, purchase_price=?, image_url=?
WHERE 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]);
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]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Asset not found' });
res.json({ message: 'Asset updated' });
@ -294,46 +279,4 @@ 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,21 +3,12 @@ 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('cctv'), async (req, res) => {
router.get('/', requireModule('monitoring'), async (req, res) => {
try {
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);
const [rows] = await db.query('SELECT * FROM camera_settings ORDER BY display_order ASC, created_at DESC');
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
@ -41,7 +32,7 @@ router.put('/reorder', hasRole('admin'), async (req, res) => {
const id = typeof cam === 'object' ? cam.id : cam;
const order = i;
await db.query('UPDATE cctv_settings SET display_order = ? WHERE id = ?', [order, id]);
await db.query('UPDATE camera_settings SET display_order = ? WHERE id = ?', [order, id]);
}
await db.query('COMMIT');
@ -52,73 +43,13 @@ 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 cctv_settings WHERE id = ?', [req.params.id]);
const [rows] = await db.query('SELECT * FROM camera_settings WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' });
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);
res.json(rows[0]);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
@ -127,13 +58,10 @@ router.get('/:id', async (req, res) => {
// Add camera (Admin only)
router.post('/', hasRole('admin'), async (req, res) => {
const { name, zone, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
const { name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
try {
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']);
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']);
res.status(201).json({ message: 'Camera added', id: result.insertId });
} catch (err) {
console.error(err);
@ -143,13 +71,10 @@ router.post('/', hasRole('admin'), async (req, res) => {
// Update camera (Admin only)
router.put('/:id', hasRole('admin'), async (req, res) => {
const { name, zone, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
const { name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
try {
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]);
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]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
// Force stream reset (kick clients to trigger reconnect)
@ -170,7 +95,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 cctv_settings SET is_active = ? WHERE id = ?', [is_active, req.params.id]);
const [result] = await db.query('UPDATE camera_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');
@ -190,7 +115,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 cctv_settings WHERE id = ?', [req.params.id]);
const [result] = await db.query('DELETE FROM camera_settings WHERE id = ?', [req.params.id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
// Stop stream
@ -210,11 +135,10 @@ 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 cctv_settings WHERE id = ?', [req.params.id]);
const [rows] = await db.query('SELECT ip_address FROM camera_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,7 +3,6 @@ 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)
@ -77,7 +76,7 @@ class StreamRelay {
if (!stream) return;
try {
const [rows] = await db.query('SELECT * FROM cctv_settings WHERE id = ?', [cameraId]);
const [rows] = await db.query('SELECT * FROM camera_settings WHERE id = ?', [cameraId]);
if (rows.length === 0) {
console.error(`Camera ${cameraId} not found`);
return;
@ -97,10 +96,8 @@ class StreamRelay {
let rtspUrl = 'rtsp://';
if (camera.username && 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;
const user = camera.rtsp_encoding ? encodeURIComponent(camera.username) : camera.username;
const pass = camera.rtsp_encoding ? encodeURIComponent(camera.password) : camera.password;
rtspUrl += `${user}:${pass}@`;
}
rtspUrl += `${camera.ip_address}:${camera.port || 554}${camera.stream_path || '/stream1'}`;

View File

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

View File

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

View File

@ -63,29 +63,24 @@ router.post('/login', async (req, res) => {
}
});
// 1.5. Check Session
// 1.5. Check Session (New)
router.get('/check', async (req, res) => {
try {
if (req.session.user) {
// Ensure CSRF token exists
// Ensure CSRF token exists, if not generate one (edge case)
if (!req.session.csrfToken) {
req.session.csrfToken = generateToken();
}
// Priority: User's individual timeout > System default
let timeoutMinutes = 60;
if (req.session.user.session_timeout) {
timeoutMinutes = parseInt(req.session.user.session_timeout);
} else {
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 10;
}
// Fetch session timeout from settings
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
res.json({
isAuthenticated: true,
user: req.session.user,
csrfToken: req.session.csrfToken,
sessionTimeout: timeoutMinutes * 60 * 1000
sessionTimeout: timeoutMinutes * 60 * 1000 // Convert to ms
});
} else {
res.json({ isAuthenticated: false });
@ -135,7 +130,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, session_timeout, 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, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
if (!rows || rows.length === 0) {
return res.json([]);
@ -159,7 +154,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, session_timeout } = req.body;
const { id, password, name, department, position, phone, role } = req.body;
if (!id || !password || !name) {
return res.status(400).json({ error: 'Missing required fields' });
@ -176,11 +171,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, session_timeout)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, password, name, department, position, phone, role)
VALUES (?, ?, ?, ?, ?, ?, ?)
`;
await db.query(sql, [id, hashedPassword, name, department, position, encryptedPhone, role || 'user', session_timeout || 10]);
await db.query(sql, [id, hashedPassword, name, department, position, encryptedPhone, role || 'user']);
res.status(201).json({ message: 'User created' });
} catch (err) {
@ -191,20 +186,41 @@ 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, session_timeout } = req.body;
const { password, name, department, position, phone, role } = req.body;
const userId = req.params.id;
try {
// ... (existing logic)
// 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
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 (session_timeout !== undefined) { updates.push('session_timeout = ?'); params.push(session_timeout); }
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 (updates.length === 0) return res.json({ message: 'No changes' });
@ -213,53 +229,7 @@ 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

@ -7,7 +7,6 @@ const fs = require('fs');
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
const { generateLicense, verifyLicense } = require('../utils/licenseManager');
const { checkRemoteKey } = require('../utils/remoteLicense');
const cryptoUtil = require('../utils/cryptoUtil');
// Load Public Key for Verification
const publicKeyPath = path.join(__dirname, '../config/public_key.pem');
@ -33,10 +32,7 @@ const ALLOWED_SETTING_KEYS = [
'asset_categories',
'asset_locations',
'asset_statuses',
'asset_maintenance_types',
'gitea_url',
'gitea_user',
'gitea_password'
'asset_maintenance_types'
];
// --- .env File Utilities ---
@ -74,7 +70,7 @@ const mysql = require('mysql2/promise');
// 0. Server Configuration (Subscriber ID & Session Timeout)
router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
try {
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key', 'gitea_url', 'gitea_user', 'gitea_password')");
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key')");
const settings = {};
rows.forEach(r => settings[r.setting_key] = r.setting_value);
@ -85,9 +81,6 @@ router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
subscriber_id: settings.subscriber_id || '',
session_timeout: parseInt(settings.session_timeout) || 60,
encryption_key: settings.encryption_key || '',
gitea_url: settings.gitea_url || 'https://gitea.qideun.com/SOKUREE/smart_ims.git',
gitea_user: settings.gitea_user || '',
gitea_password: settings.gitea_password ? cryptoUtil.decryptMasterKey(settings.gitea_password) : '',
db_config: {
host: env.DB_HOST || '',
user: env.DB_USER || '',
@ -116,16 +109,6 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) =>
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(encryption_key);
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
}
if (req.body.gitea_url !== undefined) {
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_url', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [req.body.gitea_url]);
}
if (req.body.gitea_user !== undefined) {
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_user', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [req.body.gitea_user]);
}
if (req.body.gitea_password !== undefined) {
const encryptedPass = cryptoUtil.encryptMasterKey(req.body.gitea_password);
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('gitea_password', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedPass]);
}
// Handle .env DB settings
if (db_config) {
@ -146,6 +129,7 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) =>
});
// --- Crypto & Key Rotation ---
const cryptoUtil = require('../utils/cryptoUtil');
// 0.2 Test DB Connection
router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
@ -153,20 +137,6 @@ 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,
@ -178,13 +148,7 @@ router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
await conn.query('SELECT 1');
res.json({ success: true, message: '연결 성공: 데이터베이스에 성공적으로 접속되었습니다.' });
} catch (err) {
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 });
res.status(400).json({ success: false, error: err.message });
} finally {
if (conn) await conn.end();
}
@ -270,8 +234,8 @@ router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res
try {
let stringValue = typeof value === 'string' ? value : JSON.stringify(value);
// Special handling for sensitive keys to protect it in DB
if (key === 'encryption_key' || key === 'gitea_password') {
// Special handling for encryption_key to protect it in DB
if (key === 'encryption_key') {
stringValue = cryptoUtil.encryptMasterKey(stringValue);
}
@ -294,12 +258,11 @@ router.get('/modules', isAuthenticated, async (req, res) => {
const [rows] = await db.query('SELECT * FROM system_modules');
const modules = {};
const defaults = ['asset', 'production', 'cctv'];
const defaults = ['asset', 'production', 'monitoring'];
// Get stored subscriber ID
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
// 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 : '';
const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null;
defaults.forEach(code => {
const found = rows.find(r => r.code === code);
@ -343,10 +306,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
}
// 2. Check Module match
// Allow legacy 'monitoring' licenses to activate 'cctv' module
const isMatch = result.module === code || (code === 'cctv' && result.module === 'monitoring');
if (!isMatch) {
if (result.module !== code) {
return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` });
}
@ -366,7 +326,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
}
} else {
if (result.subscriberId !== serverSubscriberId) {
return res.status(400).json({
return res.status(403).json({
error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.`
});
}
@ -399,7 +359,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
const names = {
'asset': '자산 관리',
'production': '생산 관리',
'cctv': 'CCTV'
'monitoring': 'CCTV'
};
await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]);
@ -467,136 +427,31 @@ router.post('/modules/:code/deactivate', isAuthenticated, hasRole('admin'), asyn
// --- System Update Logic ---
const getGiteaAuth = async () => {
try {
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('gitea_url', 'gitea_user', 'gitea_password')");
const settings = {};
rows.forEach(r => settings[r.setting_key] = r.setting_value);
const url = settings.gitea_url || 'https://gitea.qideun.com/SOKUREE/smart_ims.git';
if (settings.gitea_user && settings.gitea_password) {
const pass = cryptoUtil.decryptMasterKey(settings.gitea_password);
return { url, user: settings.gitea_user, pass: pass };
}
return { url, user: null, pass: null };
} catch (e) {
console.error('Failed to get Gitea auth:', e);
}
return { url: 'https://gitea.qideun.com/SOKUREE/smart_ims.git', user: null, pass: null };
};
// 5. Get Version Info (Current, Remote & History from Tags)
// 5. Get Version Info (Current & Remote)
router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => {
try {
// 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';
}
}
const packageJsonPath = path.join(__dirname, '../package.json');
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
const currentVersion = packageJson.version;
// 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) => {
// Run git fetch to update tags from remote
exec('git fetch --tags', (err) => {
if (err) {
console.error('Git fetch failed:', err);
const sanitizedError = stderr.replace(/:[^@]+@/g, ':****@');
return res.json({
current: currentVersion,
latest: null,
history: [],
error: `원격 저장소 동기화 실패: ${sanitizedError || err.message}`
error: '원격 저장소에 연결할 수 없습니다. Git 설정을 확인하세요.'
});
}
// 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=50`;
exec(historyCmd, { cwd: projectRoot }, (err, stdout, stderr) => {
const lines = stdout ? stdout.trim().split('\n') : [];
const allTags = lines.map(line => {
const [tag, subject, body, date] = line.split('|');
if (!tag) return null;
let type = 'patch';
let title = subject || '';
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];
}
const changes = (body || '')
.split('\n')
.map(c => c.trim())
.filter(c => c.startsWith('-') || c.startsWith('*'))
.map(c => c.replace(/^[-*]\s*/, ''));
return {
version: tag.replace(/^v/, ''),
date: date ? date.split(' ')[0] : '',
title: title || '업데이트',
changes: changes.length > 0 ? changes : [subject || '세부 내역 없음'],
type: type
};
}).filter(Boolean);
// 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}`);
// Get the latest tag
exec('git describe --tags $(git rev-list --tags --max-count=1)', (err, stdout) => {
const latestTag = stdout ? stdout.trim() : null;
res.json({
current: currentVersion,
latest: latestFromRemote ? `v${latestFromRemote.version}` : null,
needsUpdate: needsUpdate,
latestInfo: needsUpdate ? latestFromRemote : null,
history: history
latest: latestTag,
needsUpdate: latestTag ? (latestTag.replace(/^v/, '') !== currentVersion.replace(/^v/, '')) : false
});
});
});
@ -615,165 +470,37 @@ router.post('/version/update', isAuthenticated, hasRole('admin'), async (req, re
return res.status(400).json({ error: '업데이트할 대상 태그가 지정되지 않았습니다.' });
}
const auth = await getGiteaAuth();
const env = readEnv();
// 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 updateScript = `
git checkout ${targetTag} &&
npm install &&
npm run build &&
cd server &&
npm install &&
pm2 reload smartims-api
`;
// Note: On Windows, we might need a different script or use a shell
const isWindows = process.platform === 'win32';
const backupDir = isWindows ? './backup' : '/volume1/backup/smart_ims';
const shellCommand = isWindows ? `powershell.exe -Command "${updateScript.replace(/\n/g, '')}"` : updateScript;
// 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(`🚀 Starting system update to ${targetTag}...`);
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'
exec(shellCommand, { cwd: path.join(__dirname, '../..') }, (err, stdout, stderr) => {
if (err) {
console.error('❌ Update Failed:', err);
console.error(stderr);
return;
}
);
child.unref(); // Allow node process to exit/reload without killing this child
console.log('✅ Update completed successfully.');
console.log(stdout);
});
res.json({
success: true,
message: '업데이트 프로세스가 독립적으로 실행되었습니다. 시스템이 곧 재시작됩니다.'
message: '업데이트 프로세스가 백그라운드에서 시작되었습니다. 약 1~3분 후 시스템이 재시작됩니다.'
});
});

View File

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

View File

@ -1,27 +1,23 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from '../shared/auth/AuthContext';
import { SystemProvider } from '../shared/context/SystemContext';
import { AuthGuard } from '../shared/auth/AuthGuard';
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
import { ModuleGuard } from '../shared/auth/ModuleGuard';
import { MainLayout } from '../widgets/layout/MainLayout';
import { LoginPage } from '../pages/auth/LoginPage';
// Modules
import { assetModule } from '../modules/asset/module';
import { cctvModule } from '../modules/cctv/module';
import { productionModule } from '../modules/production/module';
import ModuleLoader from '../platform/ModuleLoader';
// Platform / System Pages
import { DashboardPage } from '../modules/asset/pages/DashboardPage';
import { AssetListPage } from '../modules/asset/pages/AssetListPage';
import { AssetRegisterPage } from '../modules/asset/pages/AssetRegisterPage';
import { AssetSettingsPage } from '../modules/asset/pages/AssetSettingsPage';
import { AssetDetailPage } from '../modules/asset/pages/AssetDetailPage';
import { UserManagementPage } from '../platform/pages/UserManagementPage';
import { BasicSettingsPage } from '../platform/pages/BasicSettingsPage';
import { VersionPage } from '../platform/pages/VersionPage';
import { ProductionPage } from '../production/pages/ProductionPage';
import { MonitoringPage } from '../modules/cctv/pages/MonitoringPage';
import { AuthProvider } from '../shared/auth/AuthContext';
import { AuthGuard } from '../shared/auth/AuthGuard';
import { SystemProvider } from '../shared/context/SystemContext';
import { LicensePage } from '../system/pages/LicensePage';
import '../platform/styles/global.css';
const modules = [assetModule, cctvModule, productionModule];
export function App() {
function App() {
return (
<AuthProvider>
<SystemProvider>
@ -33,20 +29,39 @@ export function App() {
{/* Protected Routes */}
<Route element={
<AuthGuard>
<MainLayout modulesList={modules} />
<MainLayout />
</AuthGuard>
}>
{/* Dynamic Module Routes */}
<Route path="/*" element={<ModuleLoader modules={modules} />} />
{/* Asset Management Routes */}
<Route element={<ModuleGuard moduleCode="asset"><Outlet /></ModuleGuard>}>
<Route path="/" element={<Navigate to="/asset/dashboard" replace />} />
<Route path="/asset/dashboard" element={<DashboardPage />} />
<Route path="/asset/register" element={<AssetRegisterPage />} />
<Route path="/asset/list" element={<AssetListPage />} />
<Route path="/asset/facilities" element={<AssetListPage />} />
<Route path="/asset/tools" element={<AssetListPage />} />
<Route path="/asset/general" element={<AssetListPage />} />
<Route path="/asset/settings" element={<AssetSettingsPage />} />
<Route path="/asset/detail/:assetId" element={<AssetDetailPage />} />
<Route path="/asset/consumables" element={<AssetListPage />} />
<Route path="/asset/instruments" element={<AssetListPage />} />
<Route path="/asset/vehicles" element={<AssetListPage />} />
</Route>
{/* Navigation Fallback within Layout */}
<Route index element={<Navigate to="/asset/dashboard" replace />} />
{/* Production Management Routes */}
<Route element={<ModuleGuard moduleCode="production"><Outlet /></ModuleGuard>}>
<Route path="/production/dashboard" element={<ProductionPage />} />
</Route>
{/* Platform Admin Routes */}
{/* Monitoring Routes */}
<Route element={<ModuleGuard moduleCode="monitoring"><Outlet /></ModuleGuard>}>
<Route path="/monitoring" element={<MonitoringPage />} />
</Route>
{/* Admin Routes */}
<Route path="/admin/users" element={<UserManagementPage />} />
<Route path="/admin/settings" element={<BasicSettingsPage />} />
<Route path="/admin/license" element={<LicensePage />} />
<Route path="/admin/version" element={<VersionPage />} />
<Route path="/admin" element={<Navigate to="/admin/settings" replace />} />
</Route>

View File

@ -18,7 +18,6 @@ export interface IModuleRoute {
*/
export interface IModuleDefinition {
moduleName: string; // 紐⑤뱢 ?앸퀎??(?? 'asset-management')
label?: string; // 紐⑤뱢 ?쒖떆 紐낆묶 (?? 'CCTV')
basePath: string; // 紐⑤뱢??湲곕낯 寃쎈줈 (?? '/asset')
routes: IModuleRoute[]; // 紐⑤뱢 ?대? ?쇱슦???뺣낫
requiredRoles?: string[]; // 紐⑤뱢 ?묎렐???꾪븳 ?꾩슂 沅뚰븳 (Optional)

View File

@ -7,53 +7,16 @@ import { assetApi } from '../../../shared/api/assetApi';
import type { Asset } from '../../../shared/api/assetApi';
import { SERVER_URL } from '../../../shared/api/client';
import { createPortal } from 'react-dom';
import { useAuth } from '../../../shared/auth/AuthContext';
interface AssetBasicInfoProps {
asset: Asset & { image?: string, accessories?: any[] };
asset: Asset & { image?: string, consumables?: any[] };
onRefresh: () => void;
}
export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
const { user } = useAuth();
// User role is now allowed to edit assets
const canEdit = user?.role === 'admin' || user?.role === 'supervisor' || user?.role === 'user';
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>
@ -97,28 +60,6 @@ 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' }}>
@ -128,9 +69,9 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
<Button size="sm" onClick={handleSave} icon={<Save size={16} />}></Button>
</>
) : (
canEdit && <Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}></Button>
<Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}></Button>
)}
<Button variant="secondary" size="sm" icon={<Printer size={16} />} onClick={() => window.print()}></Button>
<Button variant="secondary" size="sm" icon={<Printer size={16} />}></Button>
</div>
<Card className="content-card print-friendly">
@ -382,202 +323,51 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
</div>
) : (
<div className="w-full !h-14 flex items-center px-2 border-0 !border-none">
<span className={`badge ${editData.status === 'active' ? 'badge-success' : editData.status === 'disposed' ? 'badge-neutral' : 'badge-warning'} !text-lg !font-medium px-4 py-2`}>
<span className={`badge ${editData.status === 'active' ? 'badge-success' : editData.status === 'disposed' ? 'badge-neutral' : 'badge-warning'} !text - lg!font - medium px - 4 py - 2`}>
{editData.status === 'active' ? '정상 가동' : editData.status === 'disposed' ? '폐기 (말소)' : editData.status === 'maintain' ? '점검 중' : '수리 필요'}
</span>
</div>
)}
</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>
{/* 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} />} 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>
{/* Consumables Section */}
<div className="consumables-section">
<div className="flex justify-between items-center mb-2">
<h3 className="section-title text-lg font-bold"> </h3>
<Button size="sm" variant="secondary" icon={<Plus size={14} />}> </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>
</tr>
</thead>
<tbody>
{asset.consumables?.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">
<button className="text-slate-400 hover:text-red-500 text-sm underline"></button>
</td>
</tr>
</thead>
<tbody>
{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.quantity}</td>
<td className="border border-slate-300 p-2">
<button
onClick={() => handleDeleteAccessory(item.id)}
className="text-slate-400 hover:text-red-500 text-sm underline"
>
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={4} className="p-8 text-slate-400"> .</td>
</tr>
)}
</tbody>
</table>
</div>
)}
))}
</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

@ -22,5 +22,5 @@ export const assetModule: IModuleDefinition = {
{ path: '/instruments', element: <AssetListPage />, label: '계측기 관리', position: 'top' },
{ path: '/vehicles', element: <AssetListPage />, label: '차량 관리', position: 'top' },
],
requiredRoles: ['admin', 'manager', 'user']
requiredRoles: ['admin', 'manager']
};

View File

@ -3,8 +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, Printer } from 'lucide-react';
import { useAuth } from '../../../shared/auth/AuthContext';
import { Search, Plus, Filter, Download, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight } from 'lucide-react';
import './AssetListPage.css';
import { getCategories } from './AssetSettingsPage';
@ -20,9 +19,6 @@ import { saveAs } from 'file-saver';
export function AssetListPage() {
const location = useLocation();
const navigate = useNavigate();
const { user } = useAuth();
// User role is now allowed to register assets
const canRegister = user?.role === 'admin' || user?.role === 'supervisor' || user?.role === 'user';
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [assets, setAssets] = useState<Asset[]>([]);
@ -280,11 +276,8 @@ 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>
)}
<Button onClick={() => navigate('/asset/register')} icon={<Plus size={16} />}> </Button>
{/* Filter Popup */}
{isFilterOpen && (

View File

@ -19,7 +19,6 @@ export function AssetRegisterPage() {
const [formData, setFormData] = useState({
id: '', // Asset ID (Auto-generated)
parentId: '',
name: '',
categoryId: '', // Use ID for selection
model: '',
@ -34,20 +33,6 @@ 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
@ -121,7 +106,6 @@ 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,
@ -145,8 +129,6 @@ export function AssetRegisterPage() {
}
};
const isFacility = categories.find(c => c.id === formData.categoryId)?.name === '설비';
return (
<div className="page-container">
<div className="page-header-right">
@ -173,22 +155,6 @@ 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,16 +1,11 @@
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: 'cctv',
label: 'CCTV',
moduleName: 'monitoring-cctv',
basePath: '/monitoring',
routes: [
{ path: '/live', element: <MonitoringPage />, label: '실시간 관제' },
{ path: '/manage', element: <CameraManagementPage />, label: '장치 관리' },
{ path: '/settings', element: <CctvSettingsPage />, label: '기본 설정' },
],
requiredRoles: ['admin', 'operator', 'user']
requiredRoles: ['admin', 'operator']
};

View File

@ -1,392 +0,0 @@
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

@ -1,147 +0,0 @@
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,8 +1,7 @@
import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { useState, useEffect } from 'react';
import { apiClient } from '../../../shared/api/client';
import { JSMpegPlayer } from '../components/JSMpegPlayer';
import { Video, LayoutGrid, ChevronDown } from 'lucide-react';
import { Plus, Settings, Trash2, X, Video } 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';
@ -21,7 +20,6 @@ interface Camera {
quality?: 'low' | 'medium' | 'original';
display_order?: number;
is_active?: boolean | number;
zone?: string;
}
// Wrap Camera Card with Sortable
@ -40,7 +38,7 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr
};
return (
<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`}>
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="bg-white rounded-xl shadow-lg overflow-hidden border border-slate-200">
{children}
</div>
);
@ -49,12 +47,22 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr
export function MonitoringPage() {
const { user } = useAuth();
const [cameras, setCameras] = useState<Camera[]>([]);
const [activeZone, setActiveZone] = useState<string>('');
const [availableZones, setAvailableZones] = useState<{ name: string, layout: string }[]>([]);
const [showLayoutMenu, setShowLayoutMenu] = useState(false);
const fetchedRef = useRef(false);
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 [streamVersions] = useState<{ [key: number]: number }>({});
const [streamVersions, setStreamVersions] = useState<{ [key: number]: number }>({});
const sensors = useSensors(
useSensor(PointerSensor),
@ -64,8 +72,6 @@ export function MonitoringPage() {
);
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
fetchCameras();
}, []);
@ -78,49 +84,61 @@ export function MonitoringPage() {
}
};
const fetchZones = async () => {
try {
const res = await apiClient.get('/cameras/zones');
const zones = res.data.map((z: any) => ({
name: z.name,
layout: z.layout || '2*2'
}));
setAvailableZones(zones);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// Default to the first available zone to show video immediately
if (!activeZone && zones.length > 0) {
setActiveZone(zones[0].name);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
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);
}
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 fetch zones', 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);
}
};
// 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 handleEdit = (camera: Camera) => {
setEditingCamera(camera);
setFormData(camera);
setShowForm(true);
};
const handleDelete = async (id: number) => {
if (!window.confirm('정말 삭제하시겠습니까?')) return;
try {
await apiClient.patch(`/cameras/zones/${activeZone}/layout`, { layout: newLayout });
await apiClient.delete(`/cameras/${id}`);
fetchCameras();
} catch (err) {
console.error('Failed to update layout', err);
// Revert or show alert if needed
console.error('Failed to delete camera', err);
}
};
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;
@ -208,186 +226,185 @@ export function MonitoringPage() {
useEffect(() => {
fetchCameras();
fetchZones();
checkServerVersion();
}, []);
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
key={name}
onClick={() => setActiveZone(name)}
className={`px-4 py-2 text-[13px] font-bold transition-all border-b-2 whitespace-nowrap ${activeZone === name
? 'text-indigo-600 border-indigo-600 bg-indigo-50/20'
: 'text-slate-400 border-transparent hover:text-slate-600'
}`}
>
{name}
</button>
))}
</div>
{/* Spacer to push everything else to the right */}
<div className="flex-1" />
{/* Right Area: Layout Selector */}
<div className="px-3 pointer-events-auto flex items-center">
<div className="relative">
<button
onClick={() => setShowLayoutMenu(!showLayoutMenu)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border ${showLayoutMenu ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-slate-50 text-slate-600 border-slate-200 hover:border-indigo-400'} ${!activeZone ? 'opacity-50 grayscale cursor-not-allowed' : ''}`}
disabled={!activeZone}
>
<LayoutGrid size={14} />
: {!activeZone ? '--' : viewLayout}
<ChevronDown size={14} className={`transition-transform duration-200 ${showLayoutMenu ? 'rotate-180' : ''}`} />
</button>
{showLayoutMenu && activeZone && (
<div className="absolute right-0 top-full mt-2 w-48 bg-[#1a1a1a] rounded-xl shadow-2xl border border-white/10 p-2 z-[100] animate-in fade-in zoom-in duration-200">
<div className="px-2 py-1.5 text-[9px] font-bold text-white/30 uppercase tracking-[0.2em] border-b border-white/5 mb-1">
Monitor Arrangement
</div>
<div className="grid grid-cols-2 gap-1">
{layoutOptions.map(opt => (
<button
key={opt.id}
onClick={() => handleLayoutChange(opt.id)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-[11px] font-medium transition-all ${viewLayout === opt.id ? 'bg-indigo-600 text-white' : 'text-white/60 hover:bg-white/5 hover:text-white'}`}
>
<div className="w-4 flex justify-center opacity-60">{opt.icon}</div>
{opt.label}
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>,
portalRoot
);
};
const getLayoutInfo = () => {
if (viewLayout === '1') return { cols: 'grid-cols-1', total: 1 };
if (viewLayout === '1*2') return { cols: 'grid-cols-2', total: 2 };
// Multi-grid like 2*2, 3*3, 4*4
const parts = viewLayout.split('*');
if (parts.length === 2) {
const n = parseInt(parts[0]);
return { cols: `grid-cols-${n}`, total: n * n };
}
return { cols: 'grid-cols-2', total: 4 };
};
const layoutInfo = getLayoutInfo();
const totalSlots = Math.max(layoutInfo.total, filteredCameras.length);
// Fill slots: actual cameras + empty placeholders if needed to complete the grid rows
const slots = Array.from({ length: totalSlots }).map((_, i) => filteredCameras[i] || null);
return (
<div className="w-full h-full bg-[#0a0a0a] flex flex-col min-h-0">
<HeaderDropdown />
<div className="flex-1 p-2 overflow-hidden flex flex-col min-h-0">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={filteredCameras.map(c => c.id)}
strategy={rectSortingStrategy}
disabled={true}
<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' && (
<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"
>
<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>
<Plus size={18} />
</button>
)}
</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 object-contain"
/>
</div>
{/* Small persistent ID or Name overlay */}
<div className="absolute bottom-2 left-2 px-2 py-0.5 bg-black/60 rounded text-[10px] text-white/30 font-mono pointer-events-none border border-white/5">
CH {index + 1} | {camera.ip_address}
</div>
</div>
</SortableCamera>
) : (
<div className="h-full w-full bg-[#121212] border border-slate-900/50 flex flex-col items-center justify-center text-slate-700 select-none">
<Video size={32} className="opacity-10 mb-2" />
<span className="text-[11px] font-bold opacity-20 uppercase tracking-widest">No Buffer</span>
{/* Camera Grid with DnD */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={cameras.map(c => c.id)}
strategy={rectSortingStrategy}
>
<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'}>
<div className="relative aspect-video bg-black group">
<JSMpegPlayer
key={`${camera.id}-${streamVersions[camera.id] || 0}`}
url={getStreamUrl(camera.id)}
className="w-full h-full"
/>
{/* Overlay Controls */}
{user?.role === 'admin' && (
<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 */}
</div>
</div>
))}
</SortableCamera>
))}
</div>
</SortableContext>
</DndContext>
{cameras.length === 0 && (
<div className="text-center py-20 text-slate-500">
<Video size={48} className="mx-auto mb-4 opacity-50" />
<p> . .</p>
</div>
)}
{/* Add/Edit Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6 animate-in fade-in zoom-in duration-200">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold">
{editingCamera ? '카메라 설정 수정' : '새 카메라 추가'}
</h2>
<button onClick={() => setShowForm(false)} className="text-slate-400 hover:text-slate-600">
<X size={24} />
</button>
</div>
</SortableContext>
</DndContext>
</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

@ -7,5 +7,5 @@ export const productionModule: IModuleDefinition = {
routes: [
{ path: '/dashboard', element: <ProductionPage />, label: '생산 현황' },
],
requiredRoles: ['admin', 'manager', 'user']
requiredRoles: ['admin', 'manager']
};

View File

@ -61,8 +61,6 @@ export function LoginPage() {
placeholder="아이디를 입력하세요"
required
autoComplete="one-time-code"
readOnly
onFocus={(e) => e.target.readOnly = false}
/>
</div>
</div>
@ -79,8 +77,6 @@ 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, useAuth } from '../shared/auth/AuthContext';
import { AuthProvider } from '../shared/auth/AuthContext';
import { SystemProvider } from '../shared/context/SystemContext';
import { AuthGuard } from '../shared/auth/AuthGuard';
import { MainLayout } from '../widgets/layout/MainLayout';
@ -16,19 +16,11 @@ 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>
@ -44,17 +36,11 @@ 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={<DefaultRedirect />} />
<Route index element={<Navigate to="/asset/dashboard" replace />} />
{/* Platform Admin Routes (Could also be moved to an Admin module later) */}
<Route path="/admin/users" element={<UserManagementPage />} />

View File

@ -33,12 +33,9 @@ export const ModuleLoader = ({ modules }: ModuleLoaderProps) => {
if (!isActive) return null;
// 2. 권한 체크 (Role 기반)
if (module.requiredRoles && user) {
// supervisor는 모든 권한을 가짐
if (user.role !== 'supervisor' && !module.requiredRoles.includes(user.role)) {
return null;
}
// 2. 沅뚰븳 泥댄겕 (Role 湲곕컲)
if (module.requiredRoles && user && !module.requiredRoles.includes(user.role)) {
return null;
}
return (

View File

@ -1,18 +1,15 @@
import { useState, useEffect, useRef } from 'react';
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 { useAuth } from '../../shared/auth/AuthContext';
import { Save, Database, Server, CheckCircle2, AlertCircle, Key, RefreshCcw, ShieldAlert, Lock, Unlock, Clock } from 'lucide-react';
import { Save, Clock, Database, Server, CheckCircle2, AlertCircle, Key, RefreshCcw, ShieldAlert, Lock, Unlock } from 'lucide-react';
interface SystemSettings {
session_timeout: number;
encryption_key: string;
subscriber_id: string;
gitea_url: string;
gitea_user: string;
gitea_password: string;
db_config: {
host: string;
user: string;
@ -28,9 +25,6 @@ export function BasicSettingsPage() {
session_timeout: 60,
encryption_key: '',
subscriber_id: '',
gitea_url: '',
gitea_user: '',
gitea_password: '',
db_config: {
host: '',
user: '',
@ -45,7 +39,6 @@ export function BasicSettingsPage() {
const [saveResults, setSaveResults] = useState<{ [key: string]: { success: boolean; message: string } | null }>({
security: null,
encryption: null,
repository: null,
database: null
});
@ -58,18 +51,14 @@ export function BasicSettingsPage() {
// Supervisor Protection States
const [isDbConfigUnlocked, setIsDbConfigUnlocked] = useState(false);
const [isEncryptionUnlocked, setIsEncryptionUnlocked] = useState(false);
const [isRepoUnlocked, setIsRepoUnlocked] = useState(false);
const [verifyingTarget, setVerifyingTarget] = useState<'database' | 'encryption' | 'repository' | null>(null);
const [verifyingTarget, setVerifyingTarget] = useState<'database' | 'encryption' | null>(null);
const [showVerifyModal, setShowVerifyModal] = useState(false);
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();
}, []);
@ -151,18 +140,14 @@ export function BasicSettingsPage() {
setTestResult({ success: true, message: res.data.message });
setIsDbVerified(true);
} catch (error: any) {
const detailedError = error.response?.data?.error || error.message || '알 수 없는 오류가 발생했습니다.';
setTestResult({
success: false,
message: `접속 실패: ${detailedError}`
});
setTestResult({ success: false, message: error.response?.data?.error || '접속 테스트에 실패했습니다.' });
setIsDbVerified(false);
} finally {
setTesting(false);
}
};
const handleOpenVerify = (target: 'database' | 'encryption' | 'repository') => {
const handleOpenVerify = (target: 'database' | 'encryption') => {
if (currentUser?.role !== 'supervisor') {
alert('해당 권한이 없습니다. 최고관리자(Supervisor)만 접근 가능합니다.');
return;
@ -179,7 +164,6 @@ export function BasicSettingsPage() {
if (res.data.success) {
if (verifyingTarget === 'database') setIsDbConfigUnlocked(true);
else if (verifyingTarget === 'encryption') setIsEncryptionUnlocked(true);
else if (verifyingTarget === 'repository') setIsRepoUnlocked(true);
setShowVerifyModal(false);
setVerifyPassword('');
@ -192,7 +176,7 @@ export function BasicSettingsPage() {
}
};
const handleSaveSection = async (section: 'security' | 'encryption' | 'database' | 'repository') => {
const handleSaveSection = async (section: 'security' | 'encryption' | 'database') => {
if (section === 'database' && !isDbVerified) {
alert('DB 접속 정보가 변경되었습니다. 저장 전 반드시 [연결 테스트]를 수행하십시오.');
return;
@ -203,11 +187,6 @@ export function BasicSettingsPage() {
if (section === 'security') payload = { session_timeout: settings.session_timeout };
else if (section === 'encryption') payload = { encryption_key: settings.encryption_key };
else if (section === 'repository') payload = {
gitea_url: settings.gitea_url,
gitea_user: settings.gitea_user,
gitea_password: settings.gitea_password
};
else if (section === 'database') {
if (!confirm('DB 설정을 저장하면 서버가 재시작되어 접속이 끊길 수 있습니다. 진행하시겠습니까?')) return;
payload = { db_config: settings.db_config };
@ -243,15 +222,56 @@ export function BasicSettingsPage() {
</div>
<div className="space-y-8">
{/* 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>
{/* 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>
</div>
</Card>
@ -331,85 +351,6 @@ export function BasicSettingsPage() {
)}
</Card>
{/* 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 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)
</h2>
{!isRepoUnlocked && (
<Button size="sm" variant="secondary" onClick={() => handleOpenVerify('repository')} icon={<Lock size={14} />}>
/
</Button>
)}
{isRepoUnlocked && (
<div className="flex items-center gap-2 text-emerald-700 bg-emerald-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-emerald-200">
<Unlock size={14} /> 완료: 최고관리자
</div>
)}
</div>
{isRepoUnlocked ? (
<div className="animate-in fade-in duration-500">
<div className="p-6">
<p className="text-xs text-slate-500 mb-4"> (Gitea) .</p>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> URL (Git )</label>
<Input
value={settings.gitea_url}
onChange={e => setSettings({ ...settings, gitea_url: e.target.value })}
placeholder="https://gitea.example.com/org/repo.git"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700">Gitea ID</label>
<Input
name="gitea_username_config"
autoComplete="off"
value={settings.gitea_user}
onChange={e => setSettings({ ...settings, gitea_user: e.target.value })}
placeholder="gitea_update_user"
/>
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700">Gitea ( Token)</label>
<Input
type="password"
name="gitea_password_config"
autoComplete="new-password"
value={settings.gitea_password}
onChange={e => setSettings({ ...settings, gitea_password: e.target.value })}
placeholder="••••••••"
/>
</div>
</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.repository && (
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.repository.success ? 'text-green-600' : 'text-red-600'}`}>
{saveResults.repository.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
{saveResults.repository.message}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setIsRepoUnlocked(false)}></Button>
<Button size="sm" onClick={() => handleSaveSection('repository')} disabled={saving} icon={<Save size={14} />}> </Button>
</div>
</div>
</div>
) : (
<div className="p-12 text-center text-slate-400 bg-slate-50/50">
<Lock className="mx-auto mb-3 opacity-20" size={48} />
<p className="text-sm font-medium"> .</p>
</div>
)}
</Card>
{/* Section 3: Database Infrastructure (Supervisor Protected) */}
<Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
<div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
@ -456,14 +397,7 @@ export function BasicSettingsPage() {
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"></label>
<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); }}
/>
<Input type="password" value={settings.db_config.password} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }} />
</div>
</div>
</div>
@ -523,7 +457,7 @@ export function BasicSettingsPage() {
<Card className="w-full max-w-md shadow-2xl border-indigo-200 overflow-hidden">
<div className={`p-6 text-white ${verifyingTarget === 'encryption' ? 'bg-red-600' : 'bg-indigo-600'}`}>
<h3 className="text-lg font-bold flex items-center gap-2">
<ShieldAlert size={20} /> ({verifyingTarget === 'encryption' ? '암호화 키' : verifyingTarget === 'repository' ? '저장소 설정' : '인프라 설정'})
<ShieldAlert size={20} /> ({verifyingTarget === 'encryption' ? '암호화 키' : '인프라 설정'})
</h3>
<p className="text-white/80 text-sm mt-1"> .</p>
</div>
@ -531,17 +465,12 @@ export function BasicSettingsPage() {
<div className="p-3 bg-red-50 border border-red-100 rounded text-xs text-red-800 leading-relaxed font-bold">
{verifyingTarget === 'encryption'
? "※ 경고: 암호화 키 변경은 시스템 내 모든 민감 데이터를 다시 암호화하는 중대한 작업입니다. 성공 시 기존 데이터를 새 키로 대체하며, 실패 시 데이터 유실의 위험이 있습니다."
: verifyingTarget === 'repository'
? "※ 주의: 시스템 업데이트 저장소 정보 노출은 원본 소스 코드 유출로 이어질 수 있습니다."
: "※ 경고: 인프라 설정은 데이터베이스 물리 접속 정보를 직접 수정하는 매우 위험한 작업입니다. 잘못된 입력은 시스템 중단으로 이어질 수 있습니다."}
: "※ 경고: 인프라 설정은 데이터베이스 물리 접속 정보를 직접 수정하는 매우 위험한 작업입니다. 잘못된 입력은 시스템 중단으로 이어질 수 있습니다."}
</div>
<div>
<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

@ -1,157 +0,0 @@
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

@ -1,275 +0,0 @@
.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

@ -1,118 +0,0 @@
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, useRef } from 'react';
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, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw, Printer } from 'lucide-react';
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw } from 'lucide-react';
import type { User } from '../../shared/auth/AuthContext';
import { useAuth } from '../../shared/auth/AuthContext';
import './UserManagementPage.css';
@ -16,14 +16,12 @@ 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);
@ -35,13 +33,10 @@ export function UserManagementPage() {
department: '',
position: '',
phone: '',
role: 'user',
session_timeout: 10
role: 'user'
});
useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
fetchUsers();
}, []);
@ -66,8 +61,7 @@ export function UserManagementPage() {
department: '',
position: '',
phone: '',
role: 'user',
session_timeout: 10
role: 'user'
});
setIsEditing(false);
setIsModalOpen(true);
@ -81,8 +75,7 @@ export function UserManagementPage() {
department: user.department || '',
position: user.position || '',
phone: user.phone || '',
role: user.role,
session_timeout: (user as any).session_timeout || 10
role: user.role
});
setIsEditing(true);
setIsModalOpen(true);
@ -156,10 +149,7 @@ 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>
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}> </Button>
</div>
<Card className="overflow-hidden shadow-sm border-slate-200">
@ -170,7 +160,6 @@ 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>
@ -197,9 +186,6 @@ 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() : '미접속'}
@ -242,10 +228,6 @@ 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}
@ -259,10 +241,6 @@ 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 })}
@ -287,14 +265,17 @@ 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"></option>
<option value="supervisor"> (Supervisor)</option>
)}
</select>
{currentUser?.role !== 'supervisor' && (
@ -320,29 +301,14 @@ 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
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
placeholder="010-0000-0000"
maxLength={13}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
() <span className="text-red-500">*</span>
</label>
<Input
type="number"
value={formData.session_timeout}
onChange={(e) => setFormData({ ...formData, session_timeout: parseInt(e.target.value) || 0 })}
min={5}
placeholder="기본 10"
required
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> </label>
<Input
value={formData.phone}
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
placeholder="010-0000-0000"
maxLength={13}
/>
</div>
<div className="pt-4 flex justify-end gap-2">

View File

@ -1,31 +1,18 @@
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, ChevronLeft, ChevronRight } from 'lucide-react';
import { Info, Cpu, Database, Server, Hash, Calendar, RefreshCw, AlertTriangle, CheckCircle2 } from 'lucide-react';
interface VersionInfo {
status: string;
version: string;
node_version: string;
platform: string;
arch: string;
timestamp: string;
}
interface UpdateEntry {
version: string;
date: string;
title: string;
changes: string[];
type: 'feature' | 'fix' | 'urgent' | 'patch';
}
interface RemoteVersion {
current: string;
latest: string | null;
needsUpdate: boolean;
latestInfo: UpdateEntry | null;
history: UpdateEntry[];
error?: string;
}
@ -36,14 +23,11 @@ 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);
try {
// Add timestamp to prevent caching
const res = await apiClient.get(`/health?t=${Date.now()}`);
const res = await apiClient.get('/health');
setHealthInfo(res.data);
} catch (err) {
console.error('Failed to fetch version info', err);
@ -55,13 +39,12 @@ export function VersionPage() {
const fetchRemoteVersion = async () => {
setCheckingRemote(true);
try {
const res = await apiClient.get(`/system/version/remote?t=${Date.now()}`);
const res = await apiClient.get('/system/version/remote');
setRemoteInfo(res.data);
} catch (err) {
console.error('Failed to fetch remote version info', err);
} finally {
setCheckingRemote(false);
setCurrentPage(1);
}
};
@ -73,51 +56,28 @@ export function VersionPage() {
const handleUpdate = async () => {
if (!remoteInfo?.latest) return;
const info = remoteInfo.latestInfo;
const changelog = info ? `\n\n[주요 변경 내역]\n${info.changes.map(c => `- ${c}`).join('\n')}` : '';
if (!confirm(`시스템을 v${info?.version || remoteInfo.latest} 버전으로 업데이트하시겠습니까?${changelog}\n\n업데이트 중에는 시스템이 일시적으로 중단될 수 있습니다.`)) {
if (!confirm(`시스템을 ${remoteInfo.latest} 버전으로 업데이트하시겠습니까?\n업데이트 중에는 시스템이 일시적으로 중단될 수 있습니다.`)) {
return;
}
setUpdating(true);
setUpdateResult(null);
try {
const res = await apiClient.post('/system/version/update', { targetTag: remoteInfo.latest });
setUpdateResult({ success: true, message: res.data.message });
// Success: Wait a bit for server to settle, then refresh
let countdown = 5;
const timer = setInterval(() => {
countdown -= 1;
if (countdown <= 0) {
clearInterval(timer);
window.location.reload();
} else {
setUpdateResult({
success: true,
message: `${res.data.message} (${countdown}초 후 페이지가 새로고침됩니다.)`
});
}
}, 1000);
} catch (err: any) {
console.error('Update failed', err);
setUpdateResult({
success: false,
message: err.response?.data?.error || '업데이트 요청 중 오류가 발생했습니다.'
});
} finally {
setUpdating(false);
}
};
// Source of truth for versioning comes from the API
const currentVersion = remoteInfo?.current || healthIcon?.version || '0.4.0.0';
const buildDate = '2026-01-25';
// IMPORTANT: needsUpdate should be true if remote has a higher version than local
const needsUpdate = !!remoteInfo?.needsUpdate;
// Use values from healthIcon if available, otherwise fallback to local constants
const currentVersion = healthIcon?.version || '0.2.6';
const buildDate = '2026-01-24';
return (
<div className="page-container p-6 max-w-4xl mx-auto">
@ -136,48 +96,25 @@ export function VersionPage() {
</button>
</div>
{/* 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} />
{/* Update Alert Banner */}
{remoteInfo?.needsUpdate && !updateResult && (
<div className="mb-8 p-4 bg-amber-50 border border-amber-200 rounded-xl flex items-center justify-between animate-in fade-in slide-in-from-top-4 duration-500">
<div className="flex items-center gap-3">
<div className="p-2 bg-amber-100 text-amber-600 rounded-lg">
<AlertTriangle size={20} />
</div>
<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-indigo-600 text-white'}`}>
{remoteInfo.latestInfo.type}
</span>
</div>
<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>
{/* 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>
))}
</ul>
</div>
<div>
<h4 className="font-bold text-amber-900"> !</h4>
<p className="text-sm text-amber-700"> 버전: v{currentVersion} : <span className="font-bold">{remoteInfo.latest}</span></p>
</div>
</div>
<button
onClick={handleUpdate}
disabled={updating}
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'}`}
className="px-4 py-2 bg-amber-600 text-white rounded-lg font-bold text-sm hover:bg-amber-700 transition-colors shadow-sm disabled:opacity-50 flex items-center gap-2"
>
{updating ? <RefreshCw size={22} className="animate-spin" /> : null}
{updating ? '설치 중...' : '지금 업데이트'}
{updating ? <RefreshCw size={16} className="animate-spin" /> : null}
{updating ? '업데이트 중...' : '지금 업데이트'}
</button>
</div>
)}
@ -239,25 +176,19 @@ 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"><Cpu size={14} /> </span>
<span className="font-medium text-slate-700 text-sm">
{loading ? 'Checking...' : healthIcon?.node_version ? `Node.js ${healthIcon.node_version}` : 'N/A'}
</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"><Server size={14} /> </span>
<span className="font-medium text-slate-700 text-sm uppercase">
{loading ? '...' : healthIcon?.platform ? `${healthIcon.platform} (${healthIcon.arch})` : 'Unknown'}
<span className="text-slate-500 text-sm flex items-center gap-2"><Info size={14} /> API </span>
<span className="font-bold text-emerald-600 bg-emerald-50 px-3 py-1 rounded-full text-xs">
{loading ? 'Checking...' : healthIcon?.version ? `v${healthIcon.version}` : 'N/A'}
</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>
<span className="font-medium text-slate-700 text-sm">
<span className="font-medium text-slate-700 text-sm truncate max-w-[150px]">
{loading ? '...' : healthIcon?.timestamp || 'Unknown'}
</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-slate-500 text-sm flex items-center gap-2"><RefreshCw size={14} /> </span>
<span className="text-slate-500 text-sm flex items-center gap-2"><Server size={14} /> </span>
<span className={`font-bold text-xs uppercase ${healthIcon?.status === 'ok' ? 'text-emerald-500' : 'text-red-500'}`}>
{loading ? '...' : healthIcon?.status === 'ok' ? 'Running' : 'Offline'}
</span>
@ -286,74 +217,55 @@ export function VersionPage() {
</h2>
<div className="space-y-4">
{remoteInfo?.history && remoteInfo.history.length > 0 ? (
<>
{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'}`}>
{entry.type}
</span>
<span className="font-bold text-slate-900 font-mono text-base">v{entry.version}</span>
</div>
<div className="hidden md:block w-px h-4 bg-slate-200 mx-2"></div>
<div className="flex-1">
<h4 className="text-sm font-bold text-slate-800">{entry.title}</h4>
</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="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>
<span>{change}</span>
</li>
))}
</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>
{[
{
version: '0.2.5',
date: '2026-01-24',
title: '플랫폼 보안 모듈 및 시스템 자동 업데이트 엔진 도입',
changes: [
'Git Tag 기반 시스템 자동 업데이트 관리 모듈 신규 도입',
'최고관리자 전용 업데이트 실행 UI 구축',
'데이터베이스 및 암호화 마스터 키 자가 관리 엔진 고도화',
'사용자 관리 UI 디자인 및 권한 계층 로직 일원화'
],
type: 'feature'
},
{
version: '0.2.1',
date: '2026-01-22',
title: 'IMS 서비스 코어 성능 최적화',
changes: [
'API 서버 헬스체크 및 실시간 상태 모니터링 연동',
'보안 세션 타임아웃 유동적 처리 미들웨어 도입',
'자산 관리 모듈 초기 베타 릴리즈'
],
type: 'fix'
}
].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' : ''}`}>
<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' : 'bg-slate-200 text-slate-700'}`}>
{entry.type}
</span>
<span className="font-bold text-slate-900 font-mono text-base">v{entry.version}</span>
</div>
)}
</>
) : (
<div className="text-center py-12 text-slate-400 italic bg-slate-50 rounded-xl border border-dashed border-slate-200">
.
</div>
)}
<div className="hidden md:block w-px h-4 bg-slate-200 mx-2"></div>
<div className="flex-1">
<h4 className="text-sm font-bold text-slate-800">{entry.title}</h4>
</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">
{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>
<span>{change}</span>
</li>
))}
</ul>
</Card>
))}
</div>
</div>

View File

@ -96,143 +96,4 @@ button {
border-radius: var(--sokuree-radius-md);
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,8 +3,6 @@ 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
@ -18,15 +16,11 @@ 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;
@ -40,10 +34,8 @@ interface DBAsset {
image_url?: string;
calibration_cycle: string;
specs: string;
quantity?: number;
created_at: string;
updated_at: string;
children?: any[];
}
export interface MaintenanceRecord {
@ -89,20 +81,7 @@ export const assetApi = {
getById: async (id: string): Promise<Asset> => {
const response = await apiClient.get<DBAsset>(`/assets/${id}`);
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;
return mapDBToAsset(response.data);
},
create: (data: Partial<Asset>) => {
@ -157,27 +136,12 @@ 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 || '',
@ -190,13 +154,11 @@ const mapDBToAsset = (db: DBAsset): Asset => ({
purchasePrice: db.purchase_price,
image: db.image_url,
calibrationCycle: db.calibration_cycle || '',
specs: db.specs || '',
quantity: db.quantity
specs: db.specs || ''
});
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, useRef } from 'react';
import { createContext, useContext, useState, type ReactNode, useEffect } from 'react';
import { apiClient, setCsrfToken } from '../api/client';
export interface User {
@ -8,7 +8,6 @@ export interface User {
department?: string;
position?: string;
phone?: string;
session_timeout?: number;
last_login?: string;
}
@ -25,46 +24,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(() => {
const checkSession = async (isBackground = false) => {
if (isCheckingRef.current) return;
isCheckingRef.current = true;
let lastActivity = Date.now();
let timeoutMs = 3600000; // Default 1 hour
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) {
timeoutMsRef.current = response.data.sessionTimeout;
}
if (response.data.sessionTimeout) timeoutMs = response.data.sessionTimeout;
} else {
// Safety: only redirect if we were previously logged in
setUser(prev => {
if (prev || !isBackground) {
if (prev) {
setCsrfToken(null);
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login?expired=true';
}
window.location.href = '/login?expired=true';
return null;
}
return prev;
});
}
} catch (error) {
console.error('Session check failed', error);
setUser(prev => {
if (prev) {
setCsrfToken(null);
window.location.href = '/login?expired=true';
return null;
}
return prev;
});
} finally {
isCheckingRef.current = false;
setIsLoading(false);
}
};
const updateActivity = () => {
lastActivityRef.current = Date.now();
lastActivity = Date.now();
};
// Activity Listeners
@ -73,19 +72,25 @@ export function AuthProvider({ children }: { children: ReactNode }) {
window.addEventListener('scroll', updateActivity);
window.addEventListener('click', updateActivity);
// Initial check on mount
checkSession();
// Check activity status
// Check activity status every 30 seconds
const activityInterval = setInterval(() => {
const idleTime = Date.now() - lastActivityRef.current;
if (idleTime >= timeoutMsRef.current) {
checkSession(true);
}
// Functional check to avoid stale user closure
setUser(current => {
if (current) {
const idleTime = Date.now() - lastActivity;
if (idleTime >= timeoutMs) {
console.log('Idle timeout reached. Checking session...');
checkSession();
}
}
return current;
});
}, 30000);
// Fallback polling
const pollInterval = setInterval(() => checkSession(true), 300000);
// Fallback polling every 5 minutes (for secondary tabs etc)
const pollInterval = setInterval(checkSession, 300000);
return () => {
window.removeEventListener('mousemove', updateActivity);
@ -95,7 +100,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, useRef } from 'react';
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
import { apiClient } from '../api/client';
import { useAuth } from '../auth/AuthContext';
@ -20,7 +20,6 @@ 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 {
@ -41,12 +40,8 @@ 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, Printer } from 'lucide-react';
import { Check, X, Key, Shield, AlertTriangle, Terminal } 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: '생산 계획, 실적 및 공정 관리' },
'cctv': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' }
'monitoring': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' }
};
// Subscriber Configuration State
@ -21,13 +21,6 @@ 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();
@ -93,104 +86,48 @@ export function LicensePage() {
}
};
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);
}
};
// ... Generator logic omitted for brevity as it's dev-only ...
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>
<h2 className="text-xl font-bold flex items-center gap-2">
<Shield className="text-blue-600" />
</h2>
<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>
{!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">
ID (Server Subscriber ID)
</label>
<input
type="text"
className="w-full border border-slate-300 rounded px-3 py-2"
placeholder="예: SAMSUNG, DEMO_USER_01"
value={inputSubscriberId}
onChange={(e) => setInputSubscriberId(e.target.value)}
/>
</div>
<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] whitespace-nowrap"
>
{isConfigSaving ? '저장 중...' : '설정 저장'}
</button>
<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">
<Terminal size={20} className="text-slate-600" />
</h3>
<div className="max-w-xl">
<div className="flex items-end gap-4">
<div className="flex-1">
<label className="block text-sm font-medium text-slate-700 mb-1">
ID (Server Subscriber ID)
</label>
<input
type="text"
className="w-full border border-slate-300 rounded px-3 py-2"
placeholder="예: SAMSUNG, DEMO_USER_01"
value={inputSubscriberId}
onChange={(e) => setInputSubscriberId(e.target.value)}
/>
</div>
<p className="text-xs text-slate-500 mt-2">
* 주의: 구독자 ID를 .<br />
* ID와 .
</p>
<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]"
>
{isConfigSaving ? '저장 중...' : '설정 저장'}
</button>
</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>
)}
<p className="text-xs text-slate-500 mt-1">
* ID와 .
</p>
</div>
</div>
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
@ -314,50 +251,6 @@ 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,17 +203,11 @@
background-color: var(--sokuree-bg-card);
border-bottom: 1px solid var(--sokuree-border-color);
display: flex;
align-items: center;
padding: 0 1.5rem;
align-items: flex-end;
padding: 0 2.5rem;
color: var(--sokuree-text-primary);
justify-content: space-between;
}
.header-portal-root {
flex: 1;
display: flex;
align-items: center;
height: 100%;
justify-content: flex-end;
/* Align to right */
}
.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, Home, UserCog } from 'lucide-react';
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info } from 'lucide-react';
import type { IModuleDefinition } from '../../core/types';
import './MainLayout.css';
@ -46,15 +46,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
</div>
<nav className="sidebar-nav">
{/* 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) */}
{/* Module: System Management (Platform Core) */}
{isAdmin && (
<div className="module-group">
<button
@ -111,8 +103,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
onClick={() => toggleModule(mod.moduleName)}
>
<div className="module-title">
{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>
<Layers size={18} />
<span>{mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1)} </span>
</div>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
@ -122,14 +114,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
const groupedRoutes: Record<string, typeof mod.routes> = {};
const ungroupedRoutes: typeof mod.routes = [];
mod.routes.filter(r => {
const isLabelVisible = !!r.label;
const isSidebarPosition = !r.position || r.position === 'sidebar';
const isSettingsRoute = r.path.includes('settings');
const hasPermission = user?.role !== 'user' || !isSettingsRoute;
return isLabelVisible && isSidebarPosition && hasPermission;
}).forEach(route => {
mod.routes.filter(r => r.label && (!r.position || r.position === 'sidebar')).forEach(route => {
if (route.group) {
if (!groupedRoutes[route.group]) groupedRoutes[route.group] = [];
groupedRoutes[route.group].push(route);
@ -182,23 +167,13 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
>
<div className="module-title">
<Video size={18} />
<span>{mod.label || mod.moduleName.split('-')[0].toUpperCase()}</span>
<span>{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">
@ -262,7 +237,6 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
</div>
)}
</div>
<div id="header-portal-root" className="header-portal-root"></div>
</header>
<div className="content-area">
<Outlet />

View File

@ -1,39 +0,0 @@
@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.