Compare commits

...

50 Commits
v0.2.0 ... main

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

13
.gitignore vendored
View File

@ -22,14 +22,6 @@ dist-ssr
build build
out out
# Environment Variables (CRITICAL)
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
.env.production
# Editor directories and files # Editor directories and files
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
@ -47,10 +39,13 @@ ehthumbs.db
Desktop.ini Desktop.ini
# Project Specific - Server # Project Specific - Server
server/.env
server/.env.backup*
server/*.tmp
server/backups/
server/uploads/* server/uploads/*
!server/uploads/.gitkeep !server/uploads/.gitkeep
server/server.zip server/server.zip
server/public_key.pem
server/private_key.pem server/private_key.pem
# Project Specific - Camera/Stream # Project Specific - Camera/Stream

BIN
auth_head.js Normal file

Binary file not shown.

BIN
auth_v0.js Normal file

Binary file not shown.

BIN
auth_v1.js Normal file

Binary file not shown.

View File

@ -0,0 +1,23 @@
# ==============================================
# [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

@ -0,0 +1,74 @@
# SmartIMS 프로젝트 디렉토리 구조 (Directory Structure)
본 문서는 SmartIMS 플랫폼의 전체 프로젝트 구조와 각 디렉토리의 역할을 설명합니다.
---
## 1. 전체 구조 개요
```text
smartims/
├── docs/ # 설계서, 가이드, 매뉴얼 등 프로젝트 관련 문서
├── public/ # 파비콘, 로고 등 정적 자원
├── server/ # Node.js (Express) 기반 백엔드 서버
├── src/ # React (Vite) 기반 프론트엔드 어플리케이션
├── tools/ # 개발 및 관리에 필요한 유틸리티 도구
├── package.json # 프론트엔드 의존성 및 스크립트
├── tailwind.config.js # Tailwind CSS 설정
└── vite.config.ts # Vite 빌드 설정
```
---
## 2. 프론트엔드 구조 (`src/`)
프론트엔드는 **모듈형 아키텍처**를 따르며, 플랫폼 엔진과 독립적인 비즈니스 모듈로 구성됩니다.
### 2.1 Core & Infrastructure
- `src/core/`: 플랫폼의 핵심 인터페이스 및 공통 타입 정의 (`types.ts`).
- `src/platform/`: 시스템의 뼈대 구현.
- `App.tsx`: 메인 엔트리 및 전체 레이아웃 구성.
- `ModuleLoader.tsx`: 등록된 모듈을 동적으로 로드하고 라우팅을 구성.
- `styles/`: 전역 스타일 및 디자인 토큰(`global.css`) 관리.
- `src/app/`: Context Provider(인증, 시스템 설정 등)와 같은 전역 상태 관리.
### 2.2 Functional Components
- `src/modules/`: 독립적인 비즈니스 영역. 각 폴더는 하나의 서비스 모듈을 의미함.
- 예: `asset/`, `cctv/`, `license/` 등.
- 각 모듈은 `module.tsx` (또는 `index.tsx`)를 진입점으로 가짐.
- `src/widgets/`: 여러 페이지에서 재사용되는 복합 UI 블록 (예: `MainLayout`, `Sidebar`).
- `src/shared/`: 프로젝트 전역에서 사용되는 공통 UI 컴포넌트, 유틸리티, API 클라이언트.
---
## 3. 백엔드 구조 (`server/`)
백엔드는 RESTful API 제공 및 데이터베이스 관리를 담당합니다.
- `server/index.js`: 서버 엔트리 포인트. 환경 설정, 미들웨어 등록, 라우트 연결 총괄.
- `server/routes/`: API 엔드포인트 정의. (예: `auth.js`, `system.js`, `asset.js`).
- `server/middleware/`: 요청 처리 과정에서 동작하는 공통 로직 (인증 필터, 에러 핸들러).
- `server/configs/`: 데이터베이스 연결 (`db.js`) 및 외부 서비스 설정.
- `server/utils/`: 서버 측 공통 유틸리티 (로그 관리, 파일 업로드 등).
- `server/schema.sql`: 데이터베이스 테이블 명세 및 초기 데이터.
---
## 4. 기타 주요 디렉토리
- `docs/`:
- `operation/`: 설치 및 운영 관련 가이드 (환경 설정, 배포, 라이선스 서버 등)
- `modules/`: 모듈별 상세 기술 및 사용자 가이드 (자산 분류 기준 등)
- `design/`: 플랫폼 설계 원칙, 시스템 아키텍처 및 디렉토리 구조
- `version_control/`: Git 운영 정책 및 버전 관리 규정
- `roadmap/`: 프로젝트 통합 마스터 플랜 및 단계별 로드맵 (`INTEGRATED_ROADMAP.md`)
- `tools/`:
- 라이선스 관리 도구, 데이터 마이그레이션 스크립트 등 개발 보조 도구 포함.
---
## 5. 아키텍처 원칙 요약
1. **느슨한 결합 (Loose Coupling)**: 플랫폼은 모듈 내부 로직을 모르며, 모듈은 정의된 인터페이스를 통해 플랫폼과 통신합니다.
2. **높은 응집도 (High Cohesion)**: 특정 기능(예: 자산 관리)에 필요한 코드는 해당 모듈 폴더(`src/modules/asset/`) 내에 모여 있어야 합니다.
3. **디자인 시스템 준수**: 모든 스타일은 플랫폼이 제공하는 CSS Variables를 사용하여 일관성을 유지합니다.

View File

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

View File

@ -0,0 +1,45 @@
# SmartIMS 환경 설정 가이드 (Environment Setup Guide)
본 문서는 SmartIMS의 개발 환경(Windows)과 운영 환경(Synology NAS) 설정을 관리하기 위한 지침을 제공합니다.
---
## 1. 환경별 주요 설정 (.env)
`server/.env` 파일은 프로젝트의 핵심 설정을 담고 있습니다. 환경에 따라 아래 주석을 참고하여 값을 변경하세요.
### 1.1 데이터베이스 (MariaDB)
| 변수명 | 개발 환경 (Windows) | 운영 환경 (Synology) | 설명 |
| :--- | :--- | :--- | :--- |
| `DB_HOST` | `localhost` 또는 `sokuree.com` | `localhost` | DB 서버 주소 |
| `DB_PORT` | `3307` (Docker/XAMPP 등) | `3306` (기본값) | MariaDB 접속 포트 |
| `DB_NAME` | `sokuree_platform_dev` | `sokuree_platform_prod` | 사용할 DB 이름 |
### 1.2 시스템 및 보안
- `PORT`: 백엔드 서버 포트 (기본값: `3005`). Synology 서비스와 충돌 시 변경 가능.
- `SESSION_SECRET`: 세션 암호화 키. 운영 환경에서는 고유한 긴 문자열로 변경 권장.
- `LICENSE_MANAGER_URL`: 라이선스 서버 주소.
---
## 2. 개발 및 운영 환경 차이점 정리
### 2.1 스트리밍 전송 모드 (`CCTV_TRANSPORT_OVERRIDE`)
- **개발(Windows)**: `auto` 또는 `udp` 사용. (TCP 사용 시 권한 문제로 스트리밍이 끊길 수 있음)
- **운영(NAS)**: `tcp` 권한이 안정적이므로 `tcp` 설정을 권장합니다.
### 2.2 API 프록시 (Vite)
- 개발 시에는 `vite.config.ts``proxy` 설정을 통해 프론트엔드가 백엔드(`localhost:3005`)와 통신합니다.
- 운영 시에는 `npm run build`로 생성된 `dist` 폴더를 Node.js(Express)가 직접 서빙하므로 프록시 설정이 필요 없으며 동일 포트에서 동작합니다.
### 2.3 파일 경로 (Uploads)
- 업로드된 이미지는 `server/uploads` 디렉토리에 저장됩니다.
- 운영 환경으로 이전 시 이 디렉토리의 쓰기 권한이 Node.js 실행 계정에 있는지 확인해야 합니다.
---
## 3. 체크리스트: 자산 목록이 안 보일 때
1. **서버-DB 연결 확인**: 서버 로그(`npm start`)에서 `✅ Connected to Database` 메시지가 나오는지 확인하세요.
2. **모듈 활성화 확인**: [시스템 관리] > [모듈/라이선스 관리] 메뉴에서 '자산 관리' 모듈이 활성화 상태인지 확인하세요.
3. **구독자 ID 일치**: 라이선스 키 등록 시 사용된 구독자 ID(`SKR-2024-...`)가 [기본 설정]의 구독자 ID와 일치해야 합니다.
4. **브라우저 캐시**: 프론트엔드 빌드 후 변경 사항이 반영되지 않으면 강력 새로고침(`Ctrl + F5`)을 수행하세요.

View File

@ -0,0 +1,100 @@
# 운영 서버 배포 및 관리 가이드 (Production Deployment Guide)
본 문서는 SmartIMS 솔루션을 운영 서버에 최초 설치하는 절차와 Git Tag를 이용한 효율적인 버전 관리 및 업데이트 방역에 대해 설명합니다.
---
## 1. 개요 (Overview)
SmartIMS 배포는 **Git Tag 기반 배포** 방식을 권장합니다. 이는 검증된 특정 시점의 소스 코드(Release)만을 운영 서버에 반영하여 시스템 안정성을 확보하고, 향후 시스템 관리 메뉴를 통해 손쉽게 업데이트할 수 있는 기반을 제공합니다.
---
## 2. 최초 설치 절차 (Initial Installation)
운영 서버(Linux/Synology 등)에 접속한 상태에서 다음 과정을 순차적으로 진행합니다.
### 2.1. 사전 요구 사항
* **Git**: 소스 코드 동기화용
* **Node.js**: v20 이상 환경
* **MySQL/MariaDB**: 서비스 데이터베이스
* **PM2**: 서비스 프로세스 관리 (`npm install -g pm2`)
### 2.2. 설치 단계
1. **소스 클론**:
`/volume1/web` 위치에서 아래 명령어를 실행하면 `smartims` 폴더가 생성되며 그 안에 소스가 들어갑니다.
```bash
cd /volume1/web
# 저장소 URL 뒤에 'smartims'를 붙여 폴더명을 지정합니다.
git clone https://gitea.qideun.com/SOKUREE/smart_ims.git smartims
cd smartims
```
2. **안정 버전(Tag) 전환**:
```bash
git fetch --all --tags
# v0.2.6 버전으로 전환
git checkout v0.2.6
```
3. **환경 설정**:
* `server/.env` 파일을 환경에 맞게 생성(DB 정보 등 입력).
* `server/config/public_key.pem` 파일이 존재하는지 확인 (라이선스 검증용).
4. **패키지 설치 및 빌드**:
```bash
# 전체 의존성 설치 및 프론트엔드 빌드
npm install
npm run build
# 백엔드 의존성 설치
cd server
npm install
```
5. **데이터베이스 초기화**:
```bash
# 처음 설치 시에만 수행
node migrate_db.js
```
6. **PM2 프로세스 등록**:
```bash
pm2 start index.js --name "smartims-api"
pm2 save
pm2 startup
```
---
## 3. 업데이트 관리 (Update Management)
### 3.1. 기존 수동 업데이트 방법
수동으로 업데이트가 필요할 경우 다음 명령어를 조합하여 실행합니다.
```bash
git fetch --tags
git checkout [새로운_태그]
npm install
npm run build
pm2 reload smartims-api
```
### 3.2. 시스템 관리 메뉴를 통한 업데이트 (지원됨)
시스템의 **"시스템 관리 > 버전 정보"** 화면에서 신규 버전을 감지하고 원 클릭으로 업데이트할 수 있습니다.
* 참고: 이 기능을 사용하려면 **[기본 설정]** 메뉴에서 Gitea 원격 저장소 URL과 (필요 시) 계정 정보를 먼저 설정해야 합니다.
* **동작 원리**:
1. 서버가 설정된 원격 저장소의 최신 Tag 리스트를 확인합니다.
2. `package.json`의 현재 버전과 원격의 최신 Tag를 비교합니다.
3. 업데이트 실행 시 서버가 백그라운드에서 `git checkout` -> `npm install` -> `npm run build` -> `pm2 reload` 과정을 자동으로 수행합니다.
* **기대 효과**:
* 터미널(SSH) 접속 없이 관리자 화면에서 즉시 최신 기능 반영 가능.
* 운영 서버 환경에서도 편리한 버전 관리.
---
## 4. 주의 사항 (Precautions)
1. **보안**: `tools/` 폴더 및 개인키(`private_key.pem`)는 운영 서버에 절대로 포함되지 않도록 주의하십시오.
2. **권한**: 업데이트 자동화 스크립트 실행 시 파일 시스템 쓰기 권한 및 Git 접근 권한이 서버 프로세스에 부여되어 있어야 합니다.
3. **백업**: 업데이트 전에 데이터베이스 백업을 반드시 수행할 것을 권장합니다.
---
마지막 업데이트: 2026-01-24

View File

@ -0,0 +1,236 @@
# 🚀 SMART IMS 통합 프로젝트 로드맵 (Integrated Master Plan)
**프로젝트명:** SOKUREE Platform - Smart Integrated Management System
**최초 작성일:** 2026-01-25
**최근 업데이트:** 2026-01-25
**버전:** v0.4.1.0
---
## 📌 개요 (Overview)
본 문서는 SOKUREE 플랫폼의 핵심 성능 개선(Platform Review)과 스마트 관리 모듈(Smart Module Implementation Plan)의 구현 계획을 통합하여, 기능 구현 단계별 태그(Tag) 기반의 전체 마스터 플랜을 정의합니다.
---
## 🗺️ 구현 로드맵 (Implementation Roadmap)
### 🟦 Phase 1: 기반 강화 및 보안 고도화 (Platform Stability)
**목표:** 플랫폼의 안정성 확보 및 사용자 중심의 보안/관리 체계 구축
#### 🏷️ Tag: `v0.4.1.0` (완료)
- [x] **세션 관리 엔진 최적화**
- 세션 자동 로그아웃 시간 합산 오류 수정 (시간+분 단위 정상 동작 확인)
- 활동 부재 시 자동 로그아웃 잔여 시간 추적 로그 시스템 도입
- **(수정사항)**: 서버 요청 로그([KST])에 해당 세션의 남은 시간(`m s left`)을 실시간 표시하도록 개선 및 세션 연장 로직 최적화
-사용자가 마지막 활동 후 **설정된 시간(기본 10분)**이 경과하면, 30초 간격으로 돌아가는 감시 엔진에 의해 즉시 포착되어 로그아웃이 수행됩니다. 따라서 체감상 로그아웃이 발생하는 "최초 트리거 타임"은 [설정된 시간] + (0~30초) 이내입니다.
- [x] **사용자별 맞춤 보안 설정**
- 시스템 기본 설정의 보안/세션 을 개별 사용자의 기본설정에서 설정 가능하도록 이동
- 사용자 별 세션 만료 시간 적용가능하도록 사용자 DB에 이 설정 값을 포함하도록 재 구성
- 사용자 등록 양식 기본 값을 10분으로 설정
- 사용자 등록화면에서의 권한 명칭 수정 (최고관리자, 관리자, 사용자 로 통일)
- **(수정사항)**: `users` 테이블 스키마 확장(`session_timeout` 추가) 및 로그인 세션 생성 시 사용자별 커스텀 타임아웃 주입 엔진 구현
- [x] **CCTV 설정 추가**
- [x] 등록된 CCTV 조회 메뉴 추가하여 게시판 형으로 목록화 하고 활성(비활성)/수정/삭제 할수 있는 관리 기능 구현
- [x] CCTV 설정 파일 DB 저장 시 username/password 필드 암호화 처리
- [x] CCTV 설정 파일 조회 시 username/password 필드 복호화 처리
- **(수정사항)**: `CameraManagementPage` 신설 및 `cryptoUtil`을 통한 RTSP 계정 정보의 DB 암호화 저장 로직 완성 (AES-256)
#### 🏷️ Tag: `v0.4.2.0`
- [ ] **라이선스 관리 오류 수정**
- [ ] **시스템 관리 기본설정 오류 수정**
- [ ] **시스템 업데이트 오류 수정**
#### 🏷️ Tag: `v0.4.3.0`
- [ ] **소모품 관리**
- [ ] 이 기능은 자재/재고 관리 모듈 생성하여 메뉴가 아니라 속성(카테고리)으로 추가할 예정
- [ ] **부속설비 등록**
- [ ] 자산 등록 화면에서 부속설비 등록 버튼 클릭 시 자산 등록 화면으로 이동하도록 구현
- [ ] 부속설비 등록 버튼으로 이동 시 상위설비 자동 선택되도록 구현
- [ ] 자산 등록 화면에서 상위설비 드롭다운 메뉴에서 목록이 표시되지 않고 있음
- [ ] 상위설비 드롭다운 메뉴를 입력 텍스트 박스로 변경하고 옆에 검색 아이콘 추가
- [ ] 검색 아이콘 클릭 시 상위설비 검색 화면으로 이동
- [ ] 상위설비 검색 화면에서 검색어 입력 후 검색 버튼 클릭 시 검색 결과 목록 표시
- [ ] 검색 결과 목록에서 상위설비 선택 시 자산 등록 화면으로 이동하고 입력 텍스트 박스에 상위설비 이름 표시
- [ ] **자산관리 모듈의 현재 메뉴들을 기준정보 메뉴의 하위 메뉴로 변경**
- [ ]
- [ ] **설정 메뉴 추가**
- [ ] IoT 센서 모니터링 메뉴 추가
- [ ] 모듈 관리 메뉴 추가
- [ ] 설비 관리 메뉴 추가
- [ ]
- [ ] **시스템 관리 - 사용자 관리**
- [ ] 사용자 관리에 모듈 접근 권한을 설정할 수 있도록 구현
- [ ] 관리권한 재 검토
- [ ] 최고관리자, 관리자, 모듈 관리자, 사용자
#### 🏷️ Tag: `v0.5.0.x`
- [ ] 모듈 간 연동을 위한 API 인터페이스 정의
- [ ] 자재/재고 관리 모듈 추가
- [ ] 생산관리 모듈
- [ ] 생산 공정 메뉴
- [ ] 생산 라인 메뉴
- [ ] 생산 실적 관리 메뉴
- [ ] 품질관리 모듈
- [ ] IoT 모듈
- [ ] IoT 센서 모니터링 메뉴
- [ ] IoT 센서 데이터 수집 및 저장
- [ ] IoT 센서 데이터 시각화
- [ ] **사용자 관리 시스템 강화**
- 사용자 고유 관리번호 도입 (중복 불가)
- 프로필 이미지 필드 추가 및 업로드 기능
- 사용자 목록 UI 최적화 (페이지당 10개 제한, 네비게이션 추가)
- 복합 검색 기능 (관리번호, 아이디, 이름) 및 소속 필터 적용
---
### 🟩 Phase 2: 자산관리 MVP 및 모니터링 (Core Assets & IoT)
**목표:** 통합 자산관리의 핵심 기능 구현 및 실시간 데이터 연동 기반 구축
#### 🏷️ Tag: `v0.5.1.x`
- [ ] **자산관리 기본 모듈 (Asset CRUD)**
- 6대 카테고리(`FAC`, `TOL`, `INS`, `VEH`, `GEN`, `CSM`) 기반 관리 체계 확립
- 자산 등록/수정/삭제 웹 인터페이스 구현
- QR 코드 자동 생성 및 라벨 출력 기능
- [ ] **에너지 미터 모니터링 (Energy Meter)**
- MQTT 프로토콜 기반 실시간 데이터 수집 (qideun.com:1883)
- 전압, 전류, 전력량, 역률 시각화
- 년/월/일/시간별 누적 소비량 통계 및 차트 구현
- [ ] **CCTV 스트리밍 연동**
- 현재의 JSMpeg 기반 스트리밍 구조 유지 및 모니터링 탭 통합
---
### 🟧 Phase 3: 지능형 통합 및 품질 관리 (Smart Integration)
**목표:** 모듈 간 데이터 연쇄 작용 및 생산/품질 시스템과의 연동 고도화
#### 🏷️ Tag: `v0.6.0.x`
- [ ] **스마트 센서 매핑 및 확장 시스템**
- 설비별 임의 센서(온도, 진동 등)를 동적으로 추가할 수 있는 Sensor Mapping 기능
- 센서 임계치 도달 시 **고장 접수 자동화****예방정비(PM) 알림** 연계
- [ ] **품질-생산 연동 (Accuracy Management)**
- 계측기 검교정 후 발생하는 **보정치(Bias/Offset)** 실시간 저장
- **MES 연동**: 생산 공정 데이터 측정 시 자산 모듈의 보정 계수를 실시간 반영하여 정밀도 확보
- [ ] **예방정비-재고 자동 연동**
- 예방정비 스케줄 도래 시 필요한 소모품(`CSM`)의 재고 확인 및 자동 할당
- 소모품 안전재고(ROP) 기반 자동 발주 알림 처리
---
## 🛠️ 기술 상세 규격 (Technical Specifications)
### 1. 전력 모니터링 MQTT 규격
- **Topic:** `sokuree/home/ems` (실시간), `sokuree/home/ems/report` (통계)
- **Data Payload:**
```json
{
"node_id": "PZEM004T-R4",
"voltage": "225.20",
"current": "2.17",
"power": "408.40",
"energy": "157.94"
}
```
### 2. 고도화된 데이터 모델 (Interface)
#### 시설물 (Facility + IoT + Parts)
```typescript
interface FacilityAsset extends AssetBase {
iotConfig?: {
nodeId: string;
sensors: Array<{ type: string; field: string; threshold?: number }>;
};
linkedParts: Array<{ itemId: string; quantity: number }>; // 예방정비 소요 부품
maintenance: { nextDate: string; intervalDays: number };
}
```
#### 계측기 (Instrument + Correction)
```typescript
interface InstrumentAsset extends AssetBase {
calibration: {
nextDate: string;
correction: { bias: number; slope: number }; // 생산관리 연동용 보정치
};
}
```
---
## 📋 자산 분류 및 관리 전략
| 코드 | 분류명 | 핵심 연동 항목 |
| :--- | :--- | :--- |
| **FAC** | 시설물 | **PM(예방정비), IoT 가동률, 소모품 재고** |
| **INS** | 계측기 | **검교정 이력, MES 보정값 반영** |
| **CSM** | 소모품 | **정비 부품 할당, 안전재고 알림** |
| **TOL** | 공구 | **불출/반납 이력, 금형 수명 관리** |
---
## 💡 참고 사항
- 모든 기능 구현 시 플랫폼의 디자인 시스템(CSS Variables)을 준수해야 함.
- 모듈 독립성 유지를 위해 모듈 간 통신은 정의된 API 인터페이스를 통해서만 수행함.
## 아키텍처 상세 도식
┌─────────────────────────────────────────────────────────────────────────┐
│ Layer 5: 지능형 분석 및 시각화 (The Brain) │
│ ┌───────────────────────┐ ┌──────────────────────────────┐ │
│ │ AI 분석 엔진 │◀──────────▶│ 통합 관제 대시보드 (BI) │ │
│ │ (예지보전, 품질최적화) │ 인사이트 │ (KPI, 가동률 실시간 모니터링)│ │
│ └───────────▲───────────┘ └──────────────▲───────────────┘ │
└──────────────┼───────────────────────────────────────┼──────────────────┘
│ 분석 결과 피드백 │ 실시간 현황
▼ ▼
┌──────────────┼───────────────────────────────────────┼──────────────────┐
│ │ Layer 4: 애플리케이션 계층 (IT System) │ │
│ ┌───────────▼──┐ ┌──────────────┐ ┌─────────────┐ ┌──────────┐ │
│ │ MES │◀─▶│ ERP │◀─▶│ WMS │◀─▶│ QMS │ │
│ │ (생산 실행) │ │ (전사적 자원) │ │ (창고관리) │ │(품질관리) │ │
│ └───────▲──────┘ └───────▲──────┘ └──────▲──────┘ └─────▲────┘ │
└──────────┼──────────────────┼─────────────────┼────────────────┼────────┘
│ │ │ │
▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ ⭐ Layer 3: 통합 데이터 플랫폼 (CORE HUB & Data Lake) │
│ ┌───────────────────────────────────────────────────────────────────┐ │
│ │ 통합 데이터 허브 (Data Broker) │ │
│ │ "모든 데이터가 모이고, 필요한 곳으로 분배된다" │ │
│ └─────────────────────────────────▲─────────────────────────────────┘ │
│ │ ↕ 양방향 데이터 동기화 │
│ ┌──────────▼──────────┐ │
│ │ 실시간 / 이력 DB │ │
│ └─────────────────────┘ │
└────────────────────────────────────▲────────────────────────────────────┘
│ 표준 프로토콜 (MQTT/OPC-UA)
┌────────────────────────────────────┼────────────────────────────────────┐
│ Layer 2: 엣지 컴퓨팅 계층 (Translator) │
│ ┌────────────────────────┴───────────────────────┐ │
│ │ 엣지 게이트웨이 (Edge GW) │ │
│ │ (데이터 수집, 프로토콜 변환, 1차 필터링) │ │
│ └──────▲─────────────────▲────────────────▲──────┘ │
└──────────────────┼─────────────────┼────────────────┼───────────────────┘
│ │ │
┌──────────────────┼─────────────────┼────────────────┼───────────────────┐
│ ┌─────────┴──────┐ ┌──────┴───────┐ ┌────┴─────┐ │
│ │ P1. 기존 설비 │ │ P2. 최신설비 │ │ P3. 센서 │ │
│ │ (PLC/Legacy) │ │ (Robot/CNC) │ │ (환경) │ │
│ └────────────────┘ └──────────────┘ └──────────┘ │
│ Layer 1: 현장 물리 계층 (Data Source) │
└─────────────────────────────────────────────────────────────────────────┘
![alt text](image.png)
![alt text](image-1.png)
![alt text](image-2.png)
![alt text](image-3.png)
https://www.bulums.io/smartfactory-cmms
![alt text](image-4.png)
https://blog.naver.com/8pmcorp/224146369431

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

BIN
docs/roadmap/image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

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

View File

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

BIN
history.txt Normal file

Binary file not shown.

4
package-lock.json generated
View File

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

View File

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

36
server/fix_admin.js Normal file
View File

@ -0,0 +1,36 @@
const db = require('./db');
const crypto = require('crypto');
async function fixAdmin() {
try {
console.log('Altering table schema to include supervisor role...');
// First ensure the ENUM includes supervisor
await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'");
const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex');
console.log('Updating admin user...');
// Update both password and role for admin
const [result] = await db.query(
'UPDATE users SET password = ?, role = "supervisor" WHERE id = "admin"',
[hashedPass]
);
if (result.affectedRows > 0) {
console.log('✅ Admin user updated to Supervisor with password admin123');
} else {
console.log('⚠️ Admin user not found. Creating new admin...');
await db.query(
'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)',
['admin', hashedPass, '시스템 관리자', 'supervisor', 'IT팀', '관리자']
);
console.log('✅ Admin user created as Supervisor with password admin123');
}
} catch (err) {
console.error('❌ Failed to fix admin user:', err);
} finally {
process.exit();
}
}
fixAdmin();

View File

@ -51,6 +51,9 @@ const sessionStoreOptions = {
}; };
const sessionStore = new MySQLStore(sessionStoreOptions); const sessionStore = new MySQLStore(sessionStoreOptions);
sessionStore.on('error', (err) => {
console.error('Session Store Error:', err);
});
// Middleware // Middleware
app.use(cors({ app.use(cors({
@ -66,12 +69,13 @@ app.use(session({
key: 'smartims_sid', key: 'smartims_sid',
secret: process.env.SESSION_SECRET || 'smartims_session_secret_key', secret: process.env.SESSION_SECRET || 'smartims_session_secret_key',
store: sessionStore, store: sessionStore,
resave: true, // Force save to avoid session loss in some environments resave: false,
saveUninitialized: false, saveUninitialized: false,
rolling: false, // Do not automatic rolling (we control it in middleware)
cookie: { cookie: {
httpOnly: true, httpOnly: true,
secure: false, // Set true if using HTTPS secure: false, // HTTPS 사용 시 true로 변경 필요
maxAge: null, // Browser session by default maxAge: 3600000, // 기본 1시간 (미들웨어에서 동적 조정)
sameSite: 'lax' sameSite: 'lax'
} }
})); }));
@ -79,13 +83,30 @@ app.use(session({
// Dynamic Session Timeout Middleware // Dynamic Session Timeout Middleware
app.use(async (req, res, next) => { app.use(async (req, res, next) => {
if (req.session && req.session.user) { if (req.session && req.session.user) {
// Skip session extension for background check requests
if (req.path === '/api/check' || req.path === '/check' || req.path.includes('/auth/check')) {
return next();
}
try { try {
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'"); // Priority: User's individual timeout > System default
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60; let timeoutMinutes = 60; // Default fallback
if (req.session.user.session_timeout) {
timeoutMinutes = parseInt(req.session.user.session_timeout);
} else {
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 10; // New default fallback 10 as requested
}
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000; req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
// Explicitly save session to ensure store sync // Explicitly save session before moving to next middleware
req.session.save(); req.session.save((err) => {
if (err) console.error('Session save error:', err);
next();
});
return;
} catch (err) { } catch (err) {
console.error('Session timeout fetch error:', err); console.error('Session timeout fetch error:', err);
} }
@ -96,9 +117,24 @@ app.use(async (req, res, next) => {
// Apply CSRF Protection // Apply CSRF Protection
app.use(csrfProtection); app.use(csrfProtection);
// Request Logger // Request Logger with Session Remaining Time
app.use((req, res, next) => { app.use((req, res, next) => {
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`); 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}`);
next(); next();
}); });
@ -156,6 +192,14 @@ const initTables = async () => {
console.log("✅ Added 'quantity' column to assets"); console.log("✅ Added 'quantity' column to assets");
} }
// Check/Add 'parent_id' column to assets for sub-equipment management
const [parentIdCols] = await db.query("SHOW COLUMNS FROM assets LIKE 'parent_id'");
if (parentIdCols.length === 0) {
await db.query("ALTER TABLE assets ADD COLUMN parent_id VARCHAR(20) AFTER id");
await db.query("ALTER TABLE assets ADD CONSTRAINT fk_assets_parent FOREIGN KEY (parent_id) REFERENCES assets(id) ON DELETE SET NULL");
console.log("✅ Added 'parent_id' column to assets");
}
// Create maintenance_parts table // Create maintenance_parts table
const maintenancePartsTable = ` const maintenancePartsTable = `
CREATE TABLE IF NOT EXISTS maintenance_parts ( CREATE TABLE IF NOT EXISTS maintenance_parts (
@ -180,7 +224,8 @@ const initTables = async () => {
department VARCHAR(100), department VARCHAR(100),
position VARCHAR(100), position VARCHAR(100),
phone VARCHAR(255), phone VARCHAR(255),
role ENUM('admin', 'user') DEFAULT 'user', role ENUM('supervisor', 'admin', 'user') DEFAULT 'user',
session_timeout INT DEFAULT 10,
last_login TIMESTAMP NULL, last_login TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
@ -197,12 +242,25 @@ const initTables = async () => {
const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex'); const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex');
await db.query( await db.query(
'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)', 'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)',
[adminId, hashedPass, '시스템 관리자', 'admin', 'IT팀', '관리자'] [adminId, hashedPass, '관리자', 'supervisor', 'IT팀', '관리자']
); );
console.log('✅ Default Admin Created (admin / admin123)'); console.log('✅ Default Admin Created as Supervisor');
} }
} }
// 2. Ensure schema updates for existing table
try {
await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'");
} catch (e) {
// Ignore
}
const [userTimeoutCols] = await db.query("SHOW COLUMNS FROM users LIKE 'session_timeout'");
if (userTimeoutCols.length === 0) {
await db.query("ALTER TABLE users ADD COLUMN session_timeout INT DEFAULT 10 AFTER role");
console.log("✅ Added 'session_timeout' column to users");
}
console.log('✅ Tables Initialized'); console.log('✅ Tables Initialized');
// Create asset_manuals table // Create asset_manuals table
const manualTable = ` const manualTable = `
@ -217,43 +275,105 @@ const initTables = async () => {
`; `;
await db.query(manualTable); await db.query(manualTable);
// Create camera_settings table // Create asset_accessories table
const accessoryTable = `
CREATE TABLE IF NOT EXISTS asset_accessories (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(20) NOT NULL,
name VARCHAR(100) NOT NULL,
spec VARCHAR(100),
quantity INT DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES assets(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`;
await db.query(accessoryTable);
// 1. Rename camera_settings to cctv_settings if old table exists
const [oldCamTable] = await db.query("SHOW TABLES LIKE 'camera_settings'");
if (oldCamTable.length > 0) {
await db.query("RENAME TABLE camera_settings TO cctv_settings");
console.log("✅ Renamed 'camera_settings' to 'cctv_settings'");
}
// Create cctv_settings table
const cameraTable = ` const cameraTable = `
CREATE TABLE IF NOT EXISTS camera_settings ( CREATE TABLE IF NOT EXISTS cctv_settings (
id INT AUTO_INCREMENT PRIMARY KEY, id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL, name VARCHAR(100) NOT NULL,
zone VARCHAR(50) DEFAULT '기본 구역',
ip_address VARCHAR(100) NOT NULL, ip_address VARCHAR(100) NOT NULL,
port INT DEFAULT 554, port INT DEFAULT 554,
username VARCHAR(100), username VARCHAR(100),
password VARCHAR(100), password VARCHAR(255),
stream_path VARCHAR(200) DEFAULT '/stream1', stream_path VARCHAR(200) DEFAULT '/stream1',
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`; `;
await db.query(cameraTable); await db.query(cameraTable);
// Ensure password field is long enough for encryption
await db.query("ALTER TABLE cctv_settings MODIFY COLUMN password VARCHAR(255)");
// Check for 'transport_mode' and 'rtsp_encoding' columns and add if missing // Check for 'transport_mode' and 'rtsp_encoding' columns and add if missing
const [camColumns] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'transport_mode'"); const [camColumns] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'transport_mode'");
if (camColumns.length === 0) { if (camColumns.length === 0) {
await db.query("ALTER TABLE camera_settings ADD COLUMN transport_mode ENUM('tcp', 'udp', 'auto') DEFAULT 'tcp' AFTER stream_path"); await db.query("ALTER TABLE cctv_settings ADD COLUMN transport_mode ENUM('tcp', 'udp', 'auto') DEFAULT 'tcp' AFTER stream_path");
await db.query("ALTER TABLE camera_settings ADD COLUMN rtsp_encoding BOOLEAN DEFAULT FALSE AFTER transport_mode"); await db.query("ALTER TABLE cctv_settings ADD COLUMN rtsp_encoding BOOLEAN DEFAULT FALSE AFTER transport_mode");
await db.query("ALTER TABLE camera_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); // Default to low for stability await db.query("ALTER TABLE cctv_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); // Default to low for stability
console.log("✅ Added 'transport_mode', 'quality', and 'rtsp_encoding' columns to camera_settings"); console.log("✅ Added 'transport_mode', 'quality', and 'rtsp_encoding' columns to cctv_settings");
} else { } else {
// Check if quality exists (for subsequent updates) // Check if quality exists (for subsequent updates)
const [qualCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'quality'"); const [qualCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'quality'");
if (qualCol.length === 0) { if (qualCol.length === 0) {
await db.query("ALTER TABLE camera_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); await db.query("ALTER TABLE cctv_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode");
console.log("✅ Added 'quality' column to camera_settings"); console.log("✅ Added 'quality' column to cctv_settings");
} }
} }
// Check for 'zone' column
const [zoneCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'zone'");
if (zoneCol.length === 0) {
await db.query("ALTER TABLE cctv_settings ADD COLUMN zone VARCHAR(50) DEFAULT '기본 구역' AFTER name");
console.log("✅ Added 'zone' column to cctv_settings");
}
// Check for 'display_order' column // Check for 'display_order' column
const [orderCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'display_order'"); const [orderCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'display_order'");
if (orderCol.length === 0) { if (orderCol.length === 0) {
await db.query("ALTER TABLE camera_settings ADD COLUMN display_order INT DEFAULT 0 AFTER quality"); await db.query("ALTER TABLE cctv_settings ADD COLUMN display_order INT DEFAULT 0 AFTER quality");
console.log("✅ Added 'display_order' column to camera_settings"); console.log("✅ Added 'display_order' column to cctv_settings");
}
// Create cctv_zones table
const zoneTable = `
CREATE TABLE IF NOT EXISTS cctv_zones (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
layout ENUM('1', '1*2', '2*2') DEFAULT '2*2',
display_order INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`;
await db.query(zoneTable);
// Check for 'layout' column (for migration)
const [layoutCol] = await db.query("SHOW COLUMNS FROM cctv_zones LIKE 'layout'");
if (layoutCol.length === 0) {
await db.query("ALTER TABLE cctv_zones ADD COLUMN layout VARCHAR(10) DEFAULT '2*2' AFTER name");
console.log("✅ Added 'layout' column to cctv_zones");
} else {
// Migration: Convert ENUM to VARCHAR
await db.query("ALTER TABLE cctv_zones MODIFY COLUMN layout VARCHAR(10) DEFAULT '2*2'");
}
// Initialize default zone if empty
const [existingZones] = await db.query('SELECT 1 FROM cctv_zones LIMIT 1');
if (existingZones.length === 0) {
await db.query("INSERT INTO cctv_zones (name, display_order) VALUES ('기본 구역', 0)");
console.log("✅ Initialized default CCTV zone");
} }
// Create system_settings table (Key-Value store) // Create system_settings table (Key-Value store)
@ -321,9 +441,20 @@ const initTables = async () => {
const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1'); const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1');
if (existingModules.length === 0) { if (existingModules.length === 0) {
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`; const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`;
await db.query(insert, ['asset', '자산 관리', true, 'dev']); await db.query(insert, ['asset', '자산 관리', false, null]);
await db.query(insert, ['production', '생산 관리', false, null]); await db.query(insert, ['production', '생산 관리', false, null]);
await db.query(insert, ['monitoring', 'CCTV', false, null]); await db.query(insert, ['cctv', 'CCTV', false, null]);
} else {
// One-time update: Rename 'monitoring' code to 'cctv' (migration)
// Use subquery or check if cctv exists to avoid ER_DUP_ENTRY
const [cctvExists] = await db.query("SELECT 1 FROM system_modules WHERE code = 'cctv'");
if (cctvExists.length > 0) {
// If cctv already exists, just remove monitoring if it's there
await db.query("DELETE FROM system_modules WHERE code = 'monitoring'");
} else {
// If cctv doesn't exist, try renaming monitoring
await db.query("UPDATE system_modules SET code = 'cctv' WHERE code = 'monitoring'");
}
} }
console.log('✅ Tables Initialized'); console.log('✅ Tables Initialized');
@ -333,8 +464,33 @@ const initTables = async () => {
}; };
initTables(); initTables();
const packageJson = require('./package.json');
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
res.json({ status: 'ok', version: '1.2.0', timestamp: '2026-01-22 21:18' }); // 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]
});
}); });
// Routes // Routes
@ -385,6 +541,10 @@ app.use(express.static(distPath));
// The "catchall" handler: for any request that doesn't // The "catchall" handler: for any request that doesn't
// match one above, send back React's index.html file. // match one above, send back React's index.html file.
app.get(/(.*)/, (req, res) => { 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')); res.sendFile(path.join(distPath, 'index.html'));
}); });

View File

@ -1,3 +1,15 @@
const ROLES = {
SUPERVISOR: 'supervisor',
ADMIN: 'admin',
USER: 'user'
};
const HIERARCHY = {
[ROLES.SUPERVISOR]: 100,
[ROLES.ADMIN]: 50,
[ROLES.USER]: 10
};
const isAuthenticated = (req, res, next) => { const isAuthenticated = (req, res, next) => {
if (req.session && req.session.user) { if (req.session && req.session.user) {
return next(); return next();
@ -5,13 +17,17 @@ const isAuthenticated = (req, res, next) => {
return res.status(401).json({ success: false, message: 'Unauthorized' }); return res.status(401).json({ success: false, message: 'Unauthorized' });
}; };
const hasRole = (...roles) => { const hasRole = (requiredRole) => {
return (req, res, next) => { return (req, res, next) => {
if (!req.session || !req.session.user) { if (!req.session || !req.session.user) {
return res.status(401).json({ success: false, message: 'Unauthorized' }); return res.status(401).json({ success: false, message: 'Unauthorized' });
} }
if (roles.includes(req.session.user.role)) { const userRole = req.session.user.role;
const userLevel = HIERARCHY[userRole] || 0;
const requiredLevel = HIERARCHY[requiredRole] || 999;
if (userLevel >= requiredLevel) {
return next(); return next();
} }
@ -19,4 +35,4 @@ const hasRole = (...roles) => {
}; };
}; };
module.exports = { isAuthenticated, hasRole }; module.exports = { isAuthenticated, hasRole, ROLES };

View File

@ -28,6 +28,7 @@ const csrfProtection = (req, res, next) => {
console.error(`- Path: ${req.path}`); console.error(`- Path: ${req.path}`);
console.error(`- Session ID: ${req.sessionID ? req.sessionID.substring(0, 8) + '...' : 'NONE'}`); console.error(`- Session ID: ${req.sessionID ? req.sessionID.substring(0, 8) + '...' : 'NONE'}`);
console.error(`- Session User: ${req.session?.user?.id || 'GUEST'}`); console.error(`- Session User: ${req.session?.user?.id || 'GUEST'}`);
console.error(`- Session MaxAge: ${req.session?.cookie?.maxAge / 1000 / 60} min`);
console.error(`- Token in Session: ${tokenFromSession ? 'EXISTS (' + tokenFromSession.substring(0, 5) + '...)' : 'MISSING'}`); console.error(`- Token in Session: ${tokenFromSession ? 'EXISTS (' + tokenFromSession.substring(0, 5) + '...)' : 'MISSING'}`);
console.error(`- Token in Header: ${tokenFromHeader ? 'EXISTS (' + tokenFromHeader.substring(0, 5) + '...)' : 'MISSING'}`); console.error(`- Token in Header: ${tokenFromHeader ? 'EXISTS (' + tokenFromHeader.substring(0, 5) + '...)' : 'MISSING'}`);

View File

@ -23,7 +23,22 @@ router.get('/assets/:id', async (req, res) => {
try { try {
const [rows] = await db.query('SELECT * FROM assets WHERE id = ?', [req.params.id]); const [rows] = await db.query('SELECT * FROM assets WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' }); if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
res.json(rows[0]);
const asset = rows[0];
// Fetch sub-assets (children)
const [children] = await db.query('SELECT id, name, category, status, location FROM assets WHERE parent_id = ?', [req.params.id]);
asset.children = children;
// Fetch parent name if exists
if (asset.parent_id) {
const [parentRows] = await db.query('SELECT name FROM assets WHERE id = ?', [asset.parent_id]);
if (parentRows.length > 0) {
asset.parent_name = parentRows[0].name;
}
}
res.json(asset);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: 'Database error' });
@ -32,14 +47,14 @@ router.get('/assets/:id', async (req, res) => {
// Create Asset // Create Asset
router.post('/assets', async (req, res) => { router.post('/assets', async (req, res) => {
const { id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body; const { id, parent_id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity } = req.body;
try { try {
const sql = ` const sql = `
INSERT INTO assets (id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url) INSERT INTO assets (id, parent_id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`; `;
await db.query(sql, [id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url]); await db.query(sql, [id, parent_id || null, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity || 1]);
res.status(201).json({ message: 'Asset created', id }); res.status(201).json({ message: 'Asset created', id });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -49,15 +64,15 @@ router.post('/assets', async (req, res) => {
// Update Asset // Update Asset
router.put('/assets/:id', async (req, res) => { router.put('/assets/:id', async (req, res) => {
const { name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body; const { parent_id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity } = req.body;
try { try {
const sql = ` const sql = `
UPDATE assets UPDATE assets
SET name=?, category=?, model_name=?, serial_number=?, manufacturer=?, location=?, purchase_date=?, manager=?, status=?, specs=?, purchase_price=?, image_url=? SET parent_id=?, name=?, category=?, model_name=?, serial_number=?, manufacturer=?, location=?, purchase_date=?, manager=?, status=?, specs=?, purchase_price=?, image_url=?, quantity=?
WHERE id=? WHERE id=?
`; `;
const [result] = await db.query(sql, [name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, req.params.id]); const [result] = await db.query(sql, [parent_id || null, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity || 1, req.params.id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Asset not found' }); if (result.affectedRows === 0) return res.status(404).json({ error: 'Asset not found' });
res.json({ message: 'Asset updated' }); res.json({ message: 'Asset updated' });
@ -279,4 +294,46 @@ router.delete('/manuals/:id', async (req, res) => {
} }
}); });
// ==========================================
// 4. Accessories
// ==========================================
// Get Accessories for an Asset
router.get('/assets/:asset_id/accessories', async (req, res) => {
try {
const [rows] = await db.query('SELECT * FROM asset_accessories WHERE asset_id = ? ORDER BY created_at ASC', [req.params.asset_id]);
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Add Accessory to Asset
router.post('/assets/:asset_id/accessories', async (req, res) => {
const { name, spec, quantity } = req.body;
const { asset_id } = req.params;
try {
const sql = `INSERT INTO asset_accessories (asset_id, name, spec, quantity) VALUES (?, ?, ?, ?)`;
const [result] = await db.query(sql, [asset_id, name, spec, quantity || 1]);
res.status(201).json({ message: 'Accessory added', id: result.insertId });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Delete Accessory
router.delete('/accessories/:id', async (req, res) => {
try {
const [result] = await db.query('DELETE FROM asset_accessories WHERE id = ?', [req.params.id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Accessory not found' });
res.json({ message: 'Accessory deleted' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
module.exports = router; module.exports = router;

View File

@ -3,12 +3,21 @@ const router = express.Router();
const db = require('../../db'); const db = require('../../db');
const { isAuthenticated, hasRole } = require('../../middleware/authMiddleware'); const { isAuthenticated, hasRole } = require('../../middleware/authMiddleware');
const { requireModule } = require('../../middleware/licenseMiddleware'); const { requireModule } = require('../../middleware/licenseMiddleware');
const cryptoUtil = require('../../utils/cryptoUtil');
// Get all cameras - Protected by Module License // Get all cameras - Protected by Module License
router.get('/', requireModule('monitoring'), async (req, res) => { router.get('/', requireModule('cctv'), async (req, res) => {
try { try {
const [rows] = await db.query('SELECT * FROM camera_settings ORDER BY display_order ASC, created_at DESC'); const [rows] = await db.query('SELECT * FROM cctv_settings ORDER BY display_order ASC, created_at DESC');
res.json(rows);
// Decrypt usernames and passwords for the UI
const cameras = rows.map(cam => ({
...cam,
username: cam.username ? cryptoUtil.decryptMasterKey(cam.username) : cam.username,
password: cam.password ? cryptoUtil.decryptMasterKey(cam.password) : cam.password
}));
res.json(cameras);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: 'Database error' });
@ -32,7 +41,7 @@ router.put('/reorder', hasRole('admin'), async (req, res) => {
const id = typeof cam === 'object' ? cam.id : cam; const id = typeof cam === 'object' ? cam.id : cam;
const order = i; const order = i;
await db.query('UPDATE camera_settings SET display_order = ? WHERE id = ?', [order, id]); await db.query('UPDATE cctv_settings SET display_order = ? WHERE id = ?', [order, id]);
} }
await db.query('COMMIT'); await db.query('COMMIT');
@ -43,13 +52,73 @@ router.put('/reorder', hasRole('admin'), async (req, res) => {
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: 'Database error' });
} }
}); });
// --- Zone Management ---
// Get all zones
router.get('/zones', isAuthenticated, async (req, res) => {
try {
const [rows] = await db.query('SELECT * FROM cctv_zones ORDER BY display_order ASC, name ASC');
res.json(rows);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// Update zones (Sync list)
router.put('/zones', hasRole('admin'), async (req, res) => {
const { zones } = req.body;
if (!Array.isArray(zones)) return res.status(400).json({ error: 'Invalid data' });
try {
await db.query('START TRANSACTION');
// Rebuild zone table for simplicity since we link by name string in cctv_settings
await db.query('DELETE FROM cctv_zones');
for (let i = 0; i < zones.length; i++) {
const z = zones[i];
const name = typeof z === 'string' ? z : z.name;
const layout = typeof z === 'object' ? (z.layout || '2*2') : '2*2';
if (name) {
await db.query('INSERT INTO cctv_zones (name, layout, display_order) VALUES (?, ?, ?)', [name, layout, i]);
}
}
await db.query('COMMIT');
res.json({ message: 'Zones updated' });
} catch (err) {
await db.query('ROLLBACK');
console.error(err);
res.status(500).json({ error: 'Failed to update zones' });
}
});
// Update specific zone layout (Real-time override)
router.patch('/zones/:name/layout', hasRole('admin'), async (req, res) => {
const { layout } = req.body;
try {
const [result] = await db.query('UPDATE cctv_zones SET layout = ? WHERE name = ?', [layout, req.params.name]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Zone not found' });
res.json({ success: true, message: 'Layout updated' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to update layout' });
}
});
// Get single camera // Get single camera
router.get('/:id', async (req, res) => { router.get('/:id', async (req, res) => {
try { try {
const [rows] = await db.query('SELECT * FROM camera_settings WHERE id = ?', [req.params.id]); const [rows] = await db.query('SELECT * FROM cctv_settings WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' }); if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' });
res.json(rows[0]);
const camera = rows[0];
camera.username = camera.username ? cryptoUtil.decryptMasterKey(camera.username) : camera.username;
camera.password = camera.password ? cryptoUtil.decryptMasterKey(camera.password) : camera.password;
res.json(camera);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: 'Database error' });
@ -58,10 +127,13 @@ router.get('/:id', async (req, res) => {
// Add camera (Admin only) // Add camera (Admin only)
router.post('/', hasRole('admin'), async (req, res) => { router.post('/', hasRole('admin'), async (req, res) => {
const { name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body; const { name, zone, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
try { try {
const sql = `INSERT INTO camera_settings (name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; const encryptedUser = username ? cryptoUtil.encryptMasterKey(username) : username;
const [result] = await db.query(sql, [name, ip_address, port || 554, username, password, stream_path || '/stream1', transport_mode || 'tcp', rtsp_encoding || false, quality || 'low']); const encryptedPass = password ? cryptoUtil.encryptMasterKey(password) : password;
const sql = `INSERT INTO cctv_settings (name, zone, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`;
const [result] = await db.query(sql, [name, zone || '기본 구역', ip_address, port || 554, encryptedUser, encryptedPass, stream_path || '/stream1', transport_mode || 'tcp', rtsp_encoding || false, quality || 'low']);
res.status(201).json({ message: 'Camera added', id: result.insertId }); res.status(201).json({ message: 'Camera added', id: result.insertId });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -71,10 +143,13 @@ router.post('/', hasRole('admin'), async (req, res) => {
// Update camera (Admin only) // Update camera (Admin only)
router.put('/:id', hasRole('admin'), async (req, res) => { router.put('/:id', hasRole('admin'), async (req, res) => {
const { name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body; const { name, zone, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
try { try {
const sql = `UPDATE camera_settings SET name=?, ip_address=?, port=?, username=?, password=?, stream_path=?, transport_mode=?, rtsp_encoding=?, quality=? WHERE id=?`; const encryptedUser = username ? cryptoUtil.encryptMasterKey(username) : username;
const [result] = await db.query(sql, [name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality, req.params.id]); const encryptedPass = password ? cryptoUtil.encryptMasterKey(password) : password;
const sql = `UPDATE cctv_settings SET name=?, zone=?, ip_address=?, port=?, username=?, password=?, stream_path=?, transport_mode=?, rtsp_encoding=?, quality=? WHERE id=?`;
const [result] = await db.query(sql, [name, zone, ip_address, port, encryptedUser, encryptedPass, stream_path, transport_mode, rtsp_encoding, quality, req.params.id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' }); if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
// Force stream reset (kick clients to trigger reconnect) // Force stream reset (kick clients to trigger reconnect)
@ -95,7 +170,7 @@ router.put('/:id', hasRole('admin'), async (req, res) => {
router.patch('/:id/status', hasRole('admin'), async (req, res) => { router.patch('/:id/status', hasRole('admin'), async (req, res) => {
const { is_active } = req.body; const { is_active } = req.body;
try { try {
const [result] = await db.query('UPDATE camera_settings SET is_active = ? WHERE id = ?', [is_active, req.params.id]); const [result] = await db.query('UPDATE cctv_settings SET is_active = ? WHERE id = ?', [is_active, req.params.id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' }); if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
const streamRelay = req.app.get('streamRelay'); const streamRelay = req.app.get('streamRelay');
@ -115,7 +190,7 @@ router.patch('/:id/status', hasRole('admin'), async (req, res) => {
// Delete camera (Admin only) // Delete camera (Admin only)
router.delete('/:id', hasRole('admin'), async (req, res) => { router.delete('/:id', hasRole('admin'), async (req, res) => {
try { try {
const [result] = await db.query('DELETE FROM camera_settings WHERE id = ?', [req.params.id]); const [result] = await db.query('DELETE FROM cctv_settings WHERE id = ?', [req.params.id]);
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' }); if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
// Stop stream // Stop stream
@ -135,10 +210,11 @@ const { exec } = require('child_process');
// ... existing routes ... // ... existing routes ...
// 7. Ping Test (Troubleshooting) // 7. Ping Test (Troubleshooting)
router.get('/:id/ping', isAuthenticated, async (req, res) => { router.get('/:id/ping', isAuthenticated, async (req, res) => {
try { try {
const [rows] = await db.query('SELECT ip_address FROM camera_settings WHERE id = ?', [req.params.id]); const [rows] = await db.query('SELECT ip_address FROM cctv_settings WHERE id = ?', [req.params.id]);
if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' }); if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' });
const ip = rows[0].ip_address; const ip = rows[0].ip_address;

View File

@ -3,6 +3,7 @@ const ffmpeg = require('fluent-ffmpeg');
const staticFfmpegBinary = require('ffmpeg-static'); // Renamed for clarity const staticFfmpegBinary = require('ffmpeg-static'); // Renamed for clarity
const db = require('../../db'); const db = require('../../db');
const fs = require('fs'); const fs = require('fs');
const cryptoUtil = require('../../utils/cryptoUtil');
// We don't set global ffmpeg path here immediately because we might want to switch per-stream or check system paths dynamically // We don't set global ffmpeg path here immediately because we might want to switch per-stream or check system paths dynamically
// (though fluent-ffmpeg usage usually suggests setting it once if possible, but we check inside startFFmpeg for robustness) // (though fluent-ffmpeg usage usually suggests setting it once if possible, but we check inside startFFmpeg for robustness)
@ -76,7 +77,7 @@ class StreamRelay {
if (!stream) return; if (!stream) return;
try { try {
const [rows] = await db.query('SELECT * FROM camera_settings WHERE id = ?', [cameraId]); const [rows] = await db.query('SELECT * FROM cctv_settings WHERE id = ?', [cameraId]);
if (rows.length === 0) { if (rows.length === 0) {
console.error(`Camera ${cameraId} not found`); console.error(`Camera ${cameraId} not found`);
return; return;
@ -96,8 +97,10 @@ class StreamRelay {
let rtspUrl = 'rtsp://'; let rtspUrl = 'rtsp://';
if (camera.username && camera.password) { if (camera.username && camera.password) {
const user = camera.rtsp_encoding ? encodeURIComponent(camera.username) : camera.username; const decUser = cryptoUtil.decryptMasterKey(camera.username);
const pass = camera.rtsp_encoding ? encodeURIComponent(camera.password) : camera.password; const decPass = cryptoUtil.decryptMasterKey(camera.password);
const user = camera.rtsp_encoding ? encodeURIComponent(decUser) : decUser;
const pass = camera.rtsp_encoding ? encodeURIComponent(decPass) : decPass;
rtspUrl += `${user}:${pass}@`; rtspUrl += `${user}:${pass}@`;
} }
rtspUrl += `${camera.ip_address}:${camera.port || 554}${camera.stream_path || '/stream1'}`; rtspUrl += `${camera.ip_address}:${camera.port || 554}${camera.stream_path || '/stream1'}`;

View File

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

View File

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

View File

@ -5,54 +5,14 @@ const crypto = require('crypto');
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware'); const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
const { generateToken } = require('../middleware/csrfMiddleware'); const { generateToken } = require('../middleware/csrfMiddleware');
// --- Crypto Utilities --- const cryptoUtil = require('../utils/cryptoUtil');
// Use a fixed key for MVP. In production, store this securely in .env
// Key must be 32 bytes for aes-256-cbc
// 'my_super_secret_key_manage_asset' is 32 chars?
// let's use a simpler approach to ensure length on startup or fallback
const SECRET_KEY = process.env.ENCRYPTION_KEY || 'smartims_secret_key_0123456789'; // 32 chars needed
// Ideally use a buffer from hex, but string is okay if 32 chars.
// Let's pad it to ensure stability if env is missing.
const keyBuffer = crypto.scryptSync(SECRET_KEY, 'salt', 32);
const ALGORITHM = 'aes-256-cbc'; async function encrypt(text) {
return await cryptoUtil.encrypt(text);
function encrypt(text) {
if (!text) return text;
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
} }
function decrypt(text) { async function decrypt(text) {
if (!text) return text; return await cryptoUtil.decrypt(text);
// Check if it looks like our encrypted format (hexIV:hexContent)
if (!text.includes(':')) {
return text; // Assume plain text if no separator
}
try {
const textParts = text.split(':');
const ivHex = textParts.shift();
// IV for AES-256-CBC must be 16 bytes (32 hex characters)
if (!ivHex || ivHex.length !== 32) {
return text; // Invalid IV length, return original
}
const iv = Buffer.from(ivHex, 'hex');
const encryptedText = textParts.join(':');
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (e) {
console.error('Decryption failed for:', text, e.message);
return text; // Return original if fail
}
} }
function hashPassword(password) { function hashPassword(password) {
@ -77,7 +37,7 @@ router.post('/login', async (req, res) => {
delete user.password; delete user.password;
// Should we decrypt phone? Maybe not needed for session, but let's decrypt just in case UI needs it // Should we decrypt phone? Maybe not needed for session, but let's decrypt just in case UI needs it
if (user.phone) user.phone = decrypt(user.phone); if (user.phone) user.phone = await decrypt(user.phone);
// Save user to session // Save user to session
req.session.user = user; req.session.user = user;
@ -103,20 +63,36 @@ router.post('/login', async (req, res) => {
} }
}); });
// 1.5. Check Session (New) // 1.5. Check Session
router.get('/check', (req, res) => { router.get('/check', async (req, res) => {
if (req.session.user) { try {
// Ensure CSRF token exists, if not generate one (edge case) if (req.session.user) {
if (!req.session.csrfToken) { // Ensure CSRF token exists
req.session.csrfToken = generateToken(); 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;
}
res.json({
isAuthenticated: true,
user: req.session.user,
csrfToken: req.session.csrfToken,
sessionTimeout: timeoutMinutes * 60 * 1000
});
} else {
res.json({ isAuthenticated: false });
} }
res.json({ } catch (err) {
isAuthenticated: true, console.error('Check session error:', err);
user: req.session.user, res.status(500).json({ success: false, message: 'Server error' });
csrfToken: req.session.csrfToken
});
} else {
res.json({ isAuthenticated: false });
} }
}); });
@ -132,27 +108,58 @@ router.post('/logout', (req, res) => {
}); });
}); });
// 1.7 Verify Supervisor (For sensitive settings)
router.post('/verify-supervisor', isAuthenticated, async (req, res) => {
const { password } = req.body;
if (!password) return res.status(400).json({ error: 'Password required' });
try {
if (req.session.user.role !== 'supervisor') {
return res.status(403).json({ error: '권한이 없습니다. 최고관리자만 접근 가능합니다.' });
}
const hashedPassword = hashPassword(password);
const [rows] = await db.query('SELECT 1 FROM users WHERE id = ? AND password = ?', [req.session.user.id, hashedPassword]);
if (rows.length > 0) {
res.json({ success: true, message: 'Verification successful' });
} else {
res.status(401).json({ success: false, message: '비밀번호가 일치하지 않습니다.' });
}
} catch (err) {
console.error('Verify supervisor error:', err);
res.status(500).json({ error: '인증 처리 중 오류가 발생했습니다.' });
}
});
// 2. List Users (Admin Only) // 2. List Users (Admin Only)
router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => { router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
try { try {
// ideally check req.user.role if we had middleware, for now assuming client logic protection + internal/local usage 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');
const users = rows.map(u => ({ if (!rows || rows.length === 0) {
...u, return res.json([]);
phone: decrypt(u.phone) // Decrypt phone for admin view }
// Use Promise.all for safe async decryption
const users = await Promise.all(rows.map(async (u) => {
const decryptedPhone = await decrypt(u.phone);
return {
...u,
phone: decryptedPhone
};
})); }));
res.json(users); res.json(users);
} catch (err) { } catch (err) {
console.error(err); console.error('Failed to list users:', err);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: '데이터를 불러오는 중 오류가 발생했습니다.' });
} }
}); });
// 3. Create User // 3. Create User
router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => { router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
const { id, password, name, department, position, phone, role } = req.body; const { id, password, name, department, position, phone, role, session_timeout } = req.body;
if (!id || !password || !name) { if (!id || !password || !name) {
return res.status(400).json({ error: 'Missing required fields' }); return res.status(400).json({ error: 'Missing required fields' });
@ -166,14 +173,14 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
} }
const hashedPassword = hashPassword(password); const hashedPassword = hashPassword(password);
const encryptedPhone = encrypt(phone); const encryptedPhone = await encrypt(phone);
const sql = ` const sql = `
INSERT INTO users (id, password, name, department, position, phone, role) INSERT INTO users (id, password, name, department, position, phone, role, session_timeout)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`; `;
await db.query(sql, [id, hashedPassword, name, department, position, encryptedPhone, role || 'user']); await db.query(sql, [id, hashedPassword, name, department, position, encryptedPhone, role || 'user', session_timeout || 10]);
res.status(201).json({ message: 'User created' }); res.status(201).json({ message: 'User created' });
} catch (err) { } catch (err) {
@ -184,41 +191,20 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
// 4. Update User // 4. Update User
router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) => { router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) => {
const { password, name, department, position, phone, role } = req.body; const { password, name, department, position, phone, role, session_timeout } = req.body;
const userId = req.params.id; const userId = req.params.id;
try { try {
// Fetch current to keep old values if not provided? Frontend usually sends all. // ... (existing logic)
// We update everything provided.
// Build query dynamically or just assume full update
let updates = []; let updates = [];
let params = []; let params = [];
if (password) { updates.push('password = ?'); params.push(hashPassword(password)); }
if (password) { if (name) { updates.push('name = ?'); params.push(name); }
updates.push('password = ?'); if (department !== undefined) { updates.push('department = ?'); params.push(department); }
params.push(hashPassword(password)); if (position !== undefined) { updates.push('position = ?'); params.push(position); }
} if (phone !== undefined) { updates.push('phone = ?'); params.push(await encrypt(phone)); }
if (name) { if (role) { updates.push('role = ?'); params.push(role); }
updates.push('name = ?'); if (session_timeout !== undefined) { updates.push('session_timeout = ?'); params.push(session_timeout); }
params.push(name);
}
if (department !== undefined) {
updates.push('department = ?');
params.push(department);
}
if (position !== undefined) {
updates.push('position = ?');
params.push(position);
}
if (phone !== undefined) {
updates.push('phone = ?');
params.push(encrypt(phone));
}
if (role) {
updates.push('role = ?');
params.push(role);
}
if (updates.length === 0) return res.json({ message: 'No changes' }); if (updates.length === 0) return res.json({ message: 'No changes' });
@ -227,7 +213,53 @@ router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) =>
await db.query(sql, params); await db.query(sql, params);
res.json({ message: 'User updated' }); res.json({ message: 'User updated' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// 4.5. Update My Profile (New)
router.put('/profile', isAuthenticated, async (req, res) => {
const { name, phone, session_timeout } = req.body;
const userId = req.session.user.id;
try {
let updates = [];
let params = [];
if (name) {
updates.push('name = ?');
params.push(name);
}
if (phone !== undefined) {
updates.push('phone = ?');
params.push(await encrypt(phone));
}
if (session_timeout !== undefined) {
updates.push('session_timeout = ?');
params.push(session_timeout);
}
if (updates.length === 0) return res.json({ message: 'No changes' });
const sql = `UPDATE users SET ${updates.join(', ')} WHERE id = ?`;
params.push(userId);
await db.query(sql, params);
// Update session user object
const [rows] = await db.query('SELECT id, name, department, position, phone, role, session_timeout FROM users WHERE id = ?', [userId]);
req.session.user = rows[0];
// Also update cookie maxAge if timeout changed
if (session_timeout) {
req.session.cookie.maxAge = parseInt(session_timeout) * 60 * 1000;
}
req.session.save(() => {
res.json({ message: 'Profile updated', user: req.session.user });
});
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: 'Database error' });

View File

@ -1,11 +1,13 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { exec, execSync } = require('child_process');
const db = require('../db'); const db = require('../db');
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware'); const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
const { generateLicense, verifyLicense } = require('../utils/licenseManager'); const { generateLicense, verifyLicense } = require('../utils/licenseManager');
const { checkRemoteKey } = require('../utils/remoteLicense'); const { checkRemoteKey } = require('../utils/remoteLicense');
const cryptoUtil = require('../utils/cryptoUtil');
// Load Public Key for Verification // Load Public Key for Verification
const publicKeyPath = path.join(__dirname, '../config/public_key.pem'); const publicKeyPath = path.join(__dirname, '../config/public_key.pem');
@ -21,16 +23,78 @@ try {
console.error('❌ Error loading public key:', e); console.error('❌ Error loading public key:', e);
} }
// Helper to check if a setting key is allowed for general get/post
// This prevents modification of sensitive keys if any
const ALLOWED_SETTING_KEYS = [
'subscriber_id',
'session_timeout',
'encryption_key',
'asset_id_rule',
'asset_categories',
'asset_locations',
'asset_statuses',
'asset_maintenance_types',
'gitea_url',
'gitea_user',
'gitea_password'
];
// --- .env File Utilities ---
const envPath = path.join(__dirname, '../.env');
const readEnv = () => {
if (!fs.existsSync(envPath)) return {};
const content = fs.readFileSync(envPath, 'utf8');
const lines = content.split('\n');
const env = {};
lines.forEach(line => {
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
if (match) {
env[match[1]] = match[2] ? match[2].trim() : '';
}
});
return env;
};
const writeEnv = (updates) => {
let content = fs.readFileSync(envPath, 'utf8');
Object.entries(updates).forEach(([key, value]) => {
const regex = new RegExp(`^${key}=.*`, 'm');
if (regex.test(content)) {
content = content.replace(regex, `${key}=${value}`);
} else {
content += `\n${key}=${value}`;
}
});
fs.writeFileSync(envPath, content, 'utf8');
};
const mysql = require('mysql2/promise');
// 0. Server Configuration (Subscriber ID & Session Timeout) // 0. Server Configuration (Subscriber ID & Session Timeout)
router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
try { try {
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout')"); 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 settings = {}; const settings = {};
rows.forEach(r => settings[r.setting_key] = r.setting_value); rows.forEach(r => settings[r.setting_key] = r.setting_value);
// Include .env DB settings
const env = readEnv();
res.json({ res.json({
subscriber_id: settings.subscriber_id || '', subscriber_id: settings.subscriber_id || '',
session_timeout: parseInt(settings.session_timeout) || 60 // Default 60 min 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 || '',
password: env.DB_PASSWORD || '',
database: env.DB_NAME || '',
port: env.DB_PORT || '3306'
}
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -39,7 +103,7 @@ router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
}); });
router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => { router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
const { subscriber_id, session_timeout } = req.body; const { subscriber_id, session_timeout, encryption_key, db_config } = req.body;
try { try {
if (subscriber_id !== undefined) { if (subscriber_id !== undefined) {
@ -48,7 +112,176 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) =>
if (session_timeout !== undefined) { if (session_timeout !== undefined) {
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('session_timeout', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [session_timeout.toString()]); await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('session_timeout', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [session_timeout.toString()]);
} }
res.json({ message: 'Settings saved' }); if (encryption_key !== undefined) {
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) {
writeEnv({
DB_HOST: db_config.host,
DB_USER: db_config.user,
DB_PASSWORD: db_config.password,
DB_NAME: db_config.database,
DB_PORT: db_config.port
});
}
res.json({ message: 'Settings saved. Server may restart to apply DB changes.' });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
// --- Crypto & Key Rotation ---
// 0.2 Test DB Connection
router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
const { host, user, password, database, port } = req.body;
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,
password,
database,
port: parseInt(port) || 3306,
connectTimeout: 5000
});
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 });
} finally {
if (conn) await conn.end();
}
});
// 0.3 Encryption Key Management & Rotation
router.get('/encryption/status', isAuthenticated, hasRole('admin'), async (req, res) => {
try {
const [userRows] = await db.query('SELECT COUNT(*) as count FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"');
const currentKey = await cryptoUtil.getMasterKey();
res.json({
current_key: currentKey,
affected_records: {
users: userRows[0].count
}
});
} catch (err) {
res.status(500).json({ error: 'Failed to fetch encryption status' });
}
});
router.post('/encryption/rotate', isAuthenticated, hasRole('admin'), async (req, res) => {
const { new_key } = req.body;
if (!new_key) return res.status(400).json({ error: 'New key is required' });
const conn = await db.getConnection();
try {
await conn.beginTransaction();
const oldKey = await cryptoUtil.getMasterKey();
// 1. Migrate Users Table (phone)
const [users] = await conn.query('SELECT id, phone FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"');
for (const user of users) {
const decrypted = await cryptoUtil.decrypt(user.phone, oldKey);
const reEncrypted = await cryptoUtil.encrypt(decrypted, new_key);
await conn.query('UPDATE users SET phone = ? WHERE id = ?', [reEncrypted, user.id]);
}
// 2. Update Master Key in settings (Encrypted for DB storage)
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(new_key);
await conn.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
await conn.commit();
cryptoUtil.clearCache(); // Force immediate reload of new key
res.json({ success: true, message: 'Encryption key rotated and data migrated successfully.' });
} catch (err) {
await conn.rollback();
console.error('Rotation failed:', err);
res.status(500).json({ error: 'Key rotation failed: ' + err.message });
} finally {
conn.release();
}
});
// 0-1. Generic Setting Get/Set
router.get('/settings/:key', isAuthenticated, async (req, res) => {
const { key } = req.params;
if (!ALLOWED_SETTING_KEYS.includes(key)) {
return res.status(400).json({ error: 'Invalid setting key' });
}
try {
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = ?", [key]);
if (rows.length === 0) return res.json({ value: null });
res.json({ value: rows[0].setting_value });
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Database error' });
}
});
router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res) => {
const { key } = req.params;
const { value } = req.body;
if (!ALLOWED_SETTING_KEYS.includes(key)) {
return res.status(400).json({ error: 'Invalid setting key' });
}
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') {
stringValue = cryptoUtil.encryptMasterKey(stringValue);
}
await db.query(
`INSERT INTO system_settings (setting_key, setting_value)
VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`,
[key, stringValue]
);
res.json({ success: true, message: 'Setting saved' });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
res.status(500).json({ error: 'Database error' }); res.status(500).json({ error: 'Database error' });
@ -61,11 +294,12 @@ router.get('/modules', isAuthenticated, async (req, res) => {
const [rows] = await db.query('SELECT * FROM system_modules'); const [rows] = await db.query('SELECT * FROM system_modules');
const modules = {}; const modules = {};
const defaults = ['asset', 'production', 'monitoring']; const defaults = ['asset', 'production', 'cctv'];
// Get stored subscriber ID // Get stored subscriber ID
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'"); const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null; // Ensure we return null or empty string if not found, DO NOT use any hardcoded fallback
const serverSubscriberId = (subRows.length > 0 && subRows[0].setting_value) ? subRows[0].setting_value : '';
defaults.forEach(code => { defaults.forEach(code => {
const found = rows.find(r => r.code === code); const found = rows.find(r => r.code === code);
@ -109,7 +343,10 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
} }
// 2. Check Module match // 2. Check Module match
if (result.module !== code) { // Allow legacy 'monitoring' licenses to activate 'cctv' module
const isMatch = result.module === code || (code === 'cctv' && result.module === 'monitoring');
if (!isMatch) {
return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` }); return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` });
} }
@ -129,7 +366,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
} }
} else { } else {
if (result.subscriberId !== serverSubscriberId) { if (result.subscriberId !== serverSubscriberId) {
return res.status(403).json({ return res.status(400).json({
error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.` error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.`
}); });
} }
@ -162,7 +399,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
const names = { const names = {
'asset': '자산 관리', 'asset': '자산 관리',
'production': '생산 관리', 'production': '생산 관리',
'monitoring': 'CCTV' 'cctv': 'CCTV'
}; };
await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]); await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]);
@ -228,4 +465,316 @@ 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)
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';
}
}
// Prepare git fetch command
const auth = await getGiteaAuth();
let fetchCmd = 'git fetch --tags';
if (auth.user && auth.pass && auth.url && auth.url.includes('https://')) {
const authenticatedUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
fetchCmd = `git fetch ${authenticatedUrl} --tags --force --prune`;
} else if (auth.url) {
fetchCmd = `git fetch ${auth.url} --tags --force --prune`;
}
exec(fetchCmd, { cwd: projectRoot }, (err, stdout, stderr) => {
if (err) {
console.error('Git fetch failed:', err);
const sanitizedError = stderr.replace(/:[^@]+@/g, ':****@');
return res.json({
current: currentVersion,
latest: null,
history: [],
error: `원격 저장소 동기화 실패: ${sanitizedError || err.message}`
});
}
// 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}`);
res.json({
current: currentVersion,
latest: latestFromRemote ? `v${latestFromRemote.version}` : null,
needsUpdate: needsUpdate,
latestInfo: needsUpdate ? latestFromRemote : null,
history: history
});
});
});
} catch (err) {
console.error(err);
res.status(500).json({ error: '버전 정보를 가져오는 중 오류가 발생했습니다.' });
}
});
// 6. Execute System Update
// WARNING: This is a heavy operation and will reload the server
router.post('/version/update', isAuthenticated, hasRole('admin'), async (req, res) => {
const { targetTag } = req.body;
if (!targetTag) {
return res.status(400).json({ error: '업데이트할 대상 태그가 지정되지 않았습니다.' });
}
const auth = await getGiteaAuth();
const env = readEnv();
const isWindows = process.platform === 'win32';
const backupDir = isWindows ? './backup' : '/volume1/backup/smart_ims';
// 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)}@`);
}
const timestamp = new Date().toISOString().replace(/[:T]/g, '-').split('.')[0];
const dbName = env.DB_NAME || 'sokuree_platform_prod';
const dbUser = env.DB_USER || 'choibk';
const dbPass = env.DB_PASSWORD || '';
const dbPort = env.DB_PORT || '3307';
// 1. Generate Standalone Update Script Content
let scriptContent = '';
const scriptName = isWindows ? 'update_system.bat' : 'update_system.sh';
const scriptPath = path.join(__dirname, '../..', scriptName);
if (isWindows) {
scriptContent = `
@echo off
echo [Update] Starting update to ${targetTag}...
REM Ensure backup directory
set BACKUP_PATH=${backupDir}
if not exist "%BACKUP_PATH%" mkdir "%BACKUP_PATH%" 2>nul
if not exist "%BACKUP_PATH%" (
echo [Warning] Global backup failed, using local backup.
set BACKUP_PATH=.\\server\\backups
if not exist ".\\server\\backups" mkdir ".\\server\\backups"
)
echo [Update] Backing up Config...
if exist "server\\.env" (
copy /Y "server\\.env" "%BACKUP_PATH%\\.env.backup.${timestamp}"
copy /Y "server\\.env" "server\\.env.tmp"
)
echo [Update] Syncing Source Code...
git fetch "${remoteUrl}" --tags --force --prune
git checkout -f ${targetTag}
echo [Update] Restoring Config...
if exist "server\\.env.tmp" (
copy /Y "server\\.env.tmp" "server\\.env"
del "server\\.env.tmp"
) else if exist "%BACKUP_PATH%\\.env.backup.${timestamp}" (
copy /Y "%BACKUP_PATH%\\.env.backup.${timestamp}" "server\\.env"
)
echo [Update] Installing & Building...
call npm install
call npm run build
cd server
call npm install
echo [Update] Done.
`;
} else {
// Linux/Synology Script
const dumpTool = '/usr/local/mariadb10/bin/mysqldump';
scriptContent = `#!/bin/bash
exec > >(tee -a update.log) 2>&1
echo "[Update] Starting update to ${targetTag}..."
# Ensure backup directory
BACKUP_DIR="${backupDir}"
mkdir -p "$BACKUP_DIR" || {
echo "[Warning] Global backup failed, using local backup."
BACKUP_DIR="./server/backups"
mkdir -p "$BACKUP_DIR"
}
echo "[Update] Backing up Database..."
if [ -f "${dumpTool}" ]; then
echo "[Info] MySQL dump tool found. Attempting database backup..."
${dumpTool} -u ${dbUser} --password='${dbPass}' --port ${dbPort} ${dbName} > "$BACKUP_DIR/backup_db_${timestamp}.sql" 2>/dev/null
if [ $? -eq 0 ]; then
echo "[Info] Database backup successful."
else
echo "[Warning] Database backup failed, continuing..."
fi
else
echo "[Warning] MySQL dump tool not found. Skipping DB backup."
fi
echo "[Update] Backing up Config..."
if [ -f "server/.env" ]; then
echo "[Info] Backing up 'server/.env' to '$BACKUP_DIR/.env.backup.${timestamp}' and 'server/.env.tmp'."
cp "server/.env" "$BACKUP_DIR/.env.backup.${timestamp}"
cp "server/.env" "server/.env.tmp"
else
echo "[Warning] 'server/.env' not found. Skipping config backup."
fi
echo "[Update] Syncing Source Code..."
git remote set-url origin "${remoteUrl}"
git fetch origin --tags --force --prune
if [ $? -ne 0 ]; then
echo "[Error] Git fetch failed. Exiting update."
exit 1
fi
echo "[Info] Git fetch successful."
git checkout -f ${targetTag}
if [ $? -ne 0 ]; then
echo "[Error] Git checkout to ${targetTag} failed. Exiting update."
exit 1
fi
echo "[Info] Git checkout to ${targetTag} successful."
echo "[Update] Restoring Config..."
if [ -f "server/.env.tmp" ]; then
cp "server/.env.tmp" "server/.env"
rm "server/.env.tmp"
elif [ -f "$BACKUP_DIR/.env.backup.${timestamp}" ]; then
cp "$BACKUP_DIR/.env.backup.${timestamp}" "server/.env"
fi
echo "[Update] Installing & Building..."
npm install
npm run build
cd server
npm install
echo "[Update] Reloading PM2..."
pm2 reload smartims-api || echo "PM2 not found"
echo "[Update] Done."
`;
}
// 2. Write Script to File
try {
fs.writeFileSync(scriptPath, scriptContent, { encoding: 'utf8', mode: 0o755 });
} catch (err) {
console.error('Failed to create update script:', err);
return res.status(500).json({ error: '업데이트 스크립트 생성 실패' });
}
// 3. Execute Script Detached
console.log(`🚀 [Update] Spawning independent update process: ${scriptPath}`);
const child = require('child_process').spawn(
isWindows ? 'cmd.exe' : 'bash',
isWindows ? ['/c', scriptName] : [scriptName],
{
cwd: path.join(__dirname, '../..'),
detached: true,
stdio: 'ignore'
}
);
child.unref(); // Allow node process to exit/reload without killing this child
res.json({
success: true,
message: '업데이트 프로세스가 독립적으로 실행되었습니다. 시스템이 곧 재시작됩니다.'
});
});
module.exports = router; module.exports = router;

118
server/utils/cryptoUtil.js Normal file
View File

@ -0,0 +1,118 @@
const crypto = require('crypto');
const db = require('../db');
const ALGORITHM = 'aes-256-cbc';
const SYSTEM_INTERNAL_KEY = 'ims_system_l2_internal_protection_key_2026'; // 고정 시스템 키 (2단계 보안용)
let cachedKey = null;
const cryptoUtil = {
/**
* 내부 보안용 암호화 (마스터 보호용)
*/
_internalProcess(text, isEncrypt = true) {
if (!text) return text;
try {
const keyBuffer = crypto.scryptSync(SYSTEM_INTERNAL_KEY, 'ims_salt', 32);
if (isEncrypt) {
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
} else {
if (!text.includes(':')) return text; // 평문인 경우 그대로 반환
const [ivHex, cipherText] = text.split(':');
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
let decrypted = decipher.update(cipherText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
} catch (e) {
return text;
}
},
/**
* Get key from DB or Cache
*/
async getMasterKey() {
if (cachedKey) return cachedKey;
try {
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'encryption_key'");
if (rows.length > 0 && rows[0].setting_value) {
const rawValue = rows[0].setting_value;
// DB에 저장된 값이 암호화된 형태(iv 포함)라면 복호화하여 사용
if (rawValue.includes(':')) {
cachedKey = this._internalProcess(rawValue, false);
} else {
cachedKey = rawValue;
}
return cachedKey;
}
} catch (e) {
console.error('CryptoUtil: Failed to fetch key', e);
}
return process.env.ENCRYPTION_KEY || 'smartasset_secret_key_0123456789';
},
/**
* 마스터 키를 DB에 저장하기 암호화하는 함수
*/
encryptMasterKey(plainKey) {
return this._internalProcess(plainKey, true);
},
/**
* 내부 보안으로 암호화된 키를 복호화하는 함수
*/
decryptMasterKey(encryptedKey) {
return this._internalProcess(encryptedKey, false);
},
clearCache() {
cachedKey = null;
},
/**
* Encrypt with specific key (optional, defaults to master)
*/
async encrypt(text, customKey = null) {
if (!text) return text;
try {
const secret = customKey || await this.getMasterKey();
const keyBuffer = crypto.scryptSync(secret, 'salt', 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
let encrypted = cipher.update(text, 'utf8', 'hex');
encrypted += cipher.final('hex');
return iv.toString('hex') + ':' + encrypted;
} catch (e) {
console.error('Encryption failed:', e.message);
return text;
}
},
/**
* Decrypt with specific key (optional, defaults to master)
*/
async decrypt(text, customKey = null) {
if (!text || !text.includes(':')) return text;
try {
const secret = customKey || await this.getMasterKey();
const keyBuffer = crypto.scryptSync(secret, 'salt', 32);
const [ivHex, cipherText] = text.split(':');
if (!ivHex || ivHex.length !== 32) return text;
const iv = Buffer.from(ivHex, 'hex');
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
let decrypted = decipher.update(cipherText, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (e) {
return text;
}
}
};
module.exports = cryptoUtil;

View File

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

View File

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

View File

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

View File

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

View File

@ -6,24 +6,25 @@
height: 100%; height: 100%;
} }
.page-header { .page-header-right {
display: flex; display: flex;
justify-content: flex-start; flex-direction: column;
align-items: flex-end; align-items: flex-end;
text-align: left; text-align: right;
/* Explicitly set text align */ margin-bottom: 2rem;
width: 100%;
} }
.page-title-text { .page-title-text {
font-size: 1.5rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-primary); color: var(--sokuree-text-primary);
margin-bottom: 0.25rem; margin-bottom: 0.5rem;
} }
.page-subtitle { .page-subtitle {
color: var(--color-text-secondary); color: var(--sokuree-text-secondary);
font-size: 0.9rem; font-size: 1rem;
} }
.content-card { .content-card {

View File

@ -3,7 +3,8 @@ import { useLocation, useNavigate } from 'react-router-dom';
import { Card } from '../../../shared/ui/Card'; import { Card } from '../../../shared/ui/Card';
import { Button } from '../../../shared/ui/Button'; import { Button } from '../../../shared/ui/Button';
import { Input } from '../../../shared/ui/Input'; import { Input } from '../../../shared/ui/Input';
import { Search, Plus, Filter, Download, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight } from 'lucide-react'; import { Search, Plus, Filter, Download, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight, Printer } from 'lucide-react';
import { useAuth } from '../../../shared/auth/AuthContext';
import './AssetListPage.css'; import './AssetListPage.css';
import { getCategories } from './AssetSettingsPage'; import { getCategories } from './AssetSettingsPage';
@ -19,10 +20,14 @@ import { saveAs } from 'file-saver';
export function AssetListPage() { export function AssetListPage() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); 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 [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const itemsPerPage = 8; const itemsPerPage = 8;
// Fetch Assets // Fetch Assets
@ -33,10 +38,17 @@ export function AssetListPage() {
const loadAssets = async () => { const loadAssets = async () => {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null);
const data = await assetApi.getAll(); const data = await assetApi.getAll();
setAssets(data); if (Array.isArray(data)) {
setAssets(data);
} else {
console.error("API returned non-array data:", data);
setAssets([]);
}
} catch (error: any) { } catch (error: any) {
console.error("Failed to fetch assets:", error); console.error("Failed to fetch assets:", error);
setError("데이터를 불러오는 중 오류가 발생했습니다. 서버 연결을 확인해 주세요.");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
@ -98,8 +110,10 @@ export function AssetListPage() {
// Let's make it strict if possible, or robust for '설비' vs '설비 자산'. // Let's make it strict if possible, or robust for '설비' vs '설비 자산'.
if (asset.category !== currentCategory) { if (asset.category !== currentCategory) {
// Fallback for partial matches if needed, but let's try strict first based on user request. // partial match fallback (e.g., '시설' in '시설 자산' or vice versa)
if (!asset.category.includes(currentCategory)) return false; const assetCat = asset.category || '';
const targetCat = currentCategory || '';
if (!assetCat.includes(targetCat) && !targetCat.includes(assetCat)) return false;
} }
} }
@ -240,13 +254,11 @@ export function AssetListPage() {
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-header" style={{ justifyContent: 'flex-start', textAlign: 'left' }}> <div className="page-header-right">
<div> <h1 className="page-title-text">{getPageTitle()}</h1>
<h1 className="page-title-text">{getPageTitle()}</h1> <p className="page-subtitle">
<p className="page-subtitle"> {currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'} </p>
</p>
</div>
</div> </div>
<Card className="content-card"> <Card className="content-card">
@ -268,8 +280,11 @@ export function AssetListPage() {
> >
</Button> </Button>
<Button variant="secondary" icon={<Printer size={16} />} onClick={() => window.print()}></Button>
<Button variant="secondary" icon={<Download size={16} />} onClick={handleExcelDownload}> </Button> <Button variant="secondary" icon={<Download size={16} />} onClick={handleExcelDownload}> </Button>
<Button onClick={() => navigate('/asset/register')} icon={<Plus size={16} />}> </Button> {canRegister && (
<Button onClick={() => navigate('/asset/register')} icon={<Plus size={16} />}> </Button>
)}
{/* Filter Popup */} {/* Filter Popup */}
{isFilterOpen && ( {isFilterOpen && (
@ -395,9 +410,18 @@ export function AssetListPage() {
</td> </td>
</tr> </tr>
)) ))
) : error ? (
<tr>
<td colSpan={10} className="empty-state text-red-500">
<div className="flex flex-col items-center gap-2">
<span> {error}</span>
<Button size="sm" variant="secondary" onClick={loadAssets}> </Button>
</div>
</td>
</tr>
) : ( ) : (
<tr> <tr>
<td colSpan={9} className="empty-state"> <td colSpan={10} className="empty-state">
. .
</td> </td>
</tr> </tr>

View File

@ -0,0 +1,43 @@
/* AssetRegisterPage.css */
/* Page Header - Right Aligned */
.page-header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
text-align: right;
margin-bottom: 2rem;
width: 100%;
padding: 0 0.5rem;
}
.page-title-text {
font-size: 1.75rem;
font-weight: 700;
color: var(--sokuree-text-primary);
margin-bottom: 0.5rem;
}
.page-subtitle {
font-size: 1rem;
color: var(--sokuree-text-secondary);
}
/* Bottom Actions Styling */
.form-actions-footer {
display: flex;
justify-content: center;
align-items: center;
gap: 1.5rem;
margin-top: 3rem;
margin-bottom: 2rem;
padding-top: 2rem;
border-top: 1px solid var(--sokuree-border-color);
width: 100%;
}
.form-actions-footer .ui-btn {
min-width: 120px;
}

View File

@ -7,6 +7,7 @@ import { Select } from '../../../shared/ui/Select';
import { ArrowLeft, Save, Upload } from 'lucide-react'; import { ArrowLeft, Save, Upload } from 'lucide-react';
import { getCategories, getLocations, getIDRule } from './AssetSettingsPage'; import { getCategories, getLocations, getIDRule } from './AssetSettingsPage';
import { assetApi, type Asset } from '../../../shared/api/assetApi'; import { assetApi, type Asset } from '../../../shared/api/assetApi';
import './AssetRegisterPage.css';
export function AssetRegisterPage() { export function AssetRegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
@ -18,6 +19,7 @@ export function AssetRegisterPage() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
id: '', // Asset ID (Auto-generated) id: '', // Asset ID (Auto-generated)
parentId: '',
name: '', name: '',
categoryId: '', // Use ID for selection categoryId: '', // Use ID for selection
model: '', model: '',
@ -32,6 +34,20 @@ export function AssetRegisterPage() {
manufacturer: '' manufacturer: ''
}); });
const [allAssets, setAllAssets] = useState<Asset[]>([]);
useEffect(() => {
const loadAllAssets = async () => {
try {
const data = await assetApi.getAll();
setAllAssets(data);
} catch (err) {
console.error("Failed to load assets for parent selection", err);
}
};
loadAllAssets();
}, []);
// Auto-generate Asset ID // Auto-generate Asset ID
useEffect(() => { useEffect(() => {
// If category is required by rule but not selected, can't generate fully // If category is required by rule but not selected, can't generate fully
@ -51,13 +67,11 @@ export function AssetRegisterPage() {
if (part.type === 'separator') return part.value; if (part.type === 'separator') return part.value;
if (part.type === 'year') return year; if (part.type === 'year') return year;
if (part.type === 'category') return category ? category.code : 'UNKNOWN'; if (part.type === 'category') return category ? category.code : 'UNKNOWN';
if (part.type === 'sequence') return part.value; // In real app, we fetch next seq. here we just show the format pattern e.g. 001 if (part.type === 'sequence') return part.value;
return ''; return '';
}).join(''); }).join('');
// Ideally we would fetch the *actual* next sequence from API here. const finalId = generatedId.replace('001', '001');
// For now we assume '001' as a placeholder or "Generating..."
const finalId = generatedId.replace('001', '001'); // Just keeping the placeholder visible for user confirmation
setFormData(prev => ({ ...prev, id: finalId })); setFormData(prev => ({ ...prev, id: finalId }));
@ -87,7 +101,7 @@ export function AssetRegisterPage() {
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); if (e) e.preventDefault();
// Validation // Validation
if (!formData.categoryId || !formData.name || !formData.locationId) { if (!formData.categoryId || !formData.name || !formData.locationId) {
@ -96,11 +110,9 @@ export function AssetRegisterPage() {
} }
try { try {
// Map IDs to Names/Codes for Backend
const selectedCategory = categories.find(c => c.id === formData.categoryId); const selectedCategory = categories.find(c => c.id === formData.categoryId);
const selectedLocation = locations.find(l => l.id === formData.locationId); const selectedLocation = locations.find(l => l.id === formData.locationId);
// Upload Image if exists
let imageUrl = ''; let imageUrl = '';
if (formData.image) { if (formData.image) {
const uploadRes = await assetApi.uploadImage(formData.image); const uploadRes = await assetApi.uploadImage(formData.image);
@ -109,8 +121,9 @@ export function AssetRegisterPage() {
const payload: Partial<Asset> = { const payload: Partial<Asset> = {
id: formData.id, id: formData.id,
parentId: isFacility ? formData.parentId : undefined,
name: formData.name, name: formData.name,
category: selectedCategory ? selectedCategory.name : '미지정', // Backend expects name category: selectedCategory ? selectedCategory.name : '미지정',
model: formData.model, model: formData.model,
serialNumber: formData.serialNo, serialNumber: formData.serialNo,
location: selectedLocation ? selectedLocation.name : '미지정', location: selectedLocation ? selectedLocation.name : '미지정',
@ -132,198 +145,228 @@ export function AssetRegisterPage() {
} }
}; };
const isFacility = categories.find(c => c.id === formData.categoryId)?.name === '설비';
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-header"> <div className="page-header-right">
<div> <h1 className="page-title-text"> </h1>
<h1 className="page-title-text"> </h1> <p className="page-subtitle"> .</p>
<p className="page-subtitle"> .</p>
</div>
<div className="header-actions-row">
<Button variant="secondary" onClick={() => navigate(-1)} icon={<ArrowLeft size={16} />}></Button>
<Button onClick={handleSubmit} icon={<Save size={16} />}></Button>
</div>
</div> </div>
<Card className="w-full h-full shadow-sm border border-slate-200"> <Card className="w-full shadow-sm border border-slate-200 mb-8">
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6"> <form onSubmit={handleSubmit} className="p-2 sm:p-4 lg:p-6">
{/* Basic Info */} <div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
<div className="col-span-full border-b border-slate-100 pb-2 mb-2"> {/* Basic Info Section */}
<h3 className="text-lg font-semibold text-slate-800"> </h3> <div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2">
</div> <div className="w-1 h-6 bg-blue-500 rounded-full"></div>
<h3 className="text-xl font-bold text-slate-800"> </h3>
</div>
<Select <Select
label="카테고리 *" label="카테고리 *"
name="categoryId" name="categoryId"
value={formData.categoryId} value={formData.categoryId}
onChange={handleChange} onChange={handleChange}
options={categories.map(c => ({ label: c.name, value: c.id }))} options={categories.map(c => ({ label: c.name, value: c.id }))}
placeholder="카테고리 선택" placeholder="카테고리 선택"
required required
/> />
<Input {isFacility && (
label="자산 관리 번호 (자동 생성)" <Select
name="id" label="상위 설비 (메인 설비)"
value={formData.id} name="parentId"
disabled value={formData.parentId}
placeholder="카테고리 선택 시 자동 생성됨" onChange={handleChange}
className="bg-slate-50 font-mono text-slate-600" options={[
/> { label: '(없음 - 메인 설비로 등록)', value: '' },
...allAssets
.filter(a => a.category === '설비' && a.id !== formData.id)
.map(a => ({ label: `[${a.id}] ${a.name}`, value: a.id }))
]}
placeholder="메인 설비가 있는 경우 선택"
/>
)}
<Input <Input
label="자산명 *" label="자산 관리 번호 (자동 생성)"
name="name" name="id"
value={formData.name} value={formData.id}
onChange={handleChange} disabled
placeholder="예: CNC 머시닝 센터" placeholder="카테고리 선택 시 자동 생성됨"
required className="bg-slate-50 font-mono text-slate-600"
/> />
<Input <Input
label="제작사" label="자산명 *"
name="manufacturer" name="name"
value={formData.manufacturer} value={formData.name}
onChange={handleChange} onChange={handleChange}
/> placeholder="예: CNC 머시닝 센터"
required
/>
<Input <Input
label="모델명" label="제작사"
name="model" name="manufacturer"
value={formData.model} value={formData.manufacturer}
onChange={handleChange} onChange={handleChange}
/> />
<Input <Input
label="시리얼 번호 / 규격" label="모델명"
name="serialNo" name="model"
value={formData.serialNo} value={formData.model}
onChange={handleChange} onChange={handleChange}
/> />
{/* Image Upload Field */} <Input
<div className="ui-field-container col-span-full"> label="시리얼 번호 / 규격"
<label className="ui-label"> </label> name="serialNo"
<div className="w-full bg-white border border-slate-200 rounded-md shadow-sm p-4 flex flex-col items-start gap-4" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '16px' }}> value={formData.serialNo}
{/* Preview Area - Fixed size container */} onChange={handleChange}
<div />
className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
style={{ {/* Image Upload Field */}
width: '400px', <div className="ui-field-container col-span-full">
height: '350px', <label className="ui-label"> </label>
display: 'flex', <div className="w-full bg-white border border-slate-200 rounded-md shadow-sm p-4 flex flex-col items-start gap-4" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '16px' }}>
alignItems: 'center', <div
justifyContent: 'center' className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
}} style={{
> width: '400px',
{formData.imagePreview ? ( height: '350px',
<img display: 'flex',
src={formData.imagePreview} alignItems: 'center',
alt="Preview" justifyContent: 'center'
className="w-full h-full object-contain" }}
>
{formData.imagePreview ? (
<img
src={formData.imagePreview}
alt="Preview"
className="w-full h-full object-contain"
/>
) : (
<div
className="text-slate-300"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%'
}}
>
<Upload size={32} strokeWidth={1.5} />
</div>
)}
</div>
<div className="flex flex-row items-center flex-wrap w-full max-w-[600px]" style={{ gap: '20px' }}>
<label htmlFor="image-upload" className="ui-btn ui-btn-sm ui-btn-secondary cursor-pointer shrink-0">
</label>
<span className="text-sm text-slate-600 font-medium truncate max-w-[200px]">
{formData.image ? formData.image.name : '선택된 파일 없음'}
</span>
<span className="text-sm text-slate-400 border-l border-slate-300 pl-4" style={{ paddingLeft: '20px' }}>
형식: JPG, PNG, GIF ( 5MB)
</span>
<input
id="image-upload"
type="file"
style={{ display: 'none' }}
accept="image/*"
onChange={handleImageChange}
/> />
) : ( </div>
<div
className="text-slate-300"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: '100%'
}}
>
<Upload size={32} strokeWidth={1.5} />
</div>
)}
</div>
{/* Upload Controls - Moved below */}
<div className="flex flex-row items-center flex-wrap w-full max-w-[600px]" style={{ gap: '20px' }}>
<label htmlFor="image-upload" className="ui-btn ui-btn-sm ui-btn-secondary cursor-pointer shrink-0">
</label>
<span className="text-sm text-slate-600 font-medium truncate max-w-[200px]">
{formData.image ? formData.image.name : '선택된 파일 없음'}
</span>
<span className="text-sm text-slate-400 border-l border-slate-300 pl-4" style={{ paddingLeft: '20px' }}>
형식: JPG, PNG, GIF ( 5MB)
</span>
<input
id="image-upload"
type="file"
style={{ display: 'none' }}
accept="image/*"
onChange={handleImageChange}
/>
</div> </div>
</div> </div>
{/* Management Info Section */}
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2 mt-8">
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
<h3 className="text-xl font-bold text-slate-800"> </h3>
</div>
<Select
label="설치 위치 / 보관 장소 *"
name="locationId"
value={formData.locationId}
onChange={handleChange}
options={locations.map(l => ({ label: l.name, value: l.id }))}
placeholder="위치 선택"
required
/>
<Input
label="관리자 / 부서"
name="manager"
value={formData.manager}
onChange={handleChange}
/>
<Select
label="초기 상태"
name="status"
value={formData.status}
onChange={handleChange}
options={[
{ label: '정상 가동 (Active)', value: 'active' },
{ label: '대기 (Idle)', value: 'idle' },
{ label: '설치 중 (Installing)', value: 'installing' },
{ label: '점검 중 (Maintenance)', value: 'maintain' }
]}
/>
<div className="col-span-1"></div>
{/* Purchasing Info Section */}
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2 mt-8">
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
<h3 className="text-xl font-bold text-slate-800"> </h3>
</div>
<Input
label="도입일"
name="purchaseDate"
type="date"
value={formData.purchaseDate}
onChange={handleChange}
/>
<Input
label="구입 가격 (KRW)"
name="purchasePrice"
type="number"
value={formData.purchasePrice}
onChange={handleChange}
placeholder="0"
/>
</div> </div>
{/* Bottom Action Buttons */}
{/* Management Info */} <div className="form-actions-footer">
<div className="col-span-full border-b border-slate-100 pb-2 mb-2 mt-4"> <Button
<h3 className="text-lg font-semibold text-slate-800"> </h3> variant="secondary"
onClick={() => navigate(-1)}
icon={<ArrowLeft size={16} />}
>
</Button>
<Button
onClick={handleSubmit}
icon={<Save size={16} />}
>
</Button>
</div> </div>
<Select
label="설치 위치 / 보관 장소 *"
name="locationId"
value={formData.locationId}
onChange={handleChange}
options={locations.map(l => ({ label: l.name, value: l.id }))}
placeholder="위치 선택"
required
/>
<Input
label="관리자 / 부서"
name="manager"
value={formData.manager}
onChange={handleChange}
/>
<Select
label="초기 상태"
name="status"
value={formData.status}
onChange={handleChange}
options={[
{ label: '정상 가동 (Active)', value: 'active' },
{ label: '대기 (Idle)', value: 'idle' },
{ label: '설치 중 (Installing)', value: 'installing' },
{ label: '점검 중 (Maintenance)', value: 'maintain' }
]}
/>
<div className="col-span-1"></div>
{/* Purchasing Info */}
<div className="col-span-full border-b border-slate-100 pb-2 mb-2 mt-4">
<h3 className="text-lg font-semibold text-slate-800"> </h3>
</div>
<Input
label="도입일"
name="purchaseDate"
type="date"
value={formData.purchaseDate}
onChange={handleChange}
/>
<Input
label="구입 가격 (KRW)"
name="purchasePrice"
type="number"
value={formData.purchasePrice}
onChange={handleChange}
placeholder="0"
/>
</form> </form>
</Card> </Card>
</div> </div>

View File

@ -1,45 +1,45 @@
/* Page Header adjustments for Settings */ /* Page Header - Right Aligned */
.page-header { .page-header-right {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; align-items: flex-end;
padding-bottom: 0 !important; text-align: right;
/* Override default bottom padding */ margin-bottom: 2rem;
border-bottom: 1px solid var(--color-border); width: 100%;
} }
.header-top { .page-title-text {
padding-bottom: 0.5rem; font-size: 1.75rem;
}
/* Settings Tabs */
.settings-tabs {
display: flex;
gap: 1.5rem;
margin-bottom: -1px;
/* Overlap border */
}
.settings-tab {
padding: 0.75rem 0.5rem;
font-size: 0.95rem;
font-weight: 500;
color: var(--color-text-secondary);
background: transparent;
border: none;
border-bottom: 3px solid transparent;
cursor: pointer;
transition: all 0.2s;
}
.settings-tab:hover {
color: var(--color-brand-primary);
}
.settings-tab.active {
color: var(--color-brand-primary);
font-weight: 700; font-weight: 700;
border-bottom-color: var(--color-brand-primary); color: var(--sokuree-text-primary);
margin-bottom: 0.5rem;
}
.page-subtitle {
font-size: 1rem;
color: var(--sokuree-text-secondary);
}
/* Card Header Styles within Settings */
.card-header {
margin-bottom: 1.5rem;
}
.card-title {
font-size: 1.1rem;
font-weight: 700;
color: var(--sokuree-text-primary);
margin-bottom: 0.25rem;
}
.card-desc {
font-size: 0.95rem;
color: var(--sokuree-text-secondary);
}
/* Local tabs are now hidden and moved to Global TopHeader */
.settings-tabs {
display: none;
} }
/* Common Layout */ /* Common Layout */
@ -66,8 +66,8 @@
/* Table Styles */ /* Table Styles */
.table-wrapper { .table-wrapper {
border: 1px solid var(--color-border); border: 1px solid var(--sokuree-border-color);
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
overflow: hidden; overflow: hidden;
} }
@ -83,13 +83,13 @@
font-weight: 600; font-weight: 600;
text-align: left; text-align: left;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--sokuree-border-color);
} }
.settings-table td { .settings-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
color: var(--color-text-primary); color: var(--sokuree-text-primary);
vertical-align: middle; vertical-align: middle;
} }
@ -144,7 +144,7 @@
.preview-box { .preview-box {
background-color: #f1f5f9; background-color: #f1f5f9;
padding: 1rem; padding: 1rem;
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.75rem; gap: 0.75rem;
@ -179,7 +179,7 @@
min-height: 60px; min-height: 60px;
padding: 0.5rem; padding: 0.5rem;
border: 2px dashed #e2e8f0; border: 2px dashed #e2e8f0;
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
background-color: #fcfcfc; background-color: #fcfcfc;
} }
@ -282,14 +282,16 @@
height: 32px; height: 32px;
/* Match button height (sm size) */ /* Match button height (sm size) */
padding: 0 0.5rem; padding: 0 0.5rem;
border: 1px solid var(--color-border); border: 1px solid var(--sokuree-border-color);
border-radius: var(--radius-sm); border-radius: var(--sokuree-radius-sm);
font-size: 0.875rem; font-size: 0.875rem;
width: 150px; width: 150px;
outline: none; outline: none;
transition: border-color 0.2s; transition: border-color 0.2s;
background-color: white;
} }
.custom-text-input:focus { .custom-text-input:focus {
border-color: var(--color-brand-primary); border-color: var(--sokuree-brand-primary);
box-shadow: 0 0 0 2px rgba(var(--sokuree-brand-primary-rgb), 0.1);
} }

View File

@ -3,7 +3,8 @@ import { useSearchParams } from 'react-router-dom';
import { Card } from '../../../shared/ui/Card'; import { Card } from '../../../shared/ui/Card';
import { Button } from '../../../shared/ui/Button'; import { Button } from '../../../shared/ui/Button';
import { Input } from '../../../shared/ui/Input'; import { Input } from '../../../shared/ui/Input';
import { Plus, Trash2, Edit2, X, Check, GripVertical } from 'lucide-react'; import { Plus, Trash2, Edit2, X, Check, GripVertical, Save, Loader2 } from 'lucide-react';
import { apiClient } from '../../../shared/api/client';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
import type { DragEndEvent } from '@dnd-kit/core'; import type { DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
@ -76,6 +77,7 @@ function SortableRuleItem({ id, children, className }: { id: string; children: R
// --------------------- // ---------------------
// Types
// Types // Types
interface AssetCategory { interface AssetCategory {
id: string; id: string;
@ -96,7 +98,7 @@ interface AssetStatus {
color: string; color: string;
} }
type IDRuleComponentType = 'company' | 'category' | 'year' | 'sequence' | 'separator' | 'custom'; type IDRuleComponentType = 'company' | 'category' | 'year' | 'month' | 'sequence' | 'separator' | 'custom';
interface IDRuleComponent { interface IDRuleComponent {
id: string; id: string;
@ -105,7 +107,13 @@ interface IDRuleComponent {
label: string; label: string;
} }
// Initial Mock Data export interface AssetMaintenanceType {
id: string;
name: string;
color: string;
}
// Internal values for initial fallback
let GLOBAL_CATEGORIES: AssetCategory[] = [ let GLOBAL_CATEGORIES: AssetCategory[] = [
{ id: '1', name: '설비', code: 'FAC', menuLink: 'facilities' }, { id: '1', name: '설비', code: 'FAC', menuLink: 'facilities' },
{ id: '2', name: '공구', code: 'TOL', menuLink: 'tools' }, { id: '2', name: '공구', code: 'TOL', menuLink: 'tools' },
@ -130,7 +138,7 @@ let GLOBAL_STATUSES: AssetStatus[] = [
]; ];
let GLOBAL_ID_RULE: IDRuleComponent[] = [ let GLOBAL_ID_RULE: IDRuleComponent[] = [
{ id: 'r1', type: 'company', value: 'HK', label: '회사약어' }, { id: 'r1', type: 'company', value: 'SKR', label: '회사약어' },
{ id: 'r2', type: 'separator', value: '-', label: '구분자' }, { id: 'r2', type: 'separator', value: '-', label: '구분자' },
{ id: 'r3', type: 'category', value: '', label: '카테고리' }, { id: 'r3', type: 'category', value: '', label: '카테고리' },
{ id: 'r4', type: 'separator', value: '-', label: '구분자' }, { id: 'r4', type: 'separator', value: '-', label: '구분자' },
@ -139,12 +147,6 @@ let GLOBAL_ID_RULE: IDRuleComponent[] = [
{ id: 'r7', type: 'sequence', value: '001', label: '일련번호(3자리)' }, { id: 'r7', type: 'sequence', value: '001', label: '일련번호(3자리)' },
]; ];
export interface AssetMaintenanceType {
id: string;
name: string;
color: string;
}
let GLOBAL_MAINTENANCE_TYPES: AssetMaintenanceType[] = [ let GLOBAL_MAINTENANCE_TYPES: AssetMaintenanceType[] = [
{ id: '1', name: '정기점검', color: 'success' }, { id: '1', name: '정기점검', color: 'success' },
{ id: '2', name: '수리', color: 'danger' }, { id: '2', name: '수리', color: 'danger' },
@ -170,6 +172,68 @@ export function AssetSettingsPage() {
const [maintenanceTypes, setMaintenanceTypes] = useState<AssetMaintenanceType[]>(GLOBAL_MAINTENANCE_TYPES); const [maintenanceTypes, setMaintenanceTypes] = useState<AssetMaintenanceType[]>(GLOBAL_MAINTENANCE_TYPES);
const [idRule, setIdRule] = useState<IDRuleComponent[]>(GLOBAL_ID_RULE); const [idRule, setIdRule] = useState<IDRuleComponent[]>(GLOBAL_ID_RULE);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
// Initial Load
React.useEffect(() => {
const fetchAllSettings = async () => {
setIsLoading(true);
try {
const keys = ['asset_id_rule', 'asset_categories', 'asset_locations', 'asset_statuses', 'asset_maintenance_types'];
const results = await Promise.all(keys.map(k => apiClient.get(`/system/settings/${k}`)));
results.forEach((res, idx) => {
const key = keys[idx];
const val = res.data.value;
if (val) {
const parsed = JSON.parse(val);
if (key === 'asset_id_rule') {
setIdRule(parsed);
GLOBAL_ID_RULE = parsed;
// Update company code input from the rule if available
const comp = parsed.find((p: any) => p.type === 'company');
if (comp) setCompanyCodeInput(comp.value);
}
if (key === 'asset_categories') { setCategories(parsed); GLOBAL_CATEGORIES = parsed; }
if (key === 'asset_locations') { setLocations(parsed); GLOBAL_LOCATIONS = parsed; }
if (key === 'asset_statuses') { setStatuses(parsed); GLOBAL_STATUSES = parsed; }
if (key === 'asset_maintenance_types') { setMaintenanceTypes(parsed); GLOBAL_MAINTENANCE_TYPES = parsed; }
}
});
} catch (err) {
console.error('Failed to load settings:', err);
} finally {
setIsLoading(false);
}
};
fetchAllSettings();
}, []);
const handleSaveSettings = async (currentTab: string) => {
setIsSaving(true);
try {
let key = '';
let value: any = null;
if (currentTab === 'basic') { key = 'asset_id_rule'; value = idRule; }
else if (currentTab === 'category') { key = 'asset_categories'; value = categories; }
else if (currentTab === 'location') { key = 'asset_locations'; value = locations; }
else if (currentTab === 'status') { key = 'asset_statuses'; value = statuses; }
else if (currentTab === 'maintenance') { key = 'asset_maintenance_types'; value = maintenanceTypes; }
if (key) {
await apiClient.post(`/system/settings/${key}`, { value });
alert('설정이 저장되었습니다.');
}
} catch (err) {
console.error('Save failed:', err);
alert('저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
// Form inputs // Form inputs
const [newCategoryName, setNewCategoryName] = useState(''); const [newCategoryName, setNewCategoryName] = useState('');
const [newCategoryCode, setNewCategoryCode] = useState(''); const [newCategoryCode, setNewCategoryCode] = useState('');
@ -445,6 +509,12 @@ export function AssetSettingsPage() {
setCustomRuleText(''); setCustomRuleText('');
}; };
const updateCompanyValue = (newVal: string) => {
const updated = idRule.map(part => part.type === 'company' ? { ...part, value: newVal } : part);
setIdRule(updated);
GLOBAL_ID_RULE = updated;
};
const removeRuleComponent = (id: string) => { const removeRuleComponent = (id: string) => {
const updated = idRule.filter(comp => comp.id !== id); const updated = idRule.filter(comp => comp.id !== id);
setIdRule(updated); setIdRule(updated);
@ -453,10 +523,12 @@ export function AssetSettingsPage() {
const getPreviewId = () => { const getPreviewId = () => {
const now = new Date();
const mockData = { const mockData = {
company: 'HK', company: 'HK', // Fallback
category: 'FAC', category: 'FAC',
year: new Date().getFullYear().toString(), year: now.getFullYear().toString(),
month: String(now.getMonth() + 1).padStart(2, '0'),
sequence: '001', sequence: '001',
separator: '-' separator: '-'
}; };
@ -466,22 +538,33 @@ export function AssetSettingsPage() {
if (part.type === 'company') return part.value; if (part.type === 'company') return part.value;
if (part.type === 'category') return mockData.category; if (part.type === 'category') return mockData.category;
if (part.type === 'year') return mockData.year; if (part.type === 'year') return mockData.year;
if (part.type === 'month') return mockData.month;
if (part.type === 'sequence') return part.value; if (part.type === 'sequence') return part.value;
if (part.type === 'separator') return part.value; if (part.type === 'separator') return part.value;
return ''; return '';
}).join(''); }).join('');
}; };
if (isLoading) return <div className="p-12 text-center text-slate-500 flex flex-col items-center gap-4">
<Loader2 size={32} className="animate-spin" />
...
</div>;
return ( return (
<div className="page-container"> <div className="page-container h-full flex flex-col">
<div className="settings-content mt-4"> <div className="page-header-right">
<h1 className="page-title-text"> </h1>
<p className="page-subtitle"> .</p>
</div>
<div className="settings-content mt-4 flex-1">
<DndContext <DndContext
sensors={sensors} sensors={sensors}
collisionDetection={closestCenter} collisionDetection={closestCenter}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
{activeTab === 'basic' && ( {activeTab === 'basic' && (
<Card className="settings-card max-w-5xl"> <Card className="settings-card w-full">
<div className="card-header"> <div className="card-header">
<h2 className="card-title"> </h2> <h2 className="card-title"> </h2>
<p className="card-desc"> .</p> <p className="card-desc"> .</p>
@ -518,40 +601,57 @@ export function AssetSettingsPage() {
<div className="rule-tools"> <div className="rule-tools">
<h4 className="tools-title"> </h4> <h4 className="tools-title"> </h4>
<div className="tools-grid"> <div className="tools-grid">
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: 'auto' }}> <input
<input type="text"
type="text" placeholder="약어 (예: SKR)"
placeholder="약어 (예: HK)" value={companyCodeInput}
value={companyCodeInput} onChange={e => {
onChange={e => setCompanyCodeInput(e.target.value.toUpperCase())} const val = e.target.value.toUpperCase();
className="custom-text-input" setCompanyCodeInput(val);
maxLength={5} updateCompanyValue(val);
style={{ width: '100px' }} }}
/> className="custom-text-input"
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('company', companyCodeInput, `회사약어 (${companyCodeInput})`)}> ({companyCodeInput})</Button> maxLength={5}
</div> style={{ width: '80px' }}
/>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('company', companyCodeInput, '회사약어')}> ({companyCodeInput})</Button>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('category', '', '카테고리')}></Button> <Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('category', '', '카테고리')}></Button>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('year', 'YYYY', '등록년도')}></Button> <Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('year', 'YYYY', '등록년도')}></Button>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('month', 'MM', '월')}> (MM)</Button>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('sequence', '001', '일련번호')}></Button> <Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('sequence', '001', '일련번호')}></Button>
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('separator', '-', '구분자 (-)')}> (-)</Button> <Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('separator', '-', '구분자')}> (-)</Button>
<div className="custom-tool">
<input <input
type="text" type="text"
placeholder="사용자 정의 텍스트" placeholder="사용자 정의 텍스트"
value={customRuleText} value={customRuleText}
onChange={e => setCustomRuleText(e.target.value)} onChange={e => setCustomRuleText(e.target.value)}
className="custom-text-input" className="custom-text-input"
/> style={{ marginLeft: '0.5rem', width: '130px' }}
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('custom', '', '사용자 정의')}></Button> />
</div> <Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('custom', '', '사용자 정의')}></Button>
</div> </div>
</div> </div>
{/* Bottom Action */}
<div className="mt-12 flex justify-end">
<Button
variant="primary"
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
disabled={isSaving}
onClick={() => handleSaveSettings('basic')}
className="w-24"
>
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
</div> </div>
</Card> </Card>
)} )}
{activeTab === 'category' && ( {activeTab === 'category' && (
<Card className="settings-card max-w-4xl"> <Card className="settings-card w-full">
<div className="card-header"> <div className="card-header">
<h2 className="card-title"> </h2> <h2 className="card-title"> </h2>
<p className="card-desc"> . ( )</p> <p className="card-desc"> . ( )</p>
@ -666,12 +766,25 @@ export function AssetSettingsPage() {
</SortableContext> </SortableContext>
</table> </table>
</div> </div>
{/* Bottom Action */}
<div className="mt-8 flex justify-end">
<Button
variant="primary"
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
disabled={isSaving}
onClick={() => handleSaveSettings('category')}
className="w-32"
>
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
</div> </div>
</Card> </Card>
)} )}
{activeTab === 'location' && ( {activeTab === 'location' && (
<Card className="settings-card max-w-3xl"> <Card className="settings-card w-full">
<div className="card-header"> <div className="card-header">
<h2 className="card-title"> / </h2> <h2 className="card-title"> / </h2>
<p className="card-desc"> .</p> <p className="card-desc"> .</p>
@ -735,12 +848,25 @@ export function AssetSettingsPage() {
</SortableContext> </SortableContext>
</table> </table>
</div> </div>
{/* Bottom Action */}
<div className="mt-8 flex justify-end">
<Button
variant="primary"
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
disabled={isSaving}
onClick={() => handleSaveSettings('location')}
className="w-32"
>
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
</div> </div>
</Card> </Card>
)} )}
{activeTab === 'status' && ( {activeTab === 'status' && (
<Card className="settings-card max-w-5xl"> <Card className="settings-card w-full">
<div className="card-header"> <div className="card-header">
<h2 className="card-title"> </h2> <h2 className="card-title"> </h2>
<p className="card-desc"> (, , ) .</p> <p className="card-desc"> (, , ) .</p>
@ -834,12 +960,25 @@ export function AssetSettingsPage() {
</SortableContext> </SortableContext>
</table> </table>
</div> </div>
{/* Bottom Action */}
<div className="mt-8 flex justify-end">
<Button
variant="primary"
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
disabled={isSaving}
onClick={() => handleSaveSettings('status')}
className="w-32"
>
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
</div> </div>
</Card> </Card>
)} )}
{activeTab === 'maintenance' && ( {activeTab === 'maintenance' && (
<Card className="settings-card max-w-4xl"> <Card className="settings-card w-full">
<div className="card-header"> <div className="card-header">
<h2 className="card-title"> </h2> <h2 className="card-title"> </h2>
<p className="card-desc"> .</p> <p className="card-desc"> .</p>
@ -923,6 +1062,19 @@ export function AssetSettingsPage() {
</SortableContext> </SortableContext>
</table> </table>
</div> </div>
{/* Bottom Action */}
<div className="mt-8 flex justify-end">
<Button
variant="primary"
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
disabled={isSaving}
onClick={() => handleSaveSettings('maintenance')}
className="w-32"
>
{isSaving ? '저장 중...' : '저장'}
</Button>
</div>
</div> </div>
</Card> </Card>
)} )}

View File

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

View File

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

View File

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

View File

@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { createPortal } from 'react-dom';
import { apiClient } from '../../../shared/api/client'; import { apiClient } from '../../../shared/api/client';
import { JSMpegPlayer } from '../components/JSMpegPlayer'; import { JSMpegPlayer } from '../components/JSMpegPlayer';
import { Plus, Settings, Trash2, X, Video } from 'lucide-react'; import { Video, LayoutGrid, ChevronDown } from 'lucide-react';
import { useAuth } from '../../../shared/auth/AuthContext'; import { useAuth } from '../../../shared/auth/AuthContext';
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core'; import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy } from '@dnd-kit/sortable'; import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy } from '@dnd-kit/sortable';
@ -20,6 +21,7 @@ interface Camera {
quality?: 'low' | 'medium' | 'original'; quality?: 'low' | 'medium' | 'original';
display_order?: number; display_order?: number;
is_active?: boolean | number; is_active?: boolean | number;
zone?: string;
} }
// Wrap Camera Card with Sortable // Wrap Camera Card with Sortable
@ -38,7 +40,7 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr
}; };
return ( return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="bg-white rounded-xl shadow-lg overflow-hidden border border-slate-200"> <div ref={setNodeRef} style={style} {...attributes} {...listeners} className={`relative bg-black overflow-hidden border ${disabled ? 'border-slate-800' : 'border-slate-700 hover:border-indigo-500'} transition-colors group h-full w-full`}>
{children} {children}
</div> </div>
); );
@ -47,22 +49,12 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr
export function MonitoringPage() { export function MonitoringPage() {
const { user } = useAuth(); const { user } = useAuth();
const [cameras, setCameras] = useState<Camera[]>([]); const [cameras, setCameras] = useState<Camera[]>([]);
const [showForm, setShowForm] = useState(false); const [activeZone, setActiveZone] = useState<string>('');
const [editingCamera, setEditingCamera] = useState<Camera | null>(null); const [availableZones, setAvailableZones] = useState<{ name: string, layout: string }[]>([]);
const [formData, setFormData] = useState<Partial<Camera>>({ const [showLayoutMenu, setShowLayoutMenu] = useState(false);
name: '', const fetchedRef = useRef(false);
ip_address: '',
port: 554,
username: '',
password: '',
stream_path: '/stream1',
transport_mode: 'tcp',
rtsp_encoding: false,
quality: 'low'
});
const [loading, setLoading] = useState(false);
const [streamVersions, setStreamVersions] = useState<{ [key: number]: number }>({}); const [streamVersions] = useState<{ [key: number]: number }>({});
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor), useSensor(PointerSensor),
@ -72,6 +64,8 @@ export function MonitoringPage() {
); );
useEffect(() => { useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
fetchCameras(); fetchCameras();
}, []); }, []);
@ -84,61 +78,49 @@ export function MonitoringPage() {
} }
}; };
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { const fetchZones = async () => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
try { try {
if (editingCamera) { const res = await apiClient.get('/cameras/zones');
await apiClient.put(`/cameras/${editingCamera.id}`, formData); const zones = res.data.map((z: any) => ({
// Force stream refresh for this camera name: z.name,
setStreamVersions(prev => ({ ...prev, [editingCamera.id]: Date.now() })); layout: z.layout || '2*2'
} else { }));
await apiClient.post('/cameras', formData); setAvailableZones(zones);
// Default to the first available zone to show video immediately
if (!activeZone && zones.length > 0) {
setActiveZone(zones[0].name);
} }
setShowForm(false);
setEditingCamera(null);
setFormData({
name: '',
ip_address: '',
port: 554,
username: '',
password: '',
stream_path: '/stream1',
transport_mode: 'tcp',
rtsp_encoding: false,
quality: 'low'
});
fetchCameras();
} catch (err) { } catch (err) {
console.error('Failed to save camera', err); console.error('Failed to fetch zones', err);
const errMsg = (err as any).response?.data?.error || (err as any).message || '카메라 저장 실패';
alert(`오류 발생: ${errMsg}`);
} finally {
setLoading(false);
} }
}; };
const handleEdit = (camera: Camera) => { // Determine layout based on active zone's setting
setEditingCamera(camera); const currentZoneConfig = availableZones.find(z => z.name === activeZone);
setFormData(camera); const viewLayout = currentZoneConfig ? currentZoneConfig.layout : '2*2';
setShowForm(true);
}; const handleLayoutChange = async (newLayout: string) => {
if (!activeZone) return;
// Optimistic UI update
setAvailableZones(prev => prev.map(z =>
z.name === activeZone ? { ...z, layout: newLayout } : z
));
setShowLayoutMenu(false);
const handleDelete = async (id: number) => {
if (!window.confirm('정말 삭제하시겠습니까?')) return;
try { try {
await apiClient.delete(`/cameras/${id}`); await apiClient.patch(`/cameras/zones/${activeZone}/layout`, { layout: newLayout });
fetchCameras();
} catch (err) { } catch (err) {
console.error('Failed to delete camera', err); console.error('Failed to update layout', err);
// Revert or show alert if needed
} }
}; };
const filteredCameras = !activeZone
? []
: cameras.filter(c => (c.zone || '기본 구역') === activeZone);
const handleToggleStatus = async (camera: Camera) => { const handleToggleStatus = async (camera: Camera) => {
// Optimistic UI Update: Calculate new status // Optimistic UI Update: Calculate new status
const currentStatus = camera.is_active !== 0 && camera.is_active !== false; const currentStatus = camera.is_active !== 0 && camera.is_active !== false;
@ -226,185 +208,186 @@ export function MonitoringPage() {
useEffect(() => { useEffect(() => {
fetchCameras(); fetchCameras();
fetchZones();
checkServerVersion(); checkServerVersion();
}, []); }, []);
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold flex items-center gap-2">
<Video className="text-blue-600" />
CCTV <span className="text-xs text-slate-400 font-normal">({user?.role})</span>
</h1>
{user?.role === 'admin' && (
<button
onClick={() => {
setEditingCamera(null);
setFormData({
name: '',
ip_address: '',
port: 554,
username: '',
password: '',
stream_path: '/stream1'
});
setShowForm(true);
}}
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 transition"
>
<Plus size={18} />
</button>
)}
</div>
{/* Camera Grid with DnD */} const HeaderDropdown = () => {
<DndContext const portalRoot = document.getElementById('header-portal-root');
sensors={sensors} if (!portalRoot) return null;
collisionDetection={closestCenter}
onDragEnd={handleDragEnd} // Tabs: [Zones...]
> const zoneTabs = availableZones.map(z => z.name);
<SortableContext const layoutOptions = [
items={cameras.map(c => c.id)} { id: '1', label: '1개', icon: <div className="w-4 h-4 border border-white/40" /> },
strategy={rectSortingStrategy} { id: '1*2', label: '1*2', icon: <div className="w-4 h-4 border border-white/40 flex"><div className="w-1/2 border-r border-white/40" /></div> },
{ id: '2*2', label: '2*2', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-2 grid-rows-2"><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /><div className="border-r border-white/40" /></div> },
{ id: '3*3', label: '3*3', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-3 grid-rows-3"><div className="border-r border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /></div> },
{ id: '4*4', label: '4*4', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-4 grid-rows-4" /> },
{ id: '5*5', label: '5*5', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-5 grid-rows-5" /> },
{ id: '6*6', label: '6*6', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-6 grid-rows-6" /> }
];
return createPortal(
<div className="flex items-center w-full h-full pointer-events-none">
{/* Left Area: Zone Tabs */}
<div className="flex gap-1 h-full items-end pb-1 overflow-x-auto scrollbar-hide pointer-events-auto border-l border-slate-200 ml-4 pl-4">
{zoneTabs.map(name => (
<button
key={name}
onClick={() => setActiveZone(name)}
className={`px-4 py-2 text-[13px] font-bold transition-all border-b-2 whitespace-nowrap ${activeZone === name
? 'text-indigo-600 border-indigo-600 bg-indigo-50/20'
: 'text-slate-400 border-transparent hover:text-slate-600'
}`}
>
{name}
</button>
))}
</div>
{/* Spacer to push everything else to the right */}
<div className="flex-1" />
{/* Right Area: Layout Selector */}
<div className="px-3 pointer-events-auto flex items-center">
<div className="relative">
<button
onClick={() => setShowLayoutMenu(!showLayoutMenu)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border ${showLayoutMenu ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-slate-50 text-slate-600 border-slate-200 hover:border-indigo-400'} ${!activeZone ? 'opacity-50 grayscale cursor-not-allowed' : ''}`}
disabled={!activeZone}
>
<LayoutGrid size={14} />
: {!activeZone ? '--' : viewLayout}
<ChevronDown size={14} className={`transition-transform duration-200 ${showLayoutMenu ? 'rotate-180' : ''}`} />
</button>
{showLayoutMenu && activeZone && (
<div className="absolute right-0 top-full mt-2 w-48 bg-[#1a1a1a] rounded-xl shadow-2xl border border-white/10 p-2 z-[100] animate-in fade-in zoom-in duration-200">
<div className="px-2 py-1.5 text-[9px] font-bold text-white/30 uppercase tracking-[0.2em] border-b border-white/5 mb-1">
Monitor Arrangement
</div>
<div className="grid grid-cols-2 gap-1">
{layoutOptions.map(opt => (
<button
key={opt.id}
onClick={() => handleLayoutChange(opt.id)}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-[11px] font-medium transition-all ${viewLayout === opt.id ? 'bg-indigo-600 text-white' : 'text-white/60 hover:bg-white/5 hover:text-white'}`}
>
<div className="w-4 flex justify-center opacity-60">{opt.icon}</div>
{opt.label}
</button>
))}
</div>
</div>
)}
</div>
</div>
</div>,
portalRoot
);
};
const getLayoutInfo = () => {
if (viewLayout === '1') return { cols: 'grid-cols-1', total: 1 };
if (viewLayout === '1*2') return { cols: 'grid-cols-2', total: 2 };
// Multi-grid like 2*2, 3*3, 4*4
const parts = viewLayout.split('*');
if (parts.length === 2) {
const n = parseInt(parts[0]);
return { cols: `grid-cols-${n}`, total: n * n };
}
return { cols: 'grid-cols-2', total: 4 };
};
const layoutInfo = getLayoutInfo();
const totalSlots = Math.max(layoutInfo.total, filteredCameras.length);
// Fill slots: actual cameras + empty placeholders if needed to complete the grid rows
const slots = Array.from({ length: totalSlots }).map((_, i) => filteredCameras[i] || null);
return (
<div className="w-full h-full bg-[#0a0a0a] flex flex-col min-h-0">
<HeaderDropdown />
<div className="flex-1 p-2 overflow-hidden flex flex-col min-h-0">
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
> >
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6"> <SortableContext
{cameras.map(camera => ( items={filteredCameras.map(c => c.id)}
<SortableCamera key={camera.id} camera={camera} disabled={user?.role !== 'admin'}> strategy={rectSortingStrategy}
<div className="relative aspect-video bg-black group"> disabled={true}
<JSMpegPlayer >
key={`${camera.id}-${streamVersions[camera.id] || 0}`} <div className={`grid h-full w-full gap-1 ${layoutInfo.cols} ${totalSlots > layoutInfo.total ? 'overflow-y-auto' : ''}`}
url={getStreamUrl(camera.id)} style={{ gridAutoRows: (viewLayout === '1' || viewLayout === '1*2') ? '100%' : `${100 / Math.sqrt(layoutInfo.total)}%` }}>
className="w-full h-full" {slots.map((camera, index) => (
/> <div key={camera ? camera.id : `empty-${index}`} className="h-full w-full">
{/* Overlay Controls */} {camera ? (
{user?.role === 'admin' && ( <SortableCamera camera={camera} disabled={true}>
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2" onPointerDown={(e) => e.stopPropagation()}> <div className="relative w-full h-full flex flex-col group text-white/90">
<button {/* VMS Slot Header */}
onClick={() => handleToggleStatus(camera)} <div className="absolute top-0 left-0 right-0 z-10 bg-black/40 backdrop-blur-sm border-b border-white/5 px-3 py-1.5 flex justify-between items-center opacity-0 group-hover:opacity-100 transition-opacity">
className={`${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-orange-500/80 hover:bg-orange-600' : 'bg-green-600/80 hover:bg-green-700'} text-white p-2 rounded-full`} <div className="flex items-center gap-2">
title={(camera.is_active !== 0 && camera.is_active !== false) ? "스트리밍 중지 (Pause)" : "스트리밍 시작 (Play)"} <div className={`w-2 h-2 rounded-full ${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></div>
> <span className="text-[11px] font-bold truncate max-w-[150px]">{camera.name}</span>
{(camera.is_active !== 0 && camera.is_active !== false) ? ( </div>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
) : ( {(user?.role === 'admin' || user?.role === 'supervisor') && (
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg> <div className="flex gap-1" onPointerDown={(e) => e.stopPropagation()}>
)} <button
</button> onClick={() => handlePing(camera.id)}
<button className="p-1 text-white/50 hover:text-green-400 transition-colors"
onClick={() => handlePing(camera.id)} title="Ping"
className="bg-green-600/80 text-white p-2 rounded-full hover:bg-green-700" >
title="연결 테스트 (Ping)" <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3L15.5 7.5z" /></svg>
> </button>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3L15.5 7.5z" /></svg> <button
</button> onClick={() => handleToggleStatus(camera)}
<button className={`p-1 transition-colors ${(camera.is_active !== 0 && camera.is_active !== false) ? 'text-white/50 hover:text-orange-400' : 'text-white/50 hover:text-green-400'}`}
onClick={() => handleEdit(camera)} title={(camera.is_active !== 0 && camera.is_active !== false) ? "Pause" : "Play"}
className="bg-black/50 text-white p-2 rounded-full hover:bg-black/70" >
title="설정" {(camera.is_active !== 0 && camera.is_active !== false) ? (
> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><rect x="6" y="4" width="4" height="16"></rect><rect x="14" y="4" width="4" height="16"></rect></svg>
<Settings size={16} /> ) : (
</button> <svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polygon points="5 3 19 12 5 21 5 3"></polygon></svg>
<button )}
onClick={() => handleDelete(camera.id)} </button>
className="bg-red-500/80 text-white p-2 rounded-full hover:bg-red-600" </div>
title="삭제" )}
> </div>
<Trash2 size={16} />
</button> {/* Streaming Video */}
<div className="flex-1 bg-black flex items-center justify-center">
<JSMpegPlayer
key={`${camera.id}-${streamVersions[camera.id] || 0}`}
url={getStreamUrl(camera.id)}
className="w-full h-full object-contain"
/>
</div>
{/* Small persistent ID or Name overlay */}
<div className="absolute bottom-2 left-2 px-2 py-0.5 bg-black/60 rounded text-[10px] text-white/30 font-mono pointer-events-none border border-white/5">
CH {index + 1} | {camera.ip_address}
</div>
</div>
</SortableCamera>
) : (
<div className="h-full w-full bg-[#121212] border border-slate-900/50 flex flex-col items-center justify-center text-slate-700 select-none">
<Video size={32} className="opacity-10 mb-2" />
<span className="text-[11px] font-bold opacity-20 uppercase tracking-widest">No Buffer</span>
</div> </div>
)} )}
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
<h3 className="text-white font-medium flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></span>
{camera.name} {(camera.is_active === 0 || camera.is_active === false) && <span className="text-xs text-slate-400">()</span>}
</h3>
{/* IP/Port Hidden as requested */}
</div>
</div> </div>
</SortableCamera> ))}
))}
</div>
</SortableContext>
</DndContext>
{cameras.length === 0 && (
<div className="text-center py-20 text-slate-500">
<Video size={48} className="mx-auto mb-4 opacity-50" />
<p> . .</p>
</div>
)}
{/* Add/Edit Modal */}
{showForm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6 animate-in fade-in zoom-in duration-200">
<div className="flex justify-between items-center mb-6">
<h2 className="text-xl font-bold">
{editingCamera ? '카메라 설정 수정' : '새 카메라 추가'}
</h2>
<button onClick={() => setShowForm(false)} className="text-slate-400 hover:text-slate-600">
<X size={24} />
</button>
</div> </div>
</SortableContext>
<form onSubmit={handleSubmit} className="space-y-4"> </DndContext>
<div> </div>
<label className="block text-sm font-medium text-slate-700 mb-1"> </label>
<input type="text" name="name" value={formData.name} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="예: 정문 CCTV" />
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-slate-700 mb-1">IP </label><input type="text" name="ip_address" value={formData.ip_address} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="192.168.1.100" /></div>
<div><label className="block text-sm font-medium text-slate-700 mb-1"></label><input type="number" name="port" value={formData.port} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="554" /></div>
</div>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP </label><input type="text" name="username" value={formData.username} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="admin" /></div>
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP </label><input type="password" name="password" value={formData.password} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" /></div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1"> </label>
<input type="text" name="stream_path" value={formData.stream_path} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="/stream1" />
</div>
{/* Advanced Settings */}
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
<h3 className="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
<Settings size={14} />
</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-slate-600 mb-1"> (Transport)</label>
<select name="transport_mode" value={formData.transport_mode || 'tcp'} onChange={(e) => setFormData(prev => ({ ...prev, transport_mode: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
<option value="tcp">TCP ( - )</option>
<option value="udp">UDP ( - )</option>
<option value="auto">Auto</option>
</select>
</div>
<div className="flex items-center">
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" name="rtsp_encoding" checked={!!formData.rtsp_encoding} onChange={(e) => setFormData(prev => ({ ...prev, rtsp_encoding: e.target.checked }))} className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500" />
<span className="text-sm text-slate-600"> </span>
</label>
</div>
<div className="col-span-2">
<label className="block text-xs font-medium text-slate-600 mb-1"> (Quality)</label>
<select name="quality" value={formData.quality || 'low'} onChange={(e) => setFormData(prev => ({ ...prev, quality: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
<option value="low">Low (640px) - ()</option>
<option value="medium">Medium (1280px) - HD </option>
<option value="original">Original - ( )</option>
</select>
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition"></button>
<button type="submit" disabled={loading} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50">{loading ? '저장 중...' : '저장하기'}</button>
</div>
</form>
</div>
</div>
)}
</div> </div>
); );
} }

View File

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

View File

@ -4,8 +4,8 @@
justify-content: center; justify-content: center;
height: 100vh; height: 100vh;
width: 100vw; width: 100vw;
background-color: var(--color-bg-base); background-color: var(--sokuree-bg-main);
background: linear-gradient(135deg, var(--color-bg-sidebar) 0%, var(--color-brand-primary) 100%); background: linear-gradient(135deg, var(--sokuree-bg-sidebar) 0%, var(--sokuree-brand-primary) 100%);
} }
.login-card { .login-card {
@ -14,8 +14,8 @@
background: rgba(255, 255, 255, 0.95); background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
padding: 3rem; padding: 3rem;
border-radius: var(--radius-lg); border-radius: var(--sokuree-radius-lg);
box-shadow: var(--shadow-lg); box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
} }
.login-header { .login-header {
@ -26,22 +26,22 @@
.brand-logo { .brand-logo {
display: inline-flex; display: inline-flex;
padding: 1rem; padding: 1rem;
background-color: var(--color-bg-sidebar); background-color: var(--sokuree-bg-sidebar);
color: var(--color-text-inverse); color: var(--sokuree-text-inverse);
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
margin-bottom: 1.5rem; margin-bottom: 1.5rem;
box-shadow: var(--shadow-md); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
} }
.login-header h1 { .login-header h1 {
font-size: 1.75rem; font-size: 1.75rem;
font-weight: 700; font-weight: 700;
color: var(--color-text-primary); color: var(--sokuree-text-primary);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.login-header p { .login-header p {
color: var(--color-text-secondary); color: var(--sokuree-text-secondary);
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -60,7 +60,7 @@
.form-group label { .form-group label {
font-size: 0.875rem; font-size: 0.875rem;
font-weight: 500; font-weight: 500;
color: var(--color-text-primary); color: var(--sokuree-text-primary);
} }
.input-wrapper { .input-wrapper {
@ -72,33 +72,33 @@
.input-icon { .input-icon {
position: absolute; position: absolute;
left: 1rem; left: 1rem;
color: var(--color-text-secondary); color: var(--sokuree-text-secondary);
pointer-events: none; pointer-events: none;
} }
.form-group input { .form-group input {
width: 100%; width: 100%;
padding: 0.75rem 1rem 0.75rem 2.75rem; padding: 0.75rem 1rem 0.75rem 2.75rem;
border: 1px solid var(--color-border); border: 1px solid var(--sokuree-border-color);
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
font-size: 0.9rem; font-size: 0.9rem;
transition: all 0.2s; transition: all 0.2s;
background-color: var(--color-bg-surface); background-color: var(--sokuree-bg-card);
} }
.form-group input:focus { .form-group input:focus {
outline: none; outline: none;
border-color: var(--color-brand-primary); border-color: var(--sokuree-brand-primary);
box-shadow: 0 0 0 3px rgba(82, 109, 130, 0.1); box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
} }
.login-btn { .login-btn {
margin-top: 1rem; margin-top: 1rem;
padding: 0.875rem; padding: 0.875rem;
background-color: var(--color-bg-sidebar); background-color: var(--sokuree-bg-sidebar);
color: white; color: white;
font-weight: 600; font-weight: 600;
border-radius: var(--radius-md); border-radius: var(--sokuree-radius-md);
font-size: 1rem; font-size: 1rem;
letter-spacing: 0.025em; letter-spacing: 0.025em;
transition: all 0.2s; transition: all 0.2s;
@ -107,7 +107,7 @@
.login-btn:hover { .login-btn:hover {
background-color: #1e293b; background-color: #1e293b;
transform: translateY(-1px); transform: translateY(-1px);
box-shadow: var(--shadow-md); box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
} }
.error-message { .error-message {
@ -116,12 +116,12 @@
text-align: center; text-align: center;
background-color: rgba(239, 68, 68, 0.1); background-color: rgba(239, 68, 68, 0.1);
padding: 0.75rem; padding: 0.75rem;
border-radius: var(--radius-sm); border-radius: var(--sokuree-radius-sm);
} }
.login-footer { .login-footer {
margin-top: 2rem; margin-top: 2rem;
text-align: center; text-align: center;
font-size: 0.75rem; font-size: 0.75rem;
color: var(--color-text-secondary); color: var(--sokuree-text-secondary);
} }

View File

@ -13,6 +13,13 @@ export function LoginPage() {
const location = useLocation(); const location = useLocation();
const from = location.state?.from?.pathname || '/asset/dashboard'; const from = location.state?.from?.pathname || '/asset/dashboard';
const isExpired = new URLSearchParams(location.search).get('expired') === 'true';
useState(() => {
if (isExpired) {
setError('세션이 만료되었습니다. 보안을 위해 다시 로그인해주세요.');
}
});
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@ -54,6 +61,8 @@ export function LoginPage() {
placeholder="아이디를 입력하세요" placeholder="아이디를 입력하세요"
required required
autoComplete="one-time-code" autoComplete="one-time-code"
readOnly
onFocus={(e) => e.target.readOnly = false}
/> />
</div> </div>
</div> </div>
@ -70,6 +79,8 @@ export function LoginPage() {
placeholder="비밀번호를 입력하세요" placeholder="비밀번호를 입력하세요"
required required
autoComplete="new-password" autoComplete="new-password"
readOnly
onFocus={(e) => e.target.readOnly = false}
/> />
</div> </div>
</div> </div>

View File

@ -1,5 +1,5 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider } from '../shared/auth/AuthContext'; import { AuthProvider, useAuth } from '../shared/auth/AuthContext';
import { SystemProvider } from '../shared/context/SystemContext'; import { SystemProvider } from '../shared/context/SystemContext';
import { AuthGuard } from '../shared/auth/AuthGuard'; import { AuthGuard } from '../shared/auth/AuthGuard';
import { MainLayout } from '../widgets/layout/MainLayout'; import { MainLayout } from '../widgets/layout/MainLayout';
@ -14,12 +14,21 @@ import ModuleLoader from './ModuleLoader';
// Platform / System Pages // Platform / System Pages
import { UserManagementPage } from './pages/UserManagementPage'; import { UserManagementPage } from './pages/UserManagementPage';
import { BasicSettingsPage } from './pages/BasicSettingsPage'; import { BasicSettingsPage } from './pages/BasicSettingsPage';
import { VersionPage } from './pages/VersionPage';
import { LicensePage } from '../system/pages/LicensePage'; import { LicensePage } from '../system/pages/LicensePage';
import { LandingPage } from './pages/LandingPage';
import { GeneralPreferencesPage } from './pages/GeneralPreferencesPage';
import './styles/global.css'; import './styles/global.css';
const modules = [assetModule, cctvModule, productionModule]; 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 = () => { export const App = () => {
return ( return (
<AuthProvider> <AuthProvider>
@ -35,16 +44,23 @@ export const App = () => {
<MainLayout modulesList={modules} /> <MainLayout modulesList={modules} />
</AuthGuard> </AuthGuard>
}> }>
{/* Home / Platform Introduction */}
<Route path="/home" element={<LandingPage />} />
{/* User Preferences (Accessible by everyone authenticated) */}
<Route path="/preferences" element={<GeneralPreferencesPage />} />
{/* Dynamic Module Routes */} {/* Dynamic Module Routes */}
<Route path="/*" element={<ModuleLoader modules={modules} />} /> <Route path="/*" element={<ModuleLoader modules={modules} />} />
{/* Navigation Fallback within Layout */} {/* Navigation Fallback within Layout */}
<Route index element={<Navigate to="/asset/dashboard" replace />} /> <Route index element={<DefaultRedirect />} />
{/* Platform Admin Routes (Could also be moved to an Admin module later) */} {/* Platform Admin Routes (Could also be moved to an Admin module later) */}
<Route path="/admin/users" element={<UserManagementPage />} /> <Route path="/admin/users" element={<UserManagementPage />} />
<Route path="/admin/settings" element={<BasicSettingsPage />} /> <Route path="/admin/settings" element={<BasicSettingsPage />} />
<Route path="/admin/license" element={<LicensePage />} /> <Route path="/admin/license" element={<LicensePage />} />
<Route path="/admin/version" element={<VersionPage />} />
<Route path="/admin" element={<Navigate to="/admin/settings" replace />} /> <Route path="/admin" element={<Navigate to="/admin/settings" replace />} />
</Route> </Route>

View File

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

View File

@ -1,25 +1,88 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Card } from '../../shared/ui/Card'; import { Card } from '../../shared/ui/Card';
import { Button } from '../../shared/ui/Button'; import { Button } from '../../shared/ui/Button';
import { Input } from '../../shared/ui/Input'; import { Input } from '../../shared/ui/Input';
import { apiClient } from '../../shared/api/client'; import { apiClient } from '../../shared/api/client';
import { Save, Clock, Info } from 'lucide-react'; import { useAuth } from '../../shared/auth/AuthContext';
import { Save, Database, Server, CheckCircle2, AlertCircle, Key, RefreshCcw, ShieldAlert, Lock, Unlock, Clock } 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;
password?: string;
database: string;
port: string;
};
}
export function BasicSettingsPage() { export function BasicSettingsPage() {
const [settings, setSettings] = useState({ const { user: currentUser } = useAuth();
session_timeout: 60 const [settings, setSettings] = useState<SystemSettings>({
session_timeout: 60,
encryption_key: '',
subscriber_id: '',
gitea_url: '',
gitea_user: '',
gitea_password: '',
db_config: {
host: '',
user: '',
password: '',
database: '',
port: '3306'
}
}); });
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const [isDbVerified, setIsDbVerified] = useState(false);
const [saveResults, setSaveResults] = useState<{ [key: string]: { success: boolean; message: string } | null }>({
security: null,
encryption: null,
repository: null,
database: null
});
const [testing, setTesting] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [rotationStatus, setRotationStatus] = useState<{ currentKey: string; affectedCount: number } | null>(null);
const [rotating, setRotating] = useState(false);
// 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 [showVerifyModal, setShowVerifyModal] = useState(false);
const [verifyPassword, setVerifyPassword] = useState('');
const [verifying, setVerifying] = useState(false);
const [verifyError, setVerifyError] = useState('');
const fetchedRef = useRef(false);
useEffect(() => { useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
fetchSettings(); fetchSettings();
fetchEncryptionStatus();
}, []); }, []);
const fetchSettings = async () => { const fetchSettings = async () => {
try { try {
const res = await apiClient.get('/system/settings'); const res = await apiClient.get('/system/settings');
setSettings(res.data); const data = res.data;
if (!data.db_config) {
data.db_config = { host: '', user: '', password: '', database: '', port: '3306' };
}
setSettings(data);
setIsDbVerified(true);
} catch (error) { } catch (error) {
console.error('Failed to fetch settings', error); console.error('Failed to fetch settings', error);
} finally { } finally {
@ -27,95 +90,476 @@ export function BasicSettingsPage() {
} }
}; };
const handleSave = async () => { const fetchEncryptionStatus = async () => {
setSaving(true);
try { try {
await apiClient.post('/system/settings', settings); const res = await apiClient.get('/system/encryption/status');
alert('설정이 저장되었습니다.'); setRotationStatus({
currentKey: res.data.current_key,
affectedCount: Object.values(res.data.affected_records as Record<string, number>).reduce((a, b) => a + b, 0)
});
} catch (error) { } catch (error) {
console.error('Save failed', error); console.error('Failed to fetch encryption status', error);
alert('저장 중 오류가 발생했습니다.'); }
};
const generateNewKey = () => {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()';
let key = 'smartims_';
for (let i = 0; i < 24; i++) {
key += chars.charAt(Math.floor(Math.random() * chars.length));
}
setSettings({ ...settings, encryption_key: key });
};
const handleRotateKey = async () => {
if (!settings.encryption_key) {
alert('새로운 암호화 키를 입력하거나 생성해 주세요.');
return;
}
const confirmMsg = `암호화 키 변경 및 데이터 마이그레이션을 시작합니다.\n- 대상 레코드: 약 ${rotationStatus?.affectedCount || 0}\n- 소요 시간: 데이터 양에 따라 수 초가 걸릴 수 있습니다.\n\n주의: 절차 진행 중에는 서버 연결이 일시적으로 끊길 수 있으며, 중간에 브라우저를 닫지 마십시오.\n진행하시겠습니까?`;
if (!confirm(confirmMsg)) return;
setRotating(true);
setSaveResults(prev => ({ ...prev, encryption: null }));
try {
await apiClient.post('/system/encryption/rotate', { new_key: settings.encryption_key });
setSaveResults(prev => ({
...prev,
encryption: { success: true, message: '암호화 키 변경 및 데이터 마이그레이션 완료!' }
}));
fetchSettings();
fetchEncryptionStatus();
setTimeout(() => setSaveResults(prev => ({ ...prev, encryption: null })), 5000);
} catch (error: any) {
setSaveResults(prev => ({
...prev,
encryption: { success: false, message: error.response?.data?.error || '재암호화 작업 중 오류가 발생했습니다.' }
}));
} finally {
setRotating(false);
}
};
const handleTestConnection = async () => {
setTesting(true);
setTestResult(null);
try {
const res = await apiClient.post('/system/test-db', settings.db_config);
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}`
});
setIsDbVerified(false);
} finally {
setTesting(false);
}
};
const handleOpenVerify = (target: 'database' | 'encryption' | 'repository') => {
if (currentUser?.role !== 'supervisor') {
alert('해당 권한이 없습니다. 최고관리자(Supervisor)만 접근 가능합니다.');
return;
}
setVerifyingTarget(target);
setShowVerifyModal(true);
};
const handleVerifySupervisor = async () => {
setVerifying(true);
setVerifyError('');
try {
const res = await apiClient.post('/verify-supervisor', { password: verifyPassword });
if (res.data.success) {
if (verifyingTarget === 'database') setIsDbConfigUnlocked(true);
else if (verifyingTarget === 'encryption') setIsEncryptionUnlocked(true);
else if (verifyingTarget === 'repository') setIsRepoUnlocked(true);
setShowVerifyModal(false);
setVerifyPassword('');
setVerifyingTarget(null);
}
} catch (error: any) {
setVerifyError(error.response?.data?.message || '인증에 실패했습니다. 비밀번호를 확인해주세요.');
} finally {
setVerifying(false);
}
};
const handleSaveSection = async (section: 'security' | 'encryption' | 'database' | 'repository') => {
if (section === 'database' && !isDbVerified) {
alert('DB 접속 정보가 변경되었습니다. 저장 전 반드시 [연결 테스트]를 수행하십시오.');
return;
}
let payload: any = {};
const successMessage = section === 'database' ? 'DB 설정이 저장되었습니다.' : '설정이 성공적으로 저장되었습니다.';
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 };
}
setSaving(true);
setSaveResults(prev => ({ ...prev, [section]: null }));
try {
await apiClient.post('/system/settings', payload);
setSaveResults(prev => ({ ...prev, [section]: { success: true, message: successMessage } }));
setTimeout(() => setSaveResults(prev => ({ ...prev, [section]: null })), 3000);
if (section !== 'database') fetchSettings();
} catch (error: any) {
setSaveResults(prev => ({
...prev,
[section]: { success: false, message: error.response?.data?.error || '저장 중 오류 발생' }
}));
} finally { } finally {
setSaving(false); setSaving(false);
} }
}; };
if (loading) return <div className="p-6"> ...</div>; if (loading) return <div className="p-12 text-center text-slate-500"> ...</div>;
return ( return (
<div className="page-container p-6 max-w-4xl mx-auto"> <div className="page-container p-6 max-w-5xl mx-auto">
<div className="mb-6"> <div className="flex justify-between items-center mb-8">
<h1 className="text-2xl font-bold text-slate-900"> - </h1> <div>
<p className="text-slate-500 mt-1"> .</p> <h1 className="text-2xl font-bold text-slate-900"> - </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
</div> </div>
<div className="space-y-6"> <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">
<Card className="p-6"> <Clock className="text-indigo-600 mt-1" size={20} />
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2"> <div>
<Clock size={20} className="text-amber-500" /> <h3 className="text-sm font-bold text-indigo-900"> (v0.4.1.0)</h3>
<p className="text-xs text-indigo-700 mt-1 leading-relaxed">
</h2> <strong> </strong> .<br />
<div className="space-y-4"> <strong>[ ]</strong> <strong>[ ]</strong> .
<div> </p>
<label className="block text-sm font-medium text-slate-700 mb-1"> </label>
<div className="flex items-center gap-2 flex-wrap text-sm text-slate-600">
<div className="flex items-center gap-1">
<Input
type="number"
min="0"
max="24"
className="!w-auto !mb-0"
style={{ width: '70px', textAlign: 'center' }}
value={Math.floor((settings.session_timeout || 10) / 60)}
onChange={e => {
const newHours = parseInt(e.target.value) || 0;
const currentTotal = settings.session_timeout || 10;
const currentMinutes = currentTotal % 60;
setSettings({ ...settings, session_timeout: (newHours * 60) + currentMinutes });
}}
placeholder="0"
/>
<span className="mr-2 whitespace-nowrap text-slate-700 font-medium"></span>
</div>
<div className="flex items-center gap-1">
<Input
type="number"
min="0"
max="59"
className="!w-auto !mb-0"
style={{ width: '70px', textAlign: 'center' }}
value={(settings.session_timeout || 10) % 60}
onChange={e => {
const newMinutes = parseInt(e.target.value) || 0;
const currentTotal = settings.session_timeout || 10;
const currentHours = Math.floor(currentTotal / 60);
setSettings({ ...settings, session_timeout: (currentHours * 60) + newMinutes });
}}
placeholder="10"
/>
<span className="whitespace-nowrap text-slate-700 font-medium"></span>
</div>
<span className="text-slate-500 whitespace-nowrap ml-1"> .</span>
</div>
<p className="text-xs text-slate-400 mt-1 pl-1">
( {settings.session_timeout || 10} / 5 ~ 24 )
</p>
</div>
</div> </div>
</Card> </Card>
<div className="flex justify-end"> {/* Section 2: Encryption Master Key & Rotation (Supervisor Protected) */}
<Button <Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
onClick={handleSave} <div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
disabled={saving} <h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
icon={<Save size={18} />} <Key size={20} className="text-red-600" />
>
{saving ? '저장 중...' : '설정 저장'} </h2>
</Button> {!isEncryptionUnlocked && (
</div> <Button size="sm" variant="secondary" onClick={() => handleOpenVerify('encryption')} icon={<Lock size={14} />}>
/
</Button>
)}
{isEncryptionUnlocked && (
<div className="flex items-center gap-2 text-red-700 bg-red-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-red-200">
<Unlock size={14} /> 완료: 최고관리자
</div>
)}
</div>
{isEncryptionUnlocked ? (
<div className="animate-in fade-in duration-500">
<div className="p-6 space-y-6">
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-slate-500 font-bold block mb-1"> </span>
<code className="bg-white p-2 rounded border border-slate-200 block truncate font-mono">{rotationStatus?.currentKey || '-'}</code>
</div>
<div>
<span className="text-slate-500 font-bold block mb-1"> </span>
<div className="text-blue-600 font-bold flex items-center gap-2 mt-1">
<Database size={16} /> {rotationStatus?.affectedCount || 0}
<Button variant="secondary" size="sm" className="ml-2 h-7 text-[10px]" onClick={fetchEncryptionStatus} icon={<RefreshCcw size={12} />}></Button>
</div>
</div>
</div>
<div className="flex gap-2 max-w-xl">
<Input
value={settings.encryption_key}
onChange={e => setSettings({ ...settings, encryption_key: e.target.value })}
placeholder="새로운 키 입력"
className="font-mono flex-1"
/>
<Button variant="secondary" onClick={generateNewKey} icon={<RefreshCcw size={14} />}> </Button>
</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.encryption && (
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.encryption.success ? 'text-green-600' : 'text-red-600'}`}>
{saveResults.encryption.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
{saveResults.encryption.message}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setIsEncryptionUnlocked(false)}></Button>
<Button
className="bg-red-600 hover:bg-red-700"
size="sm"
onClick={handleRotateKey}
disabled={rotating || !settings.encryption_key}
icon={<RefreshCcw size={14} className={rotating ? 'animate-spin' : ''} />}
>
{rotating ? '재암호화 진행 중...' : '변경 및 마이그레이션 저장'}
</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 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">
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
<ShieldAlert size={20} className="text-indigo-600" />
</h2>
{!isDbConfigUnlocked && (
<Button size="sm" variant="secondary" onClick={() => handleOpenVerify('database')} icon={<Lock size={14} />}>
/
</Button>
)}
{isDbConfigUnlocked && (
<div className="flex items-center gap-2 text-indigo-700 bg-indigo-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-indigo-200">
<Unlock size={14} /> 완료: 최고관리자
</div>
)}
</div>
{isDbConfigUnlocked ? (
<div className="animate-in fade-in duration-500">
<div className="p-6 space-y-6">
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-lg flex gap-4 items-start">
<AlertCircle className="text-indigo-600 shrink-0" size={20} />
<div>
<p className="text-sm font-bold text-indigo-900"> </p>
<p className="text-xs text-indigo-700 leading-relaxed mt-1">
.<br />
.
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-8">
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700">DB </label>
<Input value={settings.db_config.host} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, host: e.target.value } }); setIsDbVerified(false); }} />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> </label>
<Input value={settings.db_config.user} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, user: e.target.value } }); setIsDbVerified(false); }} />
</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); }}
/>
</div>
</div>
</div>
<div className="space-y-4">
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> (Database)</label>
<Input value={settings.db_config.database} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, database: e.target.value } }); setIsDbVerified(false); }} />
</div>
<div className="space-y-1">
<label className="text-sm font-medium text-slate-700"> (Port)</label>
<Input type="number" value={settings.db_config.port} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, port: e.target.value } }); setIsDbVerified(false); }} />
</div>
</div>
</div>
<div className="p-4 bg-slate-50 border border-slate-100 rounded-lg flex justify-between items-center">
<div className="text-sm font-medium">
{testResult && (
<span className={`flex items-center gap-1 ${testResult.success ? 'text-green-700' : 'text-red-700'}`}>
{testResult.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />} {testResult.message}
</span>
)}
{!testResult && <span className="text-slate-500 italic"> .</span>}
</div>
<Button variant="secondary" size="sm" onClick={handleTestConnection} disabled={testing} icon={<Server size={14} />}>
{testing ? '검증 중...' : '연결 테스트'}
</Button>
</div>
</div>
<div className="px-6 py-4 bg-slate-100/30 border-t border-slate-100 flex items-center justify-between">
<div className="flex-1">
{testResult && !testResult.success && <span className="text-xs text-red-600 font-bold"> .</span>}
{saveResults.database && (
<div className={`text-sm font-medium ${saveResults.database.success ? 'text-green-600' : 'text-red-600'}`}>
{saveResults.database.message}
</div>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" size="sm" onClick={() => setIsDbConfigUnlocked(false)}></Button>
<Button size="sm" className="bg-indigo-600" onClick={() => handleSaveSection('database')} disabled={saving || !isDbVerified} 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>
</div> </div>
{/* Verification Modal */}
{showVerifyModal && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-300">
<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' ? '저장소 설정' : '인프라 설정'})
</h3>
<p className="text-white/80 text-sm mt-1"> .</p>
</div>
<div className="p-6 space-y-4">
<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="비밀번호를 입력하세요"
onKeyDown={e => e.key === 'Enter' && handleVerifySupervisor()}
autoFocus
/>
{verifyError && <p className="text-xs text-red-600 mt-2 font-bold">{verifyError}</p>}
</div>
</div>
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
<Button variant="secondary" onClick={() => { setShowVerifyModal(false); setVerifyError(''); setVerifyPassword(''); setVerifyingTarget(null); }}></Button>
<Button className={verifyingTarget === 'encryption' ? 'bg-red-600' : 'bg-indigo-600'} onClick={handleVerifySupervisor} disabled={verifying}>
{verifying ? '인증 중...' : '인증 및 조회'}
</Button>
</div>
</Card>
</div>
)}
</div> </div>
); );
} }

View File

@ -0,0 +1,157 @@
import { useState } from 'react';
import { Card } from '../../shared/ui/Card';
import { Button } from '../../shared/ui/Button';
import { Select } from '../../shared/ui/Select';
import { Input } from '../../shared/ui/Input';
import { Save, Home, User, Clock, ShieldAlert, RefreshCcw } from 'lucide-react';
import { useAuth } from '../../shared/auth/AuthContext';
import { apiClient } from '../../shared/api/client';
export function GeneralPreferencesPage() {
const { user } = useAuth();
const [preferences, setPreferences] = useState({
landingPage: localStorage.getItem(`landing_page_${user?.id}`) || '/home',
sessionTimeout: user?.session_timeout || 10
});
const [isSaving, setIsSaving] = useState(false);
const [isSaved, setIsSaved] = useState(false);
const landingPageOptions = [
{ label: '플랫폼 소개 (기본)', value: '/home' },
{ label: '자산 대시보드', value: '/asset/dashboard' },
{ label: 'CCTV 모니터링', value: '/cctv/monitoring' },
{ label: '생산 대시보드', value: '/production/dashboard' }
];
const handleSave = async () => {
setIsSaving(true);
try {
// 1. Save local preferences
localStorage.setItem(`landing_page_${user?.id}`, preferences.landingPage);
// 2. Save server-side preferences (Session Timeout)
await apiClient.put('/profile', {
session_timeout: preferences.sessionTimeout
});
setIsSaved(true);
setTimeout(() => setIsSaved(false), 3000);
} catch (error) {
console.error('Failed to save preferences', error);
alert('설정 저장 중 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
};
return (
<div className="page-container p-6 max-w-4xl mx-auto">
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
<div className="grid grid-cols-1 gap-6">
<Card className="p-6">
<div className="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
<Home className="text-blue-600" size={24} />
<h2 className="text-lg font-bold"> </h2>
</div>
<div className="max-w-md">
<p className="text-sm text-slate-600 mb-4">
.
</p>
<Select
label="접속 시작 페이지"
value={preferences.landingPage}
onChange={(e) => setPreferences({ ...preferences, landingPage: e.target.value })}
options={landingPageOptions}
/>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
<User className="text-indigo-600" size={24} />
<h2 className="text-lg font-bold"> </h2>
</div>
<div className="grid grid-cols-2 gap-6 text-sm">
<div>
<span className="text-slate-500 block mb-1"> </span>
<span className="font-bold text-slate-900">{user?.name}</span>
</div>
<div>
<span className="text-slate-500 block mb-1"> </span>
<span className="font-bold text-indigo-600 capitalize">{user?.role}</span>
</div>
<div>
<span className="text-slate-500 block mb-1"> </span>
<span className="font-medium text-slate-700">{user?.department || '미지정'}</span>
</div>
<div>
<span className="text-slate-500 block mb-1"> </span>
<span className="font-bold text-blue-600">{user?.session_timeout}</span>
</div>
</div>
</Card>
<Card className="p-6">
<div className="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
<Clock className="text-orange-600" size={24} />
<h2 className="text-lg font-bold"> </h2>
</div>
<div className="max-w-md">
<p className="text-sm text-slate-600 mb-4">
. (단위: )
</p>
<div className="flex items-center gap-3">
<Input
type="number"
value={preferences.sessionTimeout}
onChange={(e) => setPreferences({ ...preferences, sessionTimeout: parseInt(e.target.value) || 0 })}
min={5}
max={1440}
className="!w-32"
/>
<span className="text-sm font-bold text-slate-700"> </span>
</div>
<p className="text-[11px] text-slate-400 mt-2">
* 5 1440(24) .
</p>
</div>
</Card>
{user?.role !== 'user' && (
<Card className="p-4 bg-blue-50/50 border-blue-100">
<div className="flex items-start gap-3">
<ShieldAlert className="text-blue-500 mt-0.5" size={18} />
<div>
<h3 className="text-sm font-bold text-blue-900"> </h3>
<p className="text-xs text-blue-700 mt-1 leading-relaxed">
'시스템 관리 - 기본 설정' v0.4.1.0 .<br />
<strong> </strong> <strong> </strong> .
</p>
</div>
</div>
</Card>
)}
<div className="flex items-center justify-between bg-white p-4 rounded-xl border border-slate-200 shadow-sm mt-4">
<div className="text-sm">
{isSaved && <span className="text-green-600 font-bold animate-pulse"> .</span>}
</div>
<Button
onClick={handleSave}
disabled={isSaving}
icon={isSaving ? <RefreshCcw size={18} className="animate-spin" /> : <Save size={18} />}
>
{isSaving ? '저장 중...' : '설정 저장'}
</Button>
</div>
</div>
</div>
);
}

View File

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

View File

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

View File

@ -1,11 +1,12 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Card } from '../../shared/ui/Card'; import { Card } from '../../shared/ui/Card';
import { Button } from '../../shared/ui/Button'; import { Button } from '../../shared/ui/Button';
import { Input } from '../../shared/ui/Input'; import { Input } from '../../shared/ui/Input';
import { apiClient } from '../../shared/api/client'; import { apiClient } from '../../shared/api/client';
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon } from 'lucide-react'; import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw, Printer } from 'lucide-react';
import type { User } from '../../shared/auth/AuthContext'; import type { User } from '../../shared/auth/AuthContext';
import './UserManagementPage.css'; // Will create CSS separately or inline styles initially. Let's assume global css or create specific. import { useAuth } from '../../shared/auth/AuthContext';
import './UserManagementPage.css';
interface UserFormData { interface UserFormData {
id: string; id: string;
@ -14,12 +15,15 @@ interface UserFormData {
department: string; department: string;
position: string; position: string;
phone: string; phone: string;
role: 'admin' | 'user'; role: 'supervisor' | 'admin' | 'user';
session_timeout: number;
} }
export function UserManagementPage() { export function UserManagementPage() {
const { user: currentUser } = useAuth();
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const fetchedRef = useRef(false);
// Modal State // Modal State
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@ -31,10 +35,13 @@ export function UserManagementPage() {
department: '', department: '',
position: '', position: '',
phone: '', phone: '',
role: 'user' role: 'user',
session_timeout: 10
}); });
useEffect(() => { useEffect(() => {
if (fetchedRef.current) return;
fetchedRef.current = true;
fetchUsers(); fetchUsers();
}, []); }, []);
@ -59,7 +66,8 @@ export function UserManagementPage() {
department: '', department: '',
position: '', position: '',
phone: '', phone: '',
role: 'user' role: 'user',
session_timeout: 10
}); });
setIsEditing(false); setIsEditing(false);
setIsModalOpen(true); setIsModalOpen(true);
@ -68,12 +76,13 @@ export function UserManagementPage() {
const handleOpenEdit = (user: User) => { const handleOpenEdit = (user: User) => {
setFormData({ setFormData({
id: user.id, id: user.id,
password: '', // Password empty by default on edit password: '',
name: user.name, name: user.name,
department: user.department || '', department: user.department || '',
position: user.position || '', position: user.position || '',
phone: user.phone || '', phone: user.phone || '',
role: user.role role: user.role,
session_timeout: (user as any).session_timeout || 10
}); });
setIsEditing(true); setIsEditing(true);
setIsModalOpen(true); setIsModalOpen(true);
@ -92,28 +101,20 @@ export function UserManagementPage() {
const formatPhoneNumber = (value: string) => { const formatPhoneNumber = (value: string) => {
const cleaned = value.replace(/\D/g, ''); const cleaned = value.replace(/\D/g, '');
if (cleaned.length <= 3) { if (cleaned.length <= 3) return cleaned;
return cleaned; else if (cleaned.length <= 7) return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
} else if (cleaned.length <= 7) { else return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
} else {
return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
}
}; };
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
if (isEditing) { if (isEditing) {
// Update
const payload: any = { ...formData }; const payload: any = { ...formData };
if (!payload.password) delete payload.password; // Don't send empty password if (!payload.password) delete payload.password;
await apiClient.put(`/users/${formData.id}`, payload); await apiClient.put(`/users/${formData.id}`, payload);
alert('수정되었습니다.'); alert('수정되었습니다.');
} else { } else {
// Create
if (!formData.password) return alert('비밀번호를 입력하세요.'); if (!formData.password) return alert('비밀번호를 입력하세요.');
await apiClient.post('/users', formData); await apiClient.post('/users', formData);
alert('등록되었습니다.'); alert('등록되었습니다.');
@ -121,9 +122,30 @@ export function UserManagementPage() {
setIsModalOpen(false); setIsModalOpen(false);
fetchUsers(); fetchUsers();
} catch (error: any) { } catch (error: any) {
console.error('Submit failed', error); alert(`오류: ${error.response?.data?.error || error.message}`);
const errorMsg = error.response?.data?.error || error.message || '저장 중 오류가 발생했습니다.'; }
alert(`오류: ${errorMsg}`); };
const getRoleBadge = (role: string) => {
switch (role) {
case 'supervisor':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-100 text-amber-700 ring-1 ring-amber-200">
<ShieldCheck size={10} className="mr-1" />
</span>
);
case 'admin':
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-100 text-indigo-700 ring-1 ring-indigo-200">
<Shield size={10} className="mr-1" />
</span>
);
default:
return (
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-600 ring-1 ring-slate-200">
<UserIcon size={10} className="mr-1" />
</span>
);
} }
}; };
@ -134,55 +156,67 @@ export function UserManagementPage() {
<h1 className="text-2xl font-bold text-slate-900"> - </h1> <h1 className="text-2xl font-bold text-slate-900"> - </h1>
<p className="text-slate-500 mt-1"> .</p> <p className="text-slate-500 mt-1"> .</p>
</div> </div>
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}> </Button> <div className="flex gap-2">
<Button variant="secondary" onClick={() => window.print()} icon={<Printer size={16} />}></Button>
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}> </Button>
</div>
</div> </div>
<Card className="overflow-hidden"> <Card className="overflow-hidden shadow-sm border-slate-200">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-sm text-left text-slate-600"> <table className="w-full text-sm text-left">
<thead className="text-xs text-slate-700 uppercase bg-slate-50 border-b border-slate-200"> <thead className="text-xs text-slate-500 uppercase bg-slate-50/80 border-b border-slate-200">
<tr> <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"></th>
<th className="px-6 py-4"> / </th> <th className="px-6 py-4"> / </th>
<th className="px-6 py-4"> ()</th>
<th className="px-6 py-4"></th> <th className="px-6 py-4"></th>
<th className="px-6 py-4"> </th> <th className="px-6 py-4"> </th>
<th className="px-6 py-4 text-center"></th> <th className="px-6 py-4 text-center"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100"> <tbody className="divide-y divide-slate-100 bg-white">
{users.map((user) => ( {loading ? (
<tr key={user.id} className="hover:bg-slate-50 transition-colors"> <tr>
<td className="px-6 py-4"> <td colSpan={6} className="px-6 py-12 text-center text-slate-400">
<div className="font-medium text-slate-900">{user.id}</div> <div className="flex flex-col items-center gap-2">
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium mt-1 ${user.role === 'admin' ? 'bg-indigo-100 text-indigo-700' : 'bg-slate-100 text-slate-600' <RefreshCcw size={24} className="animate-spin text-indigo-500" />
}`}> <span> ...</span>
{user.role === 'admin' ? <Shield size={10} className="mr-1" /> : <UserIcon size={10} className="mr-1" />} </div>
{user.role === 'admin' ? '관리자' : '사용자'}
</span>
</td> </td>
<td className="px-6 py-4 font-medium">{user.name}</td> </tr>
) : users.map((user) => (
<tr key={user.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="text-slate-900">{user.department || '-'}</div> <div className="font-bold text-slate-900">{user.id}</div>
<div className="text-slate-500 text-xs">{user.position}</div> <div className="mt-1">{getRoleBadge(user.role)}</div>
</td> </td>
<td className="px-6 py-4">{user.phone || '-'}</td> <td className="px-6 py-4 font-semibold text-slate-800">{user.name}</td>
<td className="px-6 py-4 text-slate-500"> <td className="px-6 py-4">
{user.last_login ? new Date(user.last_login).toLocaleString() : '접속 기록 없음'} <div className="text-slate-900 font-bold">{user.department || '-'}</div>
<div className="text-slate-600 text-[11px] font-medium">{user.position}</div>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex justify-center gap-2"> <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>
<button className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded" onClick={() => handleOpenEdit(user)} title="수정"> </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() : '미접속'}
</td>
<td className="px-6 py-4">
<div className="flex justify-center gap-1">
<button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(user)}>
<Edit2 size={16} /> <Edit2 size={16} />
</button> </button>
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded" onClick={() => handleDelete(user.id)} title="삭제"> <button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(user.id)}>
<Trash2 size={16} /> <Trash2 size={16} />
</button> </button>
</div> </div>
</td> </td>
</tr> </tr>
))} ))}
{users.length === 0 && !loading && ( {!loading && users.length === 0 && (
<tr> <tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-400"> .</td> <td colSpan={6} className="px-6 py-12 text-center text-slate-400"> .</td>
</tr> </tr>
@ -194,46 +228,52 @@ export function UserManagementPage() {
{/* Modal */} {/* Modal */}
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"> <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-md shadow-2xl animate-in fade-in zoom-in duration-200"> <Card className="w-full max-w-md 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"> <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"> <h2 className="text-lg font-bold text-slate-800">
{isEditing ? '사용자 정보 수정' : '새 사용자 등록'} {isEditing ? '사용자 정보 수정' : '새 사용자 등록'}
</h2> </h2>
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600"> <button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1">
<X size={20} /> <X size={18} />
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off"> <form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <span className="text-red-500">*</span></label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> <span className="text-red-500">*</span></label>
<Input <Input
name="user_id_new"
autoComplete="off"
readOnly={!isEditing}
onFocus={(e) => e.target.readOnly = false}
value={formData.id} value={formData.id}
onChange={(e) => setFormData({ ...formData, id: e.target.value })} onChange={(e) => setFormData({ ...formData, id: e.target.value })}
disabled={isEditing} // ID cannot be changed on edit disabled={isEditing}
placeholder="로그인 아이디" placeholder="로그인 아이디 입력"
required required
autoComplete="off"
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">
<span className="text-red-500">{!isEditing && '*'}</span> <span className="text-red-500">{!isEditing && '*'}</span>
</label> </label>
<Input <Input
name="user_password_new"
autoComplete="new-password"
readOnly={!isEditing}
onFocus={(e) => e.target.readOnly = false}
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={isEditing ? "(변경시에만 입력)" : "비밀번호"} placeholder={isEditing ? "(변경시에만 입력)" : "초기 비밀번호 입력"}
required={!isEditing} required={!isEditing}
autoComplete="new-password"
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"> <span className="text-red-500">*</span></label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> <span className="text-red-500">*</span></label>
<Input <Input
value={formData.name} value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
@ -241,28 +281,38 @@ export function UserManagementPage() {
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"></label>
<select <select
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-500 focus:border-transparent" className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all outline-none"
value={formData.role} value={formData.role}
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'user' | 'admin' })} onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
disabled={
(isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor' && currentUser?.role !== 'supervisor') ||
(formData.role === 'supervisor' && currentUser?.role !== 'supervisor')
}
> >
<option value="user"> </option> <option value="user"></option>
<option value="admin"> </option> <option value="admin"></option>
{(currentUser?.role === 'supervisor' || (isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor')) && (
<option value="supervisor"></option>
)}
</select> </select>
{currentUser?.role !== 'supervisor' && (
<p className="text-[10px] text-slate-400 mt-1">* .</p>
)}
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"></label>
<Input <Input
value={formData.department} value={formData.department}
onChange={(e) => setFormData({ ...formData, department: e.target.value })} onChange={(e) => setFormData({ ...formData, department: e.target.value })}
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1"></label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"></label>
<Input <Input
value={formData.position} value={formData.position}
onChange={(e) => setFormData({ ...formData, position: e.target.value })} onChange={(e) => setFormData({ ...formData, position: e.target.value })}
@ -270,19 +320,34 @@ export function UserManagementPage() {
</div> </div>
</div> </div>
<div> <div className="grid grid-cols-2 gap-4">
<label className="block text-sm font-medium text-slate-700 mb-1"> </label> <div>
<Input <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> </label>
value={formData.phone} <Input
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })} value={formData.phone}
placeholder="예: 010-1234-5678" onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
maxLength={13} placeholder="010-0000-0000"
/> maxLength={13}
/>
</div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
() <span className="text-red-500">*</span>
</label>
<Input
type="number"
value={formData.session_timeout}
onChange={(e) => setFormData({ ...formData, session_timeout: parseInt(e.target.value) || 0 })}
min={5}
placeholder="기본 10"
required
/>
</div>
</div> </div>
<div className="pt-4 flex justify-end gap-2"> <div className="pt-4 flex justify-end gap-2">
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}></Button> <Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}></Button>
<Button type="submit" icon={<Check size={16} />}>{isEditing ? '저장' : '등록'}</Button> <Button type="submit" className="bg-indigo-600" icon={<Check size={16} />}>{isEditing ? '저장' : '전송'}</Button>
</div> </div>
</form> </form>
</Card> </Card>

View File

@ -0,0 +1,397 @@
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';
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;
}
export function VersionPage() {
const [healthIcon, setHealthInfo] = useState<VersionInfo | null>(null);
const [remoteInfo, setRemoteInfo] = useState<RemoteVersion | null>(null);
const [loading, setLoading] = useState(true);
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()}`);
setHealthInfo(res.data);
} catch (err) {
console.error('Failed to fetch version info', err);
} finally {
setLoading(false);
}
};
const fetchRemoteVersion = async () => {
setCheckingRemote(true);
try {
const res = await apiClient.get(`/system/version/remote?t=${Date.now()}`);
setRemoteInfo(res.data);
} catch (err) {
console.error('Failed to fetch remote version info', err);
} finally {
setCheckingRemote(false);
setCurrentPage(1);
}
};
useEffect(() => {
fetchVersion();
fetchRemoteVersion();
}, []);
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업데이트 중에는 시스템이 일시적으로 중단될 수 있습니다.`)) {
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 || '업데이트 요청 중 오류가 발생했습니다.'
});
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;
return (
<div className="page-container p-6 max-w-4xl mx-auto">
<div className="flex justify-between items-start mb-8">
<div>
<h1 className="text-2xl font-bold text-slate-900"> - </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
<button
onClick={() => { fetchVersion(); fetchRemoteVersion(); }}
disabled={loading || checkingRemote}
className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors disabled:opacity-50"
title="새로고침"
>
<RefreshCw size={20} className={loading || checkingRemote ? 'animate-spin' : ''} />
</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} />
</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>
</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'}`}
>
{updating ? <RefreshCw size={22} className="animate-spin" /> : null}
{updating ? '설치 중...' : '지금 업데이트'}
</button>
</div>
)}
{/* Update Result Message */}
{updateResult && (
<div className={`mb-8 p-4 rounded-xl flex items-center gap-3 border ${updateResult.success ? 'bg-emerald-50 border-emerald-200 text-emerald-900' : 'bg-red-50 border-red-200 text-red-900'}`}>
{updateResult.success ? <CheckCircle2 size={20} className="text-emerald-500" /> : <AlertTriangle size={20} className="text-red-500" />}
<p className="font-medium">{updateResult.message}</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Frontend Platform Version */}
<Card className="p-6 border-slate-200 shadow-sm relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Cpu size={120} />
</div>
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-xl">
<Hash size={24} />
</div>
<div>
<h3 className="text-sm font-bold text-slate-500 uppercase tracking-wider">Frontend Platform</h3>
<p className="text-xl font-black text-slate-900">Smart IMS </p>
</div>
</div>
<div className="space-y-4">
<div className="flex justify-between items-center py-2 border-b border-slate-50">
<span className="text-slate-500 text-sm flex items-center gap-2"><Info size={14} /> </span>
<span className="font-bold text-indigo-600 bg-indigo-50 px-3 py-1 rounded-full text-xs">v{currentVersion}</span>
</div>
<div className="flex justify-between items-center py-2 border-b border-slate-50">
<span className="text-slate-500 text-sm flex items-center gap-2"><Calendar size={14} /> </span>
<span className="font-medium text-slate-700 text-sm">{buildDate}</span>
</div>
<div className="flex justify-between items-center py-2">
<span className="text-slate-500 text-sm flex items-center gap-2"><Database size={14} /> </span>
<span className="font-medium text-slate-700 text-sm">React Agentic Architecture</span>
</div>
</div>
</Card>
{/* Backend Server Version */}
<Card className="p-6 border-slate-200 shadow-sm relative overflow-hidden group">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Server size={120} />
</div>
<div className="flex items-center gap-3 mb-6">
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-xl">
<Database size={24} />
</div>
<div>
<h3 className="text-sm font-bold text-slate-500 uppercase tracking-wider">Backend Core</h3>
<p className="text-xl font-black text-slate-900">IMS </p>
</div>
</div>
<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>
</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">
{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={`font-bold text-xs uppercase ${healthIcon?.status === 'ok' ? 'text-emerald-500' : 'text-red-500'}`}>
{loading ? '...' : healthIcon?.status === 'ok' ? 'Running' : 'Offline'}
</span>
</div>
</div>
</Card>
</div>
{/* Remote Info Status (Debug/Info) */}
{remoteInfo && (
<div className="mt-4 text-[11px] text-slate-400 flex items-center gap-3 px-2">
<div className="flex items-center gap-1">
<div className={`w-1.5 h-1.5 rounded-full ${remoteInfo.error ? 'bg-red-400' : 'bg-emerald-400'}`}></div>
<span> : {remoteInfo.error ? `오류 (${remoteInfo.error})` : '정상'}</span>
</div>
{!remoteInfo.error && (
<span> : <span className="font-bold text-slate-500">{remoteInfo.latest || '없음'}</span></span>
)}
</div>
)}
{/* Release History Section */}
<div className="mt-12 space-y-6">
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2 mb-4">
<Calendar size={22} className="text-indigo-600" />
</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>
</div>
)}
</>
) : (
<div className="text-center py-12 text-slate-400 italic bg-slate-50 rounded-xl border border-dashed border-slate-200">
.
</div>
)}
</div>
</div>
{/* Bottom Integrity Banner */}
<Card className="mt-12 p-6 bg-slate-900 text-white border-none shadow-xl overflow-hidden relative">
<div className="relative z-10">
<h3 className="text-lg font-bold mb-2 flex items-center gap-2">
<ShieldCheck size={20} className="text-amber-400" /> Platform Integrity
</h3>
<p className="text-slate-400 text-sm leading-relaxed max-w-2xl">
Smart IMS .
Sokuree , (L2 Protection) .
</p>
</div>
<div className="absolute -bottom-4 -right-4 opacity-10">
<Box size={160} />
</div>
</Card>
</div>
);
}
// Internal Local Components if not available in shared/ui
function ShieldCheck({ size, className }: { size?: number, className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size || 24} height={size || 24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
<path d="m9 12 2 2 4-4" />
</svg>
);
}
function Box({ size, className }: { size?: number, className?: string }) {
return (
<svg xmlns="http://www.w3.org/2000/svg" width={size || 24} height={size || 24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
<path d="m3.3 7 8.7 5 8.7-5" />
<path d="M12 22V12" />
</svg>
);
}

View File

@ -60,6 +60,7 @@ body {
a { a {
text-decoration: none; text-decoration: none;
color: inherit; color: inherit;
cursor: pointer;
} }
button { button {
@ -96,3 +97,142 @@ button {
padding: var(--sokuree-spacing-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); box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
} }
/* Utility Animations */
.animate-fade-in {
animation: fadeIn 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* ==========================================
Print Styles
========================================== */
@media print {
/* Hide UI elements that shouldn't be printed */
header,
nav,
aside,
.sidebar,
.top-header,
.no-print,
button,
.btn,
.ui-button,
.table-toolbar,
.pagination-bar,
.form-actions,
.form-actions-footer,
.section-divider,
.badge-neutral {
display: none !important;
}
/* Reset layout for print */
body {
background-color: white !important;
}
.layout-container {
display: block !important;
}
.main-content {
margin-left: 0 !important;
padding: 0 !important;
width: 100% !important;
}
.content-area {
padding: 0 !important;
}
.page-container {
margin: 0 !important;
padding: 0 !important;
}
.content-card,
.sokuree-card {
border: none !important;
box-shadow: none !important;
padding: 0 !important;
margin: 0 !important;
}
/* Ensure original layout is kept but scaled to fit */
.document-layout {
display: flex !important;
flex-direction: row !important;
/* Keep original row layout */
gap: 15pt !important;
align-items: flex-start !important;
width: 100% !important;
zoom: 0.85;
/* Scale down slightly to fit Portrait width */
}
/* Adjust image area for print to be more compact */
.doc-image-area {
width: 200pt !important;
height: 200pt !important;
flex: none !important;
border: 1px solid #e2e8f0 !important;
}
.doc-info-area {
flex: 1 !important;
min-width: 0 !important;
/* Allow shrinking */
}
.doc-table {
width: 100% !important;
table-layout: fixed !important;
/* Maintain the fixed structure requested */
border: 1px solid #cbd5e1 !important;
}
.doc-table th,
.doc-table td {
padding: 4pt 6pt !important;
/* Tighter padding for print */
font-size: 10pt !important;
height: 35pt !important;
/* Reduced height for compactness */
word-break: break-all !important;
}
/* Reset colgroup if needed specifically for print if still tight */
.doc-table col {
height: auto !important;
}
/* Force background colors and colors to show up */
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
/* Typography adjustments for print */
.page-title-text {
font-size: 24pt !important;
margin-bottom: 20pt !important;
text-align: center;
}
.doc-table th {
background-color: #f1f5f9 !important;
}
}

View File

@ -3,6 +3,8 @@ import { apiClient } from './client';
// Frontend Interface (CamelCase) // Frontend Interface (CamelCase)
export interface Asset { export interface Asset {
id: string; id: string;
parentId?: string; // DB: parent_id
parentName?: string; // Joined
name: string; name: string;
category: string; category: string;
model: string; // DB: model_name model: string; // DB: model_name
@ -16,11 +18,15 @@ export interface Asset {
image?: string; // DB: image_url image?: string; // DB: image_url
calibrationCycle?: string; // DB: calibration_cycle calibrationCycle?: string; // DB: calibration_cycle
specs?: string; specs?: string;
quantity?: number;
children?: Partial<Asset>[]; // List of sub-assets
} }
// DB Interface (SnakeCase) - for internal mapping // DB Interface (SnakeCase) - for internal mapping
interface DBAsset { interface DBAsset {
id: string; id: string;
parent_id?: string;
parent_name?: string; // Joined or added in response
name: string; name: string;
category: string; category: string;
model_name: string; model_name: string;
@ -34,8 +40,10 @@ interface DBAsset {
image_url?: string; image_url?: string;
calibration_cycle: string; calibration_cycle: string;
specs: string; specs: string;
quantity?: number;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
children?: any[];
} }
export interface MaintenanceRecord { export interface MaintenanceRecord {
@ -69,13 +77,32 @@ export interface Manual {
export const assetApi = { export const assetApi = {
getAll: async (): Promise<Asset[]> => { getAll: async (): Promise<Asset[]> => {
const response = await apiClient.get<DBAsset[]>('/assets'); try {
return response.data.map(mapDBToAsset); const response = await apiClient.get<DBAsset[]>('/assets');
if (!response.data || !Array.isArray(response.data)) return [];
return response.data.map(mapDBToAsset);
} catch (err) {
console.error('API Error in getAll:', err);
throw err;
}
}, },
getById: async (id: string): Promise<Asset> => { getById: async (id: string): Promise<Asset> => {
const response = await apiClient.get<DBAsset>(`/assets/${id}`); const response = await apiClient.get<DBAsset>(`/assets/${id}`);
return mapDBToAsset(response.data); const asset = mapDBToAsset(response.data);
if (response.data.children) {
asset.children = response.data.children.map(c => ({
id: c.id,
name: c.name,
category: c.category,
status: c.status,
location: c.location
}));
}
if (response.data.parent_name) {
asset.parentName = response.data.parent_name;
}
return asset;
}, },
create: (data: Partial<Asset>) => { create: (data: Partial<Asset>) => {
@ -130,12 +157,27 @@ export const assetApi = {
deleteManual: async (id: number) => { deleteManual: async (id: number) => {
return apiClient.delete(`/manuals/${id}`); return apiClient.delete(`/manuals/${id}`);
},
// Accessories
getAccessories: async (assetId: string): Promise<any[]> => {
const response = await apiClient.get<any[]>(`/assets/${assetId}/accessories`);
return response.data;
},
addAccessory: async (assetId: string, data: { name: string, spec: string, quantity: number }) => {
return apiClient.post(`/assets/${assetId}/accessories`, data);
},
deleteAccessory: async (id: number) => {
return apiClient.delete(`/accessories/${id}`);
} }
}; };
// Helper Mappers // Helper Mappers
const mapDBToAsset = (db: DBAsset): Asset => ({ const mapDBToAsset = (db: DBAsset): Asset => ({
id: db.id, id: db.id,
parentId: db.parent_id,
name: db.name, name: db.name,
category: db.category, category: db.category,
model: db.model_name || '', model: db.model_name || '',
@ -148,11 +190,13 @@ const mapDBToAsset = (db: DBAsset): Asset => ({
purchasePrice: db.purchase_price, purchasePrice: db.purchase_price,
image: db.image_url, image: db.image_url,
calibrationCycle: db.calibration_cycle || '', calibrationCycle: db.calibration_cycle || '',
specs: db.specs || '' specs: db.specs || '',
quantity: db.quantity
}); });
const mapAssetToDB = (asset: Partial<Asset>): Partial<DBAsset> => { const mapAssetToDB = (asset: Partial<Asset>): Partial<DBAsset> => {
const db: any = { ...asset }; const db: any = { ...asset };
if (asset.parentId !== undefined) { db.parent_id = asset.parentId; delete db.parentId; }
if (asset.model !== undefined) { db.model_name = asset.model; delete db.model; } if (asset.model !== undefined) { db.model_name = asset.model; delete db.model; }
if (asset.serialNumber !== undefined) { db.serial_number = asset.serialNumber; delete db.serialNumber; } if (asset.serialNumber !== undefined) { db.serial_number = asset.serialNumber; delete db.serialNumber; }
if (asset.purchaseDate !== undefined) { db.purchase_date = asset.purchaseDate; delete db.purchaseDate; } if (asset.purchaseDate !== undefined) { db.purchase_date = asset.purchaseDate; delete db.purchaseDate; }

View File

@ -25,3 +25,18 @@ apiClient.interceptors.request.use((config) => {
} }
return config; return config;
}); });
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
// Avoid infinite loops if we are already on login page or checking session
const currentPath = window.location.pathname;
if (currentPath !== '/login' && !error.config.url.includes('/check')) {
console.warn('Session expired or security error. Redirecting to login.');
// Brute force redirect for simplicity in MVP
window.location.href = '/login?expired=true';
}
}
return Promise.reject(error);
}
);

View File

@ -1,13 +1,14 @@
import { createContext, useContext, useState, type ReactNode, useEffect } from 'react'; import { createContext, useContext, useState, type ReactNode, useEffect, useRef } from 'react';
import { apiClient, setCsrfToken } from '../api/client'; import { apiClient, setCsrfToken } from '../api/client';
export interface User { export interface User {
id: string; id: string;
name: string; name: string;
role: 'admin' | 'user'; role: 'supervisor' | 'admin' | 'user';
department?: string; department?: string;
position?: string; position?: string;
phone?: string; phone?: string;
session_timeout?: number;
last_login?: string; last_login?: string;
} }
@ -24,25 +25,76 @@ const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) { export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const isCheckingRef = useRef(false);
const lastActivityRef = useRef(Date.now());
const timeoutMsRef = useRef(3600000); // 1 hour default
// Check for existing session on mount
useEffect(() => { useEffect(() => {
const checkSession = async () => { const checkSession = async (isBackground = false) => {
if (isCheckingRef.current) return;
isCheckingRef.current = true;
try { try {
const response = await apiClient.get('/check'); const response = await apiClient.get('/check');
if (response.data.isAuthenticated) { if (response.data.isAuthenticated) {
setUser(response.data.user); setUser(response.data.user);
if (response.data.csrfToken) { if (response.data.csrfToken) setCsrfToken(response.data.csrfToken);
setCsrfToken(response.data.csrfToken); if (response.data.sessionTimeout) {
timeoutMsRef.current = response.data.sessionTimeout;
} }
} else {
// Safety: only redirect if we were previously logged in
setUser(prev => {
if (prev || !isBackground) {
setCsrfToken(null);
if (!window.location.pathname.includes('/login')) {
window.location.href = '/login?expired=true';
}
return null;
}
return prev;
});
} }
} catch (error) { } catch (error) {
console.error('Session check failed:', error); console.error('Session check failed', error);
} finally { } finally {
isCheckingRef.current = false;
setIsLoading(false); setIsLoading(false);
} }
}; };
const updateActivity = () => {
lastActivityRef.current = Date.now();
};
// Activity Listeners
window.addEventListener('mousemove', updateActivity);
window.addEventListener('keydown', updateActivity);
window.addEventListener('scroll', updateActivity);
window.addEventListener('click', updateActivity);
// Initial check on mount
checkSession(); checkSession();
// Check activity status
const activityInterval = setInterval(() => {
const idleTime = Date.now() - lastActivityRef.current;
if (idleTime >= timeoutMsRef.current) {
checkSession(true);
}
}, 30000);
// Fallback polling
const pollInterval = setInterval(() => checkSession(true), 300000);
return () => {
window.removeEventListener('mousemove', updateActivity);
window.removeEventListener('keydown', updateActivity);
window.removeEventListener('scroll', updateActivity);
window.removeEventListener('click', updateActivity);
clearInterval(activityInterval);
clearInterval(pollInterval);
};
}, []); }, []);
const login = async (id: string, password: string) => { const login = async (id: string, password: string) => {

View File

@ -1,4 +1,4 @@
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'; import { createContext, useContext, useState, useEffect, type ReactNode, useRef } from 'react';
import { apiClient } from '../api/client'; import { apiClient } from '../api/client';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
@ -20,6 +20,7 @@ export function SystemProvider({ children }: { children: ReactNode }) {
const [modules, setModules] = useState<Record<string, ModuleState>>({}); const [modules, setModules] = useState<Record<string, ModuleState>>({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const { isAuthenticated } = useAuth(); // Only load if authenticated const { isAuthenticated } = useAuth(); // Only load if authenticated
const isFetchingRef = useRef<string | null>(null); // Track if already fetched for current state
const refreshModules = async () => { const refreshModules = async () => {
try { try {
@ -40,8 +41,12 @@ export function SystemProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (isAuthenticated) { if (isAuthenticated) {
// Only fetch if we haven't fetched for this authenticated state yet
if (isFetchingRef.current === 'authed') return;
isFetchingRef.current = 'authed';
refreshModules(); refreshModules();
} else { } else {
isFetchingRef.current = 'guest';
setModules({}); setModules({});
setIsLoading(false); setIsLoading(false);
} }

View File

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

View File

@ -103,6 +103,10 @@
transition: var(--transition-base); transition: var(--transition-base);
font-weight: 500; font-weight: 500;
font-size: 0.9rem; font-size: 0.9rem;
text-decoration: none;
cursor: pointer;
position: relative;
z-index: 5;
} }
.nav-item:hover { .nav-item:hover {
@ -181,26 +185,45 @@
background-color: var(--sokuree-bg-main); background-color: var(--sokuree-bg-main);
} }
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Top Header - White Theme */ /* Top Header - White Theme */
.top-header { .top-header {
height: 64px; height: 64px;
background-color: var(--sokuree-bg-card); background-color: var(--sokuree-bg-card);
/* White Header */
border-bottom: 1px solid var(--sokuree-border-color); border-bottom: 1px solid var(--sokuree-border-color);
display: flex; display: flex;
align-items: flex-end; align-items: center;
/* Tabs at bottom */ padding: 0 1.5rem;
padding: 0 2rem;
color: var(--sokuree-text-primary); color: var(--sokuree-text-primary);
justify-content: space-between;
}
.header-portal-root {
flex: 1;
display: flex;
align-items: center;
height: 100%;
} }
.header-tabs { .header-tabs {
display: flex; display: flex;
gap: 0.5rem; gap: 1.5rem;
/* Better gap between tabs */
} }
.tab-item { .tab-item {
padding: 0.75rem 1.5rem; padding: 0.75rem 0.5rem;
font-weight: 500; font-weight: 500;
color: var(--sokuree-text-secondary); color: var(--sokuree-text-secondary);
border-bottom: 3px solid transparent; border-bottom: 3px solid transparent;
@ -211,20 +234,17 @@
position: relative; position: relative;
top: 1px; top: 1px;
text-decoration: none; text-decoration: none;
white-space: nowrap;
} }
.tab-item:hover { .tab-item:hover {
color: var(--sokuree-brand-primary); color: var(--sokuree-brand-primary);
background-color: rgba(0, 0, 0, 0.02);
border-top-left-radius: 4px;
border-top-right-radius: 4px;
} }
.tab-item.active { .tab-item.active {
color: var(--sokuree-brand-primary); color: var(--sokuree-brand-primary);
font-weight: 700; font-weight: 700;
border-bottom-color: var(--sokuree-brand-primary); border-bottom-color: var(--sokuree-brand-primary);
/* Blue underline */
} }
.header-actions { .header-actions {

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../shared/auth/AuthContext'; import { useAuth } from '../../shared/auth/AuthContext';
import { useSystem } from '../../shared/context/SystemContext'; import { useSystem } from '../../shared/context/SystemContext';
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield } from 'lucide-react'; import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info, Home, UserCog } from 'lucide-react';
import type { IModuleDefinition } from '../../core/types'; import type { IModuleDefinition } from '../../core/types';
import './MainLayout.css'; import './MainLayout.css';
@ -16,6 +16,15 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
const { modules } = useSystem(); const { modules } = useSystem();
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']); const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']);
const isAdmin = user ? (['admin', 'supervisor'] as string[]).includes(user.role) : false;
const checkRole = (requiredRoles: string[]) => {
if (!user) return false;
// Supervisor possesses all rights
if (user.role === 'supervisor') return true;
return requiredRoles.includes(user.role);
};
const toggleModule = (moduleId: string) => { const toggleModule = (moduleId: string) => {
setExpandedModules(prev => setExpandedModules(prev =>
prev.includes(moduleId) prev.includes(moduleId)
@ -37,8 +46,16 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
</div> </div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{/* Module: System Management (Platform Core) */} {/* Platform Home */}
{user?.role === 'admin' && ( <div className="module-group mb-4">
<Link to="/home" className={`nav-item ${location.pathname === '/home' ? 'active' : ''}`}>
<Home size={18} />
<span> </span>
</Link>
</div>
{/* System Management (Platform Core) */}
{isAdmin && (
<div className="module-group"> <div className="module-group">
<button <button
className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`} className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`}
@ -65,6 +82,10 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
<Shield size={18} /> <Shield size={18} />
<span>/ </span> <span>/ </span>
</Link> </Link>
<Link to="/admin/version" className={`nav-item ${location.pathname.includes('/admin/version') ? 'active' : ''}`}>
<Info size={18} />
<span> </span>
</Link>
</div> </div>
)} )}
</div> </div>
@ -75,8 +96,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
const moduleKey = mod.moduleName.split('-')[0]; const moduleKey = mod.moduleName.split('-')[0];
if (!isModuleActive(moduleKey)) return null; if (!isModuleActive(moduleKey)) return null;
// Check roles // Check roles with hierarchy
if (mod.requiredRoles && user && !mod.requiredRoles.includes(user.role)) return null; if (mod.requiredRoles && !checkRole(mod.requiredRoles)) return null;
const hasSubMenu = mod.routes.filter(r => r.label).length > 1; const hasSubMenu = mod.routes.filter(r => r.label).length > 1;
const isExpanded = expandedModules.includes(mod.moduleName); const isExpanded = expandedModules.includes(mod.moduleName);
@ -90,8 +111,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
onClick={() => toggleModule(mod.moduleName)} onClick={() => toggleModule(mod.moduleName)}
> >
<div className="module-title"> <div className="module-title">
<Layers size={18} /> {mod.moduleName === 'cctv' ? <Video size={18} /> : <Layers size={18} />}
<span>{mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1)} </span> <span>{mod.label || (mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1) + ' 관리')}</span>
</div> </div>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />} {isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button> </button>
@ -101,7 +122,14 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
const groupedRoutes: Record<string, typeof mod.routes> = {}; const groupedRoutes: Record<string, typeof mod.routes> = {};
const ungroupedRoutes: typeof mod.routes = []; const ungroupedRoutes: typeof mod.routes = [];
mod.routes.filter(r => r.label && (!r.position || r.position === 'sidebar')).forEach(route => { 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 => {
if (route.group) { if (route.group) {
if (!groupedRoutes[route.group]) groupedRoutes[route.group] = []; if (!groupedRoutes[route.group]) groupedRoutes[route.group] = [];
groupedRoutes[route.group].push(route); groupedRoutes[route.group].push(route);
@ -154,13 +182,23 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
> >
<div className="module-title"> <div className="module-title">
<Video size={18} /> <Video size={18} />
<span>{mod.moduleName.split('-')[0].toUpperCase()}</span> <span>{mod.label || mod.moduleName.split('-')[0].toUpperCase()}</span>
</div> </div>
</Link> </Link>
)} )}
</div> </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> </nav>
<div className="sidebar-footer"> <div className="sidebar-footer">
@ -188,7 +226,9 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
const activeModule = modulesList.find(m => location.pathname.startsWith(m.basePath)); const activeModule = modulesList.find(m => location.pathname.startsWith(m.basePath));
if (!activeModule) return null; if (!activeModule) return null;
const topRoutes = activeModule.routes.filter(r => r.position === 'top'); const topRoutes = location.pathname.includes('/settings')
? []
: activeModule.routes.filter(r => r.position === 'top');
return topRoutes.map(route => ( return topRoutes.map(route => (
<Link <Link
@ -201,16 +241,28 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
)); ));
})()} })()}
{/* Legacy manual override check (can be removed if Asset Settings is fully migrated to route config) */} {/* Asset Settings Specific Tabs */}
{location.pathname.includes('/asset/settings') && ( {location.pathname.startsWith('/asset/settings') && (
<> <div className="header-tabs">
{/* Keeping this just in case, but ideally should be managed via route config now? {[
Actually settings tabs are usually sub-pages of settings, not top-level module nav. { id: 'basic', label: '기본 설정' },
Leaving for safety. { id: 'category', label: '카테고리 관리' },
*/} { id: 'location', label: '설치 위치' },
</> { id: 'status', label: '자산 상태' },
{ id: 'maintenance', label: '정비 구분' }
].map(tab => (
<Link
key={tab.id}
to={`/asset/settings?tab=${tab.id}`}
className={`tab-item ${(new URLSearchParams(location.search).get('tab') || 'basic') === tab.id ? 'active' : ''}`}
>
{tab.label}
</Link>
))}
</div>
)} )}
</div> </div>
<div id="header-portal-root" className="header-portal-root"></div>
</header> </header>
<div className="content-area"> <div className="content-area">
<Outlet /> <Outlet />

BIN
temp_auth.js Normal file

Binary file not shown.

39
update_system.bat Normal file
View File

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