Compare commits
No commits in common. "main" and "v0.2.0" have entirely different histories.
16
.gitignore
vendored
16
.gitignore
vendored
@ -22,6 +22,14 @@ 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
|
||||||
@ -39,16 +47,10 @@ ehthumbs.db
|
|||||||
Desktop.ini
|
Desktop.ini
|
||||||
|
|
||||||
# Project Specific - Server
|
# Project Specific - Server
|
||||||
server/.env
|
|
||||||
server/.env.local
|
|
||||||
server/.env.backup*
|
|
||||||
backup/
|
|
||||||
*.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
BIN
auth_head.js
Binary file not shown.
BIN
auth_v0.js
BIN
auth_v0.js
Binary file not shown.
BIN
auth_v1.js
BIN
auth_v1.js
Binary file not shown.
@ -1,74 +0,0 @@
|
|||||||
# 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를 사용하여 일관성을 유지합니다.
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
# 📋 자산 분류 및 등록 기준 가이드 (Asset Classification Guide)
|
|
||||||
|
|
||||||
이 문서는 SmartIMS 자산 관리 시스템에서 각 메뉴(카테고리)별로 등록해야 할 자산의 구분 기준과 대표적인 품목들을 정의합니다. 자산 등록 시 올바른 카테고리를 선택하는 기준으로 활용하시기 바랍니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 🏗️ 시설물 관리 (Facilities)
|
|
||||||
**구분**: 생산 설비, 건축물 부속 설비 등 사업장의 유지 운영에 필요한 규모가 큰 고정 자산
|
|
||||||
- **생산 설비**: CNC 머시닝 센터, 사출 성형기, 프레스기, 산업용 로봇, 생산 라인 컨베이어 등
|
|
||||||
- **유틸리티 설비**: 공기 압축기(컴프레셔), 변압기, 배전반, 산업용 보일러, 항온항습기, 대형 펌프 등
|
|
||||||
- **건축 부속**: 소방 설비 시스템, 보안 시스템(CCTV 본체 등), 엘리베이터, 중앙 제어 조명 등
|
|
||||||
|
|
||||||
## 2. 🔧 공구 관리 (Tools)
|
|
||||||
**구분**: 생산 및 정비 작업에 사용되는 도구 중 비교적 내구성이 있고 개별 관리가 필요한 품목
|
|
||||||
- **전동/에어 공구**: 충전 드릴, 그라인더, 임팩트 렌치, 에어 커터, 전기 톱 등
|
|
||||||
- **수공구 및 일반**: 토크 렌치, 대형 바이스, 특수 렌치 세트, 공구함 등
|
|
||||||
- **치공구/금형**: 전용 지그(Jig), 성형 금형(Die), 툴 홀더, 척(Chuck) 등
|
|
||||||
|
|
||||||
## 3. 📏 계측기 관리 (Instruments)
|
|
||||||
**구분**: 정밀도 유지가 중요하며 법적 또는 사내 규정에 따라 **정기적인 검교정(Calibration)**이 필요한 장비
|
|
||||||
- **길이/정밀 측정**: 버니어 캘리퍼스, 마이크로미터, 다이얼 게이지, 하이트 게이지, 3차원 측정기 등
|
|
||||||
- **전기/전자 측정**: 디지털 멀티미터, 오실로스코프, 절연 저항계, 전력 분석기 등
|
|
||||||
- **물리/환경 측정**: 정밀 저울, 온도/습도 기록계, 압력계, 소음계, 가스 분석기 등
|
|
||||||
|
|
||||||
## 4. 🚚 차량/운반 (Vehicles)
|
|
||||||
**구분**: 인원 이동, 자재 수송 및 상하역 작업을 수행하는 모빌리티 장비
|
|
||||||
- **운반/하역 장비**: 지게차(Forklift), 전동 스태커, 핸드 자키, 전동 대차(Cart) 등
|
|
||||||
- **사내외 차량**: 법인 승용차, 화물 트럭(1톤~5톤), 통근 버스 등
|
|
||||||
- **특수 장비**: 고소 작업대, 이동식 크레인, 트랙터 등
|
|
||||||
|
|
||||||
## 5. 💻 일반 자산 (General Assets)
|
|
||||||
**구분**: 사무 환경 조성 및 지원 업무에 사용되는 집기류 및 IT 기기
|
|
||||||
- **IT/사무 기기**: PC 본체, 모니터, 노트북, 복합기, 레이저 프린터, 서버 서버랙 등
|
|
||||||
- **사무 가구**: 책상, 사무용 의자, 회의용 테이블, 문서 보관함(캐비닛), 금고 등
|
|
||||||
- **생활/환경 비품**: 공용 냉장고, 정수기, 공기 청정기, 사내 공지용 TV(DID) 등
|
|
||||||
|
|
||||||
## 6. 📦 소모품 관리 (Consumables)
|
|
||||||
**구분**: 사용 시 가치가 즉시 소멸되거나 교체 주기가 매우 짧아 재고 수량 관리가 중요한 물품
|
|
||||||
- **가공 소모품**: 드릴 비트, 엔드밀, 절단석, 사포, 용접봉 등
|
|
||||||
- **유지보수 부자재**: 산업용 오일(윤활유), 에어 필터, 구동 벨트, 베어링, 전구/LED 램프 등
|
|
||||||
- **사무/안전 용품**: 프린터 토너/카트리지, 복사 용지, 장갑, 마스크, 안전화 등
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 등록 시 주의사항
|
|
||||||
1. **관리번호 자동 생성**: 각 카테고리 선택 시 설정된 규칙(예: `FAC-2024-001`)에 따라 관리번호가 부여됩니다.
|
|
||||||
2. **검교정 대상 확인**: 계측기 카테고리 품목은 등록 시 반드시 **교정 주기**를 설정하여 알림을 관리하십시오.
|
|
||||||
3. **위치 정보**: 자산 이동 발생 시 시스템의 **설치 위치** 정보를 업데이트하여 실물과 일치시키십시오.
|
|
||||||
4. **사진 첨부**: 외관 확인 및 식별을 위해 자산 정면과 명판(Nameplate) 사진을 함께 등록할 것을 권장합니다.
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
# 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`)을 수행하세요.
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
# 운영 서버 배포 및 관리 가이드 (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
|
|
||||||
@ -1,273 +0,0 @@
|
|||||||
# 🚀 SMART IMS 통합 프로젝트 로드맵 (Integrated Master Plan)
|
|
||||||
|
|
||||||
**프로젝트명:** SOKUREE Platform - Smart Integrated Management System
|
|
||||||
**최초 작성일:** 2026-01-25
|
|
||||||
**최근 업데이트:** 2026-01-26
|
|
||||||
**버전:** v0.4.4.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.9`
|
|
||||||
- [x] **라이선스 관리 오류 수정**
|
|
||||||
- 모듈 활성화 상태 체크 미들웨어(`requireModule`) 보강 및 라이선스 만료 감지 로직 안정화
|
|
||||||
- [x] **시스템 관리 기본설정 오류 수정**
|
|
||||||
- 'monitoring' → 'cctv' 모듈 코드 마이그레이션 시 발생하는 중복 키 오류(`ER_DUP_ENTRY`) 완벽 해결
|
|
||||||
- [x] **시스템 업데이트 엔진 고도화 & 인프라 보호 (Shovel-work/삽질 기록)**
|
|
||||||
- **인프라 보호 시스템(Infrastructure Shield)**: `.env.local` 우선순위 도입으로 업데이트 시 운영 서버 설정이 초기화되는 문제 근본 해결
|
|
||||||
- **실시간 버전 동기화**: 정적 파일 대신 Git Tag를 직접 조회하도록 개선하여 업데이트 후 현재 버전 즉시 반영
|
|
||||||
- **업데이트 안정성**: 소스 교체 전 `.env`/`.env.local` 2중 백업 및 복구 시퀀스 적용
|
|
||||||
- **서버 가용성**: Synology NAS 등 저사양 환경에서의 블로킹 방지를 위한 `execSync` 차단 및 에러 핸들링 강화
|
|
||||||
|
|
||||||
#### 🏷️ Tag: `v0.4.2.11`
|
|
||||||
- [x] **CCTV 모니터링 UI 리뉴얼**
|
|
||||||
- 페이지 배경색을 플랫폼 표준인 연한 그레이(`var(--sokuree-bg-main)`)로 변경하여 일체감 형성
|
|
||||||
- 카메라 슬롯을 독립적인 카드(Card) 형태로 디자인하고 둥근 모서리(`rounded-xl`) 및 그림자(`shadow-sm`) 적용
|
|
||||||
- 고정된 검은색 배경 대신 브랜드 컬러인 다크 슬레이트(`var(--sokuree-brand-secondary)`) 배경 적용
|
|
||||||
- 빈 슬롯 디자인을 깔끔한 화이트 카드 형태로 개선하여 시각적 피로도 감소
|
|
||||||
|
|
||||||
|
|
||||||
#### 🏷️ Tag: `v0.4.3.0` [x]
|
|
||||||
- [x] **소모품 및 자재 관리 모듈(`material`) 신규 구축**
|
|
||||||
- [x] 자재/재고 관리 독립 모듈 생성 및 '기준정보' 하위 메뉴 배치
|
|
||||||
- [x] 소모품(공구, 부품, 오일 등)의 재고 상태 시각화 및 카드형 UI 구현 (`MaterialListPage`)
|
|
||||||
- [x] **부속설비 관리 UI 및 로직 고도화**
|
|
||||||
- [x] 설비 검색 전용 `AssetSearchModal` 도입으로 수천 개의 자산 중 정밀 검색 및 선택 가능
|
|
||||||
- [x] 부속 설비 등록 버튼 클릭 시 상위 설비(부모) 정보 및 카테고리 자동 바인딩 로직 구현
|
|
||||||
- [x] 상위 설비 선택 방식을 드롭다운에서 검색 모달 방식으로 전면 교체하여 데이터 무결성 강화
|
|
||||||
- [x] **사이드바 메뉴 구조 전면 개편**
|
|
||||||
- [x] **기준정보(Standard Info) 그룹**: 자산 관리, 자재/재고 관리를 통합하여 관리 효율성 증대
|
|
||||||
- [x] **설정(Settings) 그룹**: IoT 센서 모니터링, 모듈 관리, 설비 관리 메뉴 신설 및 구조화
|
|
||||||
- [x] **사용자 맞춤형 권한 제어 엔진 구현**
|
|
||||||
- [x] `users` 테이블 스키마 확장(`allowed_modules` JSON 도입)을 통한 사용자별 모듈 접근 제한 지원
|
|
||||||
- [x] 사용자 관리 화면에 모듈별 접근 권한 체크박스 UI 추가하여 직관적인 권한 부여 가능
|
|
||||||
- [x] 권한에 따른 사이드바 메뉴 동적 필터링 시스템 구축 (최고관리자 면제 로직 포함)
|
|
||||||
|
|
||||||
#### 🏷️ Tag: `v0.4.3.1` (완료)
|
|
||||||
- [x] **모듈 통합 및 구조 최적화**
|
|
||||||
- [x] '자재 관리'와 '재고 관리' 모듈을 `material` 모듈로 통합 (`자재/재고 관리`)
|
|
||||||
- [x] 중복되는 `inventory` 모듈을 제거하고 비즈니스 로직을 `material`로 흡수하여 관리 효율성 증대
|
|
||||||
- [x] **스마트 관리 모듈 라인업 확장**
|
|
||||||
- [x] 품질 관리 모듈(`quality`) 신규 구축 및 핵심 지표(불량률, 합격률, Cpk 등) 시각화 대시보드 구현
|
|
||||||
- [x] **라이선스 및 시스템 설정 고도화**
|
|
||||||
- [x] 라이선스 관리 화면(`LicensePage`)에 통합된 '자재/재고' 및 '품질 관리' 모듈 즉시 연동
|
|
||||||
- [x] 서버 기동 시 DB(`system_modules`) 내 모듈 정보 자동 동기화 및 전역 권한 설정 최적화
|
|
||||||
- [x] **플랫폼 신뢰성 강화 (Runtime Stability)**
|
|
||||||
- [x] 사용자 관리 페이지의 데이터 가드 로직 강화로 불완전한 데이터 수신 시 발생하던 런타임 오류(흰 화면) 해결
|
|
||||||
- [x] **라이선스 발급 표준 정의**
|
|
||||||
- [x] 발급 관리 프로그램 연동을 위한 모듈별 고유 코드(`asset`, `production`, `material`, `quality`, `cctv`) 및 필수 규격 확정
|
|
||||||
|
|
||||||
#### 🏷️ Tag: `v0.4.3.5` (완료)
|
|
||||||
- [x] **버전 관리 표준 확립**
|
|
||||||
- MAJOR.MINOR.PATCH.BUILD 4단계 체계 도입 및 문서화 (`version manage.md`)
|
|
||||||
- 배포 표준 절차(Standard Deployment Procedure) 정의
|
|
||||||
- [x] **시스템 안정성 및 빌드 최적화**
|
|
||||||
- 빌드 시 정적 자산 강제 업데이트 스크립트 보강
|
|
||||||
- 미사용 변수 및 린트 에러 수정을 통한 빌드 실패 방지
|
|
||||||
- 버전 정보 동기화 로직 최적화 (package.json 기반)
|
|
||||||
|
|
||||||
#### 🏷️ Tag: `v0.4.4.0` (진행중)
|
|
||||||
- [x] **모듈 카테고리 동적 생성 기능**
|
|
||||||
- 모듈 정의(`IModuleDefinition`) 시 카테고리를 하드코딩하지 않고 직접 문자열로 정의 가능하도록 개선
|
|
||||||
- 사이드바 그룹화 로직을 동적으로 처리하여 신규 카테고리 추가 시 코드 수정 최소화
|
|
||||||
- [x] **UI 시각화 및 사용성 개선**
|
|
||||||
- 사이드바 메뉴 선택 시 하이라이트 색상을 브랜드 테마(`var(--sokuree-brand-primary)`)와 일치시켜 시각적 일관성 확보
|
|
||||||
- [x] **통합 로드맵 및 문서 현행화**
|
|
||||||
- `INTEGRATED_ROADMAP.md` 업데이트 및 버전 관리 규정 반영
|
|
||||||
|
|
||||||
#### 🏷️ Tag: `v0.5.0.x`
|
|
||||||
- [ ] 모듈 간 연동을 위한 API 인터페이스 정의
|
|
||||||
- [ ] 자재/재고 관리 모듈 추가
|
|
||||||
- [ ] 생산관리 모듈
|
|
||||||
- [ ] 생산 공정 메뉴
|
|
||||||
- [ ] 생산 라인 메뉴
|
|
||||||
- [ ] 생산 실적 관리 메뉴
|
|
||||||
- [ ] 품질관리 모듈
|
|
||||||
|
|
||||||
- [ ] IoT 모듈
|
|
||||||
- [ ] IoT 센서 모니터링 메뉴
|
|
||||||
- [ ] IoT 센서 데이터 수집 및 저장
|
|
||||||
- [ ] IoT 센서 데이터 시각화
|
|
||||||
|
|
||||||
- [ ] **사용자 관리 시스템 강화**
|
|
||||||
- 사용자 고유 관리번호 도입 (중복 불가)
|
|
||||||
- 프로필 이미지 필드 추가 및 업로드 기능
|
|
||||||
- 사용자 목록 UI 최적화 (페이지당 10개 제한, 네비게이션 추가)
|
|
||||||
- 복합 검색 기능 (관리번호, 아이디, 이름) 및 소속 필터 적용
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🟩 Phase 2: 자산관리 MVP 및 모니터링 (Core Assets & IoT)
|
|
||||||
**목표:** 통합 자산관리의 핵심 기능 구현 및 실시간 데이터 연동 기반 구축
|
|
||||||
|
|
||||||
#### 🏷️ Tag: `v0.5.1.x`
|
|
||||||
- [ ] **자산관리 기본 모듈 (Asset CRUD)**
|
|
||||||
- 6대 카테고리(`FAC`, `TOL`, `INS`, `VEH`, `GEN`, `CSM`) 기반 관리 체계 확립
|
|
||||||
- 자산 등록/수정/삭제 웹 인터페이스 구현
|
|
||||||
- QR 코드 자동 생성 및 라벨 출력 기능
|
|
||||||
- [ ] **에너지 미터 모니터링 (Energy Meter)**
|
|
||||||
- MQTT 프로토콜 기반 실시간 데이터 수집 (qideun.com:1883)
|
|
||||||
- 전압, 전류, 전력량, 역률 시각화
|
|
||||||
- 년/월/일/시간별 누적 소비량 통계 및 차트 구현
|
|
||||||
- [ ] **CCTV 스트리밍 연동**
|
|
||||||
- 현재의 JSMpeg 기반 스트리밍 구조 유지 및 모니터링 탭 통합
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🟧 Phase 3: 지능형 통합 및 품질 관리 (Smart Integration)
|
|
||||||
**목표:** 모듈 간 데이터 연쇄 작용 및 생산/품질 시스템과의 연동 고도화
|
|
||||||
|
|
||||||
#### 🏷️ Tag: `v0.6.0.x`
|
|
||||||
- [ ] **스마트 센서 매핑 및 확장 시스템**
|
|
||||||
- 설비별 임의 센서(온도, 진동 등)를 동적으로 추가할 수 있는 Sensor Mapping 기능
|
|
||||||
- 센서 임계치 도달 시 **고장 접수 자동화** 및 **예방정비(PM) 알림** 연계
|
|
||||||
- [ ] **품질-생산 연동 (Accuracy Management)**
|
|
||||||
- 계측기 검교정 후 발생하는 **보정치(Bias/Offset)** 실시간 저장
|
|
||||||
- **MES 연동**: 생산 공정 데이터 측정 시 자산 모듈의 보정 계수를 실시간 반영하여 정밀도 확보
|
|
||||||
- [ ] **예방정비-재고 자동 연동**
|
|
||||||
- 예방정비 스케줄 도래 시 필요한 소모품(`CSM`)의 재고 확인 및 자동 할당
|
|
||||||
- 소모품 안전재고(ROP) 기반 자동 발주 알림 처리
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ 기술 상세 규격 (Technical Specifications)
|
|
||||||
|
|
||||||
### 1. 전력 모니터링 MQTT 규격
|
|
||||||
- **Topic:** `sokuree/home/ems` (실시간), `sokuree/home/ems/report` (통계)
|
|
||||||
- **Data Payload:**
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"node_id": "PZEM004T-R4",
|
|
||||||
"voltage": "225.20",
|
|
||||||
"current": "2.17",
|
|
||||||
"power": "408.40",
|
|
||||||
"energy": "157.94"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. 고도화된 데이터 모델 (Interface)
|
|
||||||
|
|
||||||
#### 시설물 (Facility + IoT + Parts)
|
|
||||||
```typescript
|
|
||||||
interface FacilityAsset extends AssetBase {
|
|
||||||
iotConfig?: {
|
|
||||||
nodeId: string;
|
|
||||||
sensors: Array<{ type: string; field: string; threshold?: number }>;
|
|
||||||
};
|
|
||||||
linkedParts: Array<{ itemId: string; quantity: number }>; // 예방정비 소요 부품
|
|
||||||
maintenance: { nextDate: string; intervalDays: number };
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 계측기 (Instrument + Correction)
|
|
||||||
```typescript
|
|
||||||
interface InstrumentAsset extends AssetBase {
|
|
||||||
calibration: {
|
|
||||||
nextDate: string;
|
|
||||||
correction: { bias: number; slope: number }; // 생산관리 연동용 보정치
|
|
||||||
};
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 자산 분류 및 관리 전략
|
|
||||||
| 코드 | 분류명 | 핵심 연동 항목 |
|
|
||||||
| :--- | :--- | :--- |
|
|
||||||
| **FAC** | 시설물 | **PM(예방정비), IoT 가동률, 소모품 재고** |
|
|
||||||
| **INS** | 계측기 | **검교정 이력, MES 보정값 반영** |
|
|
||||||
| **CSM** | 소모품 | **정비 부품 할당, 안전재고 알림** |
|
|
||||||
| **TOL** | 공구 | **불출/반납 이력, 금형 수명 관리** |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 참고 사항
|
|
||||||
- 모든 기능 구현 시 플랫폼의 디자인 시스템(CSS Variables)을 준수해야 함.
|
|
||||||
- 모듈 독립성 유지를 위해 모듈 간 통신은 정의된 API 인터페이스를 통해서만 수행함.
|
|
||||||
|
|
||||||
## 아키텍처 상세 도식
|
|
||||||
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ Layer 5: 지능형 분석 및 시각화 (The Brain) │
|
|
||||||
│ ┌───────────────────────┐ ┌──────────────────────────────┐ │
|
|
||||||
│ │ AI 분석 엔진 │◀──────────▶│ 통합 관제 대시보드 (BI) │ │
|
|
||||||
│ │ (예지보전, 품질최적화) │ 인사이트 │ (KPI, 가동률 실시간 모니터링)│ │
|
|
||||||
│ └───────────▲───────────┘ └──────────────▲───────────────┘ │
|
|
||||||
└──────────────┼───────────────────────────────────────┼──────────────────┘
|
|
||||||
│ 분석 결과 피드백 │ 실시간 현황
|
|
||||||
▼ ▼
|
|
||||||
┌──────────────┼───────────────────────────────────────┼──────────────────┐
|
|
||||||
│ │ Layer 4: 애플리케이션 계층 (IT System) │ │
|
|
||||||
│ ┌───────────▼──┐ ┌──────────────┐ ┌─────────────┐ ┌──────────┐ │
|
|
||||||
│ │ MES │◀─▶│ ERP │◀─▶│ WMS │◀─▶│ QMS │ │
|
|
||||||
│ │ (생산 실행) │ │ (전사적 자원) │ │ (창고관리) │ │(품질관리) │ │
|
|
||||||
│ └───────▲──────┘ └───────▲──────┘ └──────▲──────┘ └─────▲────┘ │
|
|
||||||
└──────────┼──────────────────┼─────────────────┼────────────────┼────────┘
|
|
||||||
│ │ │ │
|
|
||||||
▼ ▼ ▼ ▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────────────┐
|
|
||||||
│ ⭐ Layer 3: 통합 데이터 플랫폼 (CORE HUB & Data Lake) │
|
|
||||||
│ ┌───────────────────────────────────────────────────────────────────┐ │
|
|
||||||
│ │ 통합 데이터 허브 (Data Broker) │ │
|
|
||||||
│ │ "모든 데이터가 모이고, 필요한 곳으로 분배된다" │ │
|
|
||||||
│ └─────────────────────────────────▲─────────────────────────────────┘ │
|
|
||||||
│ │ ↕ 양방향 데이터 동기화 │
|
|
||||||
│ ┌──────────▼──────────┐ │
|
|
||||||
│ │ 실시간 / 이력 DB │ │
|
|
||||||
│ └─────────────────────┘ │
|
|
||||||
└────────────────────────────────────▲────────────────────────────────────┘
|
|
||||||
│ 표준 프로토콜 (MQTT/OPC-UA)
|
|
||||||
┌────────────────────────────────────┼────────────────────────────────────┐
|
|
||||||
│ Layer 2: 엣지 컴퓨팅 계층 (Translator) │
|
|
||||||
│ ┌────────────────────────┴───────────────────────┐ │
|
|
||||||
│ │ 엣지 게이트웨이 (Edge GW) │ │
|
|
||||||
│ │ (데이터 수집, 프로토콜 변환, 1차 필터링) │ │
|
|
||||||
│ └──────▲─────────────────▲────────────────▲──────┘ │
|
|
||||||
└──────────────────┼─────────────────┼────────────────┼───────────────────┘
|
|
||||||
│ │ │
|
|
||||||
┌──────────────────┼─────────────────┼────────────────┼───────────────────┐
|
|
||||||
│ ┌─────────┴──────┐ ┌──────┴───────┐ ┌────┴─────┐ │
|
|
||||||
│ │ P1. 기존 설비 │ │ P2. 최신설비 │ │ P3. 센서 │ │
|
|
||||||
│ │ (PLC/Legacy) │ │ (Robot/CNC) │ │ (환경) │ │
|
|
||||||
│ └────────────────┘ └──────────────┘ └──────────┘ │
|
|
||||||
│ Layer 1: 현장 물리 계층 (Data Source) │
|
|
||||||
└─────────────────────────────────────────────────────────────────────────┘
|
|
||||||
|
|
||||||

|
|
||||||

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

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

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

|
|
||||||
https://blog.naver.com/8pmcorp/224146369431
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 58 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 193 KiB |
@ -1,158 +0,0 @@
|
|||||||
# 🏷️ Git 운영 및 통합 업데이트 관리 규칙
|
|
||||||
|
|
||||||
이 문서는 Smart IMS 플랫폼의 **Git 운영 정책**, **태그 작성 규칙**, 그리고 **운영 서버 수동 업데이트 방법**을 설명합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. 태그 메시지 작성 규칙 (Changelog 자동 생성)
|
|
||||||
|
|
||||||
플랫폼의 업데이트 엔진은 태그 메시지의 **머리말(Prefix)**과 **리스트 기호(`-`, `*`)**를 분석하여 관리 페이지의 업데이트 히스토리와 배너를 생성합니다. 상세 버전 번호 부여 방식은 `docs/version manage.md`의 규정을 따릅니다.
|
|
||||||
|
|
||||||
### 📋 메시지 구조
|
|
||||||
```text
|
|
||||||
[TYPE] 업데이트 제목 (한 줄 요약)
|
|
||||||
- 상세 변경 내용 1
|
|
||||||
- 상세 변경 내용 2
|
|
||||||
* 상세 변경 내용 3
|
|
||||||
```
|
|
||||||
|
|
||||||
### 🏷️ 업데이트 타입 (TYPE) 정의
|
|
||||||
| 키워드 | 업데이트 유형 | 시각적 표시 | 비고 |
|
|
||||||
|:--- |:--- |:--- |:--- |
|
|
||||||
| `[URGENT]` | **긴급/보안** | 빨간색 배너 | 최우선 업데이트 권장 |
|
|
||||||
| `[HOTFIX]` | **긴급 수정** | 빨간색 배너 | 버그 즉시 수정 건 |
|
|
||||||
| `[FEATURE]` | **기능 추가** | 남색(Indigo) | 새로운 기능 도입 |
|
|
||||||
| `[FIX]` | **일반 수정** | 회색(Slate) | 일반적인 버그 수정 |
|
|
||||||
| `[PATCH]` | **패치/보정** | 회색(Slate) | 마이너 품질 개선 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 개발 및 배포 절차 (개발 PC)
|
|
||||||
|
|
||||||
개발 작업 및 태그 생성은 아래 경로에서 진행합니다.
|
|
||||||
* **작업 경로**: `d:\antigravity\smartims` (윈도우 개발 환경 기반)
|
|
||||||
|
|
||||||
### STEP 1: 코드 변경 사항 커밋 및 푸시
|
|
||||||
```powershell
|
|
||||||
# [위치: d:\antigravity\smartims (루트)]
|
|
||||||
git add .
|
|
||||||
git commit -m "릴리즈 준비: v0.4.0.0 패치"
|
|
||||||
git push origin main
|
|
||||||
```
|
|
||||||
|
|
||||||
### STEP 2: 어노테이티드 태그(Annotated Tag) 생성
|
|
||||||
```powershell
|
|
||||||
# [위치: d:\antigravity\smartims (루트)]
|
|
||||||
git tag -a v0.4.0.0 -m "[PATCH] 시스템 안정화 패치
|
|
||||||
- 4자리 버전 관리 체계(MAJOR.MINOR.PATCH.BUILD) 도입
|
|
||||||
- 원격 태그 동기화 로직 및 UI 가시성 강화"
|
|
||||||
```
|
|
||||||
|
|
||||||
### STEP 3: 원격 저장소에 태그 푸시
|
|
||||||
```powershell
|
|
||||||
# [위치: d:\antigravity\smartims (루트)]
|
|
||||||
git push origin v0.4.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 운영 서버 수동 업데이트 통합 공정 [작업 전 필수 확인]
|
|
||||||
|
|
||||||
운영 환경에서 작업을 수행할 때는 **반드시 아래 순서를 1번부터 차례대로 수행**하십시오. 단계를 건너뛸 경우 데이터 유실 시 복구가 불가능합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🚨 [STEP 1] 데이터 백업 (무조건 수행 - 예외 없음)
|
|
||||||
업데이트 명령을 입력하기 전, 현재의 이미지 파일과 데이터베이스를 안전 경로(`/volume1/backup/smart_ims`)로 격리합니다.
|
|
||||||
|
|
||||||
#### 1-1. 업로드 파일(이미지/매뉴얼) 백업
|
|
||||||
```bash
|
|
||||||
# [실행 위치: /volume1/web/smartims]
|
|
||||||
# 이 명령은 등록된 모든 자산 이미지를 압축하여 보관합니다.
|
|
||||||
tar -czvf /volume1/backup/smart_ims/backup_images_$(date +%Y%m%d).tar.gz server/uploads/
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 1-2. 데이터베이스(DB) 전체 백업
|
|
||||||
```bash
|
|
||||||
# [실행 위치: /volume1/web/smartims]
|
|
||||||
# DB명: sokuree_platform_prod, 포트: 3307 사용
|
|
||||||
/usr/local/mariadb10/bin/mysqldump -u choibk -p --port 3307 sokuree_platform_prod > /volume1/backup/smart_ims/backup_db_$(date +%Y%m%d).sql
|
|
||||||
```
|
|
||||||
> **주의**: 비밀번호 요청 시 `.env` 파일의 `DB_PASSWORD`(`^Ocean...`)를 입력하세요.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🚀 [STEP 2] 버전 동기화 및 코드 반영
|
|
||||||
백업이 완료된 것이 확인된 경우에만 아래 명령어를 실행하여 소스를 업데이트합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# [실행 위치: /volume1/web/smartims]
|
|
||||||
|
|
||||||
# 1. 원격 저장소의 태그 목록 확인 (배포된 최신 버전이 무엇인지 체크)
|
|
||||||
git ls-remote --tags origin
|
|
||||||
|
|
||||||
# 2. 로컬 태그 정보를 원격과 완벽히 일치 (강제 동기화)
|
|
||||||
git fetch origin +refs/tags/*:refs/tags/* --force --prune --prune-tags
|
|
||||||
|
|
||||||
# 3. 목표 버전으로 소스 코드 강제 전환 (예: v0.4.0.0)
|
|
||||||
# [주의] 이 명령 실행 시 추적되지 않은 소스 수정사항은 사라집니다.
|
|
||||||
git checkout -f v0.4.0.0
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🏗️ [STEP 3] 시스템 빌드 및 서비스 재시작
|
|
||||||
코드가 반영되었다면 변경된 내용을 적용하기 위해 빌드와 프로세스를 갱신합니다.
|
|
||||||
|
|
||||||
#### 3-1. 프론트엔드 빌드
|
|
||||||
```bash
|
|
||||||
# [실행 위치: /volume1/web/smartims]
|
|
||||||
npm install && npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3-2. 백엔드 반영 및 PM2 재시작
|
|
||||||
```bash
|
|
||||||
# [실행 위치: /volume1/web/smartims/server]
|
|
||||||
cd server
|
|
||||||
npm install
|
|
||||||
pm2 reload smartims-api
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 🆘 [STEP 4] 비상시 복구 절차 (Emergency Restore)
|
|
||||||
만약 업데이트 후 시스템이 작동하지 않거나 데이터가 보이지 않을 경우에만 수행합니다.
|
|
||||||
|
|
||||||
#### 4-1. 이미지 파일 복원
|
|
||||||
```bash
|
|
||||||
# [실행 위치: /volume1/web/smartims]
|
|
||||||
# 작업 당일 생성한 압축파일 이름을 확인하여 입력하세요.
|
|
||||||
tar -xzvf /volume1/backup/smart_ims/backup_images_[날짜].tar.gz -C ./
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 4-2. 데이터베이스(DB) 복원
|
|
||||||
```bash
|
|
||||||
# [실행 위치: /volume1/web/smartims]
|
|
||||||
/usr/local/mariadb10/bin/mysql -u choibk -p --port 3307 sokuree_platform_prod < /volume1/backup/smart_ims/backup_db_[날짜].sql
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 유용한 Git 명령어 레퍼런스
|
|
||||||
|
|
||||||
* **태그 목록 확인**: `git tag -l` (모든 환경의 루트 폴더)
|
|
||||||
* **최신 태그 정보 확인**: `git describe --tags --abbrev=0`
|
|
||||||
* **원격 태그 강제 덮어쓰기 (메시지 수정 시)**:
|
|
||||||
```powershell
|
|
||||||
# [위치: 개발 PC 루트]
|
|
||||||
git tag -a v0.4.0.0 -f -m "수정된 메시지..."
|
|
||||||
git push origin v0.4.0.0 -f
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 최종 주의사항 (Must Read)
|
|
||||||
1. **DB 계정 확인**: `-u choibk` 계정은 본 시스템 전용 계정입니다. 운영 서버 접속 시 `root` 권한이더라도 DB 작업은 `choibk`로 수행해야 권한 꼬임이 없습니다.
|
|
||||||
2. **경로 이동 주의**: `cd server` 이동 후 빌드가 끝나면 `cd ..`를 통해 반드시 루트로 돌아오십시오.
|
|
||||||
3. **수동 설치 주의**: `git clone`을 새로 받아 설치할 때는 기존 `uploads` 폴더가 포함되어 있지 않으므로 반드시 위 **STEP 4-1 복구 절차**를 통해 이미지를 채워넣어야 합니다.
|
|
||||||
4. **자동 업데이트 정책**: 시스템 관리 메뉴의 **[자동 업데이트]**는 내부적으로 소스 코드만 갱신하며 `uploads` 폴더는 건드리지 않습니다. 따라서 자동 업데이트 시에는 별도의 이미지 복구가 필요 없습니다.
|
|
||||||
@ -1,98 +0,0 @@
|
|||||||
# 📘 SMART IMS 버전 관리 규정 (Versioning Rules)
|
|
||||||
|
|
||||||
SMART IMS 플랫폼의 모든 배포와 업데이트는 아래의 **MAJOR.MINOR.PATCH.BUILD** 4자리 버전 관리 체계를 엄격히 준수합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1️⃣ MAJOR (첫 번째 자리)
|
|
||||||
**대규모 변경, 하위 호환성 붕괴**
|
|
||||||
|
|
||||||
- **의미**: 시스템의 근간이 바뀌거나 기존 데이터/연동 체계와의 호환성이 파괴될 때 증가합니다.
|
|
||||||
- **상황**: API 구조 전면 개편, 데이터베이스 아키텍처 변경, 통신 프로토콜 교체 등.
|
|
||||||
- **규칙**:
|
|
||||||
- 절대 자주 올리지 않으며, "제품의 세대 교체" 수준에서만 사용합니다.
|
|
||||||
- 사용자는 업데이트 전 반드시 호환성 여부를 검토해야 합니다.
|
|
||||||
- **예시**: `1.x.x.x` → `2.0.0.0`
|
|
||||||
|
|
||||||
## 2️⃣ MINOR (두 번째 자리)
|
|
||||||
**기능 추가, 호환성 유지**
|
|
||||||
|
|
||||||
- **의미**: 기존 시스템과 호환성을 유지하면서 새로운 기능이 추가되거나 UI가 대폭 개선될 때 증가합니다.
|
|
||||||
- **상황**: 신규 모듈 추가, 기존 기능 확장, 설정 옵션 대량 추가 등.
|
|
||||||
- **규칙**:
|
|
||||||
- 기능 단위의 주요 배포 시 사용합니다.
|
|
||||||
- 고객에게 "업데이트 권장" 수준의 변화를 의미합니다.
|
|
||||||
- **예시**: `1.2.x.x` → `1.3.0.0`
|
|
||||||
|
|
||||||
## 3️⃣ PATCH (세 번째 자리)
|
|
||||||
**버그 수정, 품질 개선**
|
|
||||||
|
|
||||||
- **의미**: 새로운 기능 추가 없이 기존 기능의 버그를 수정하거나 성능을 개선할 때 증가합니다.
|
|
||||||
- **상황**: 보안 패치, 성능 최적화, 로직 오류 수정 등.
|
|
||||||
- **규칙**:
|
|
||||||
- 기능 추가는 엄격히 금지됩니다.
|
|
||||||
- 긴급한 오류 수정 및 정기 유지보수 배포에 주로 사용됩니다.
|
|
||||||
- **예시**: `1.2.3.x` → `1.2.4.0`
|
|
||||||
|
|
||||||
## 4️⃣ BUILD / REVISION (네 번째 자리)
|
|
||||||
**빌드 번호 또는 미세 변경**
|
|
||||||
|
|
||||||
- **의미**: 사용자에게 기능적/시각적 변화를 거의 주지 않는 기술적 수정 시 증가합니다.
|
|
||||||
- **상황**:
|
|
||||||
- **빌드 번호**: CI/CD 파이프라인의 빌드 식별자.
|
|
||||||
- **핫픽스**: 오타 수정, 로그 문구 변경, 주석 업데이트 등.
|
|
||||||
- **규칙**:
|
|
||||||
- 사용자에게 "버전 변경 체감"을 주지 않는 수준의 작업입니다.
|
|
||||||
- 릴리스 노트(Changelog) 작성을 생략할 수 있습니다.
|
|
||||||
- **예시**: `1.2.3.45` (45번째 빌드)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 버전 비교 및 업데이트 공지 원칙
|
|
||||||
1. 플랫폼은 원격 저장소의 최신 태그 버전과 현재 설치된 버전의 **4자리를 순차적으로 비교**합니다.
|
|
||||||
2. 상위 자리수가 더 높을 경우 즉시 시스템 업데이트 안내를 발생시킵니다.
|
|
||||||
3. 모든 버전은 `v` 접두사를 포함하여 관리하나, 비교 시에는 숫자로 정규화하여 처리합니다.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 배포 및 태그 생성 절차 (Standard Deployment Procedure)
|
|
||||||
|
|
||||||
> **경고**: 이 절차를 위반할 경우 운영 서버에서 업데이트가 누락되거나 버전 불일치가 발생할 수 있습니다.
|
|
||||||
|
|
||||||
### [Step 1] 사전 검증 (Pre-check)
|
|
||||||
1. **작업 내용 커밋 확인**: `git status` 명령어로 커밋되지 않은(Unstaged/Modified) 파일이 없는지 확인합니다.
|
|
||||||
- ❌ 실수 패턴: 코드를 수정하고 저장만 한 뒤, 커밋 없이 태그를 생성하면 **구버전 코드**가 배포됩니다.
|
|
||||||
|
|
||||||
2. **버전 번호 동기화 (필수)**: 아래 2개 파일의 `version` 필드를 배포할 버전 번호(예: `0.4.3.5`)로 직접 수정합니다.
|
|
||||||
- `root/package.json`
|
|
||||||
- `root/server/package.json`
|
|
||||||
- *이 작업이 누락되면 시스템은 업데이트 후에도 "구버전"으로 인식합니다.*
|
|
||||||
|
|
||||||
### [Step 2] 커밋 및 태그 생성 (Commit & Tag)
|
|
||||||
반드시 **소스코드 커밋을 먼저** 수행하고, 그 커밋에 태그를 붙여야 합니다.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 버전 파일 및 변경 사항 커밋
|
|
||||||
git add .
|
|
||||||
git commit -m "[BUILD] v0.4.3.5 - 배포 내용 요약"
|
|
||||||
|
|
||||||
# 2. 원격 저장소로 코드 푸시 (먼저!)
|
|
||||||
git push origin main
|
|
||||||
|
|
||||||
# 3. 태그 생성 (경량 태그 권장)
|
|
||||||
git tag v0.4.3.5
|
|
||||||
|
|
||||||
# 4. 태그 푸시 (배포 트리거)
|
|
||||||
git push origin v0.4.3.5
|
|
||||||
```
|
|
||||||
|
|
||||||
### [Step 3] 운영 서버 배포 확인
|
|
||||||
1. 운영 서버(NAS)에서 업데이트 스크립트 실행
|
|
||||||
2. 업데이트 완료 후 브라우저에서 '새로고침(Ctrl+F5)' 또는 캐시 비우기 수행
|
|
||||||
3. [시스템 관리] > [버전 정보] 메뉴에서 **표시되는 버전 번호**와 **빌드 일자**가 최신인지 확인
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚫 절대 금지 사항 (Do Not)
|
|
||||||
1. **커밋 없이 태그만 생성 금지**: 운영 서버는 태그가 가리키는 시점의 코드를 가져옵니다. 변경 사항이 커밋되지 않았다면 아무것도 변하지 않습니다.
|
|
||||||
2. **`package.json` 수정 누락**: 코드는 바꼈는데 명찰(버전 번호)을 안 바꾸면, 시스템은 "이미 최신 버전"이라 판단하고 업데이트 알림을 띄우지 않습니다.
|
|
||||||
BIN
history.txt
BIN
history.txt
Binary file not shown.
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "smartims",
|
"name": "smartims",
|
||||||
"version": "0.4.3.1",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "smartims",
|
"name": "smartims",
|
||||||
"version": "0.4.3.1",
|
"version": "0.1.0",
|
||||||
"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",
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "smartims",
|
"name": "smartims",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.4.4.0",
|
"version": "0.2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@ -1,67 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Smart IMS Emergency Restore Script (Revised)
|
|
||||||
# 작성일: 2026-01-26
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
PROJECT_DIR="/volume1/web/smartims"
|
|
||||||
BACKUP_DIR="/volume1/smart_ims"
|
|
||||||
SERVER_DIR="${PROJECT_DIR}/server"
|
|
||||||
MYSQL_BIN="/usr/local/mariadb10/bin"
|
|
||||||
|
|
||||||
DB_USER="choibk"
|
|
||||||
DB_PORT="3307"
|
|
||||||
DB_NAME="sokuree_platform_prod"
|
|
||||||
DB_HOST="127.0.0.1"
|
|
||||||
|
|
||||||
echo "=============================================="
|
|
||||||
echo "🚨 EMERGENCY RESTORE PROCESS STARTING"
|
|
||||||
echo "=============================================="
|
|
||||||
|
|
||||||
# DB 비밀번호 추출 (Node.js)
|
|
||||||
if [ -f "${SERVER_DIR}/.env" ]; then
|
|
||||||
cd "${SERVER_DIR}"
|
|
||||||
DB_PASS=$(node -e "try{const fs=require('fs');const c=fs.readFileSync('.env','utf8');const m=c.match(/^DB_PASSWORD=(.*)$/m);if(m){let p=m[1].trim();if((p.startsWith('\"')&&p.endsWith('\"'))||(p.startsWith(\"'\")&&p.endsWith(\"'\"))){p=p.slice(1,-1);}process.stdout.write(p);}}catch(e){}")
|
|
||||||
else
|
|
||||||
echo "❌ [Error] .env file not found."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 1. 최신 이미지 복구
|
|
||||||
echo "[Step 1] Finding latest image backup..."
|
|
||||||
LATEST_IMG=$(ls -t "${BACKUP_DIR}"/backup_images_*.tar.gz 2>/dev/null | head -n 1)
|
|
||||||
|
|
||||||
if [ -n "$LATEST_IMG" ]; then
|
|
||||||
echo "✅ Found: $LATEST_IMG"
|
|
||||||
echo " Restoring images..."
|
|
||||||
tar -xzf "$LATEST_IMG" -C "${PROJECT_DIR}"
|
|
||||||
else
|
|
||||||
echo "⚠️ [Warning] No image backup found."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 2. 최신 DB 복구
|
|
||||||
echo "[Step 2] Finding latest database backup..."
|
|
||||||
LATEST_SQL=$(ls -t "${BACKUP_DIR}"/backup_db_*.sql 2>/dev/null | head -n 1)
|
|
||||||
|
|
||||||
if [ -z "$LATEST_SQL" ]; then
|
|
||||||
echo "❌ [Error] No database backup found!"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
echo "✅ Found: $LATEST_SQL"
|
|
||||||
echo " Restoring database..."
|
|
||||||
|
|
||||||
"${MYSQL_BIN}/mysql" -h "${DB_HOST}" -u "${DB_USER}" "-p${DB_PASS}" --port "${DB_PORT}" "${DB_NAME}" < "$LATEST_SQL"
|
|
||||||
|
|
||||||
if [ $? -eq 0 ]; then
|
|
||||||
echo "✅ Database restore complete."
|
|
||||||
else
|
|
||||||
echo "❌ [Error] Database restore failed."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "=============================================="
|
|
||||||
echo "🎉 SYSTEM RESTORE COMPLETED"
|
|
||||||
echo " Please restart PM2: pm2 reload smartims-api"
|
|
||||||
echo "=============================================="
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# Smart IMS NAS Update Script (Revised)
|
|
||||||
# 작성일: 2026-01-26
|
|
||||||
# ==========================================
|
|
||||||
|
|
||||||
# 1. 환경 설정
|
|
||||||
PROJECT_DIR="/volume1/web/smartims"
|
|
||||||
BACKUP_DIR="/volume1/smart_ims"
|
|
||||||
SERVER_DIR="${PROJECT_DIR}/server"
|
|
||||||
DATE_STAMP=$(date +%Y%m%d_%H%M%S)
|
|
||||||
|
|
||||||
# DB 접속 정보
|
|
||||||
DB_USER="choibk"
|
|
||||||
DB_PORT="3307"
|
|
||||||
DB_NAME="sokuree_platform_prod"
|
|
||||||
DB_HOST="127.0.0.1" # 호스트 명시 (필수)
|
|
||||||
MYSQL_BIN="/usr/local/mariadb10/bin"
|
|
||||||
|
|
||||||
# 로그 파일 설정
|
|
||||||
mkdir -p "${BACKUP_DIR}/logs"
|
|
||||||
LOG_FILE="${BACKUP_DIR}/logs/update_${DATE_STAMP}.log"
|
|
||||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
|
||||||
|
|
||||||
echo "=============================================="
|
|
||||||
echo "[Update] Started at $(date)"
|
|
||||||
echo "=============================================="
|
|
||||||
|
|
||||||
# 2. .env에서 DB 비밀번호 추출 (Node.js 활용으로 특수문자/따옴표 안전 처리)
|
|
||||||
if [ -f "${SERVER_DIR}/.env" ]; then
|
|
||||||
cd "${SERVER_DIR}"
|
|
||||||
# node 명령어로 파싱하여 쉘 특수문자 이슈 회피
|
|
||||||
DB_PASS=$(node -e "try{const fs=require('fs');const c=fs.readFileSync('.env','utf8');const m=c.match(/^DB_PASSWORD=(.*)$/m);if(m){let p=m[1].trim();if((p.startsWith('\"')&&p.endsWith('\"'))||(p.startsWith(\"'\")&&p.endsWith(\"'\"))){p=p.slice(1,-1);}process.stdout.write(p);}}catch(e){}")
|
|
||||||
|
|
||||||
if [ -z "$DB_PASS" ]; then
|
|
||||||
echo "[Error] Could not retrieve DB_PASSWORD from .env"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
echo "[Info] DB Password loaded (Length: ${#DB_PASS})"
|
|
||||||
else
|
|
||||||
echo "[Error] .env file not found at ${SERVER_DIR}/.env"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 백업 디렉토리 생성
|
|
||||||
mkdir -p "${BACKUP_DIR}"
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 🚨 [STEP 1] 데이터 백업
|
|
||||||
# ==========================================
|
|
||||||
echo "[Step 1] Creating Backups..."
|
|
||||||
|
|
||||||
# 1-1. 이미지 백업
|
|
||||||
if [ -d "${PROJECT_DIR}/server/uploads" ]; then
|
|
||||||
cd "${PROJECT_DIR}"
|
|
||||||
TAR_NAME="backup_images_${DATE_STAMP}.tar.gz"
|
|
||||||
echo " - Backing up images..."
|
|
||||||
tar -czf "${BACKUP_DIR}/${TAR_NAME}" server/uploads/
|
|
||||||
else
|
|
||||||
echo " - [Warning] Uploads directory not found. Skipping image backup."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 1-2. DB 백업
|
|
||||||
SQL_NAME="backup_db_${DATE_STAMP}.sql"
|
|
||||||
echo " - Backing up database..."
|
|
||||||
|
|
||||||
# -h 127.0.0.1 옵션 추가 및 비밀번호 인자 방식 변경
|
|
||||||
"${MYSQL_BIN}/mysqldump" -h "${DB_HOST}" -u "${DB_USER}" "-p${DB_PASS}" --port "${DB_PORT}" "${DB_NAME}" --single-transaction --quick --lock-tables=false > "${BACKUP_DIR}/${SQL_NAME}" 2>/dev/null
|
|
||||||
|
|
||||||
if [ $? -ne 0 ]; then
|
|
||||||
echo "[Error] Database backup failed! Please check:"
|
|
||||||
echo " 1. DB Port ($DB_PORT) matches MariaDB 10 port."
|
|
||||||
echo " 2. DB Password in .env is correct."
|
|
||||||
echo " 3. 'choibk' user has permissions."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# 빈 파일 체크 (0 바이트면 실패로 간주)
|
|
||||||
if [ ! -s "${BACKUP_DIR}/${SQL_NAME}" ]; then
|
|
||||||
echo "[Error] Backup file is empty. Dump failed."
|
|
||||||
rm "${BACKUP_DIR}/${SQL_NAME}"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[Success] All backups completed successfully."
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 🚀 [STEP 2] 버전 동기화 및 코드 반영
|
|
||||||
# ==========================================
|
|
||||||
echo "[Step 2] Updating Source Code..."
|
|
||||||
cd "${PROJECT_DIR}"
|
|
||||||
|
|
||||||
# 목표 태그 설정
|
|
||||||
TARGET_TAG=$1
|
|
||||||
if [ -z "$TARGET_TAG" ]; then
|
|
||||||
echo " - Fetching latest tag info..."
|
|
||||||
git fetch origin --tags --force
|
|
||||||
TARGET_TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
|
|
||||||
echo " - Detected latest tag: ${TARGET_TAG}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ -z "$TARGET_TAG" ]; then
|
|
||||||
echo "[Error] Target tag not found."
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo " - Syncing with remote..."
|
|
||||||
git fetch origin --tags --force --prune
|
|
||||||
if [ $? -ne 0 ]; then echo "[Error] Git fetch failed."; exit 1; fi
|
|
||||||
|
|
||||||
echo " - Checkout to ${TARGET_TAG}..."
|
|
||||||
git checkout -f "${TARGET_TAG}"
|
|
||||||
if [ $? -ne 0 ]; then echo "[Error] Git checkout failed."; exit 1; fi
|
|
||||||
|
|
||||||
|
|
||||||
# ==========================================
|
|
||||||
# 🏗️ [STEP 3] 시스템 빌드 및 서비스 재시작
|
|
||||||
# ==========================================
|
|
||||||
echo "[Step 3] Building and Restarting..."
|
|
||||||
|
|
||||||
# 3-1. 프론트엔드
|
|
||||||
echo " - Building Frontend..."
|
|
||||||
cd "${PROJECT_DIR}"
|
|
||||||
npm install --no-audit --no-fund > /dev/null
|
|
||||||
npm run build
|
|
||||||
|
|
||||||
# 3-2. 백엔드
|
|
||||||
echo " - Installing Backend Dependencies..."
|
|
||||||
cd "${SERVER_DIR}"
|
|
||||||
npm install --no-audit --no-fund > /dev/null
|
|
||||||
|
|
||||||
echo " - Reloading PM2..."
|
|
||||||
pm2 reload smartims-api || pm2 start index.js --name "smartims-api"
|
|
||||||
|
|
||||||
echo "=============================================="
|
|
||||||
echo "[Update] Completed Successfully at $(date)"
|
|
||||||
echo "[Info] Target Version: ${TARGET_TAG}"
|
|
||||||
echo "=============================================="
|
|
||||||
@ -1,8 +1,5 @@
|
|||||||
const mysql = require('mysql2');
|
const mysql = require('mysql2');
|
||||||
const path = require('path');
|
require('dotenv').config();
|
||||||
// Prioritize machine-specific local config (.env.local)
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
|
|
||||||
require('dotenv').config(); // Fallback to standard .env
|
|
||||||
|
|
||||||
const pool = mysql.createPool({
|
const pool = mysql.createPool({
|
||||||
host: process.env.DB_HOST,
|
host: process.env.DB_HOST,
|
||||||
|
|||||||
@ -1,36 +0,0 @@
|
|||||||
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();
|
|
||||||
231
server/index.js
231
server/index.js
@ -1,9 +1,6 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const path = require('path');
|
require('dotenv').config();
|
||||||
// Prioritize machine-specific local config (.env.local)
|
|
||||||
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
|
|
||||||
require('dotenv').config(); // Fallback to standard .env
|
|
||||||
|
|
||||||
const db = require('./db');
|
const db = require('./db');
|
||||||
const authRoutes = require('./routes/auth');
|
const authRoutes = require('./routes/auth');
|
||||||
@ -14,6 +11,7 @@ const { isAuthenticated } = require('./middleware/authMiddleware');
|
|||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3005; // Changed to 3005 to avoid conflict with Synology services (3001 issue)
|
const PORT = process.env.PORT || 3005; // Changed to 3005 to avoid conflict with Synology services (3001 issue)
|
||||||
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
|
|
||||||
// Ensure uploads directory exists
|
// Ensure uploads directory exists
|
||||||
@ -53,9 +51,6 @@ 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({
|
||||||
@ -71,13 +66,12 @@ 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: false,
|
resave: true, // Force save to avoid session loss in some environments
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
rolling: false, // Do not automatic rolling (we control it in middleware)
|
|
||||||
cookie: {
|
cookie: {
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
secure: false, // HTTPS 사용 시 true로 변경 필요
|
secure: false, // Set true if using HTTPS
|
||||||
maxAge: 3600000, // 기본 1시간 (미들웨어에서 동적 조정)
|
maxAge: null, // Browser session by default
|
||||||
sameSite: 'lax'
|
sameSite: 'lax'
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@ -85,30 +79,13 @@ 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 {
|
||||||
// Priority: User's individual timeout > System default
|
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
|
||||||
let timeoutMinutes = 60; // Default fallback
|
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
|
||||||
|
|
||||||
if (req.session.user.session_timeout) {
|
|
||||||
timeoutMinutes = parseInt(req.session.user.session_timeout);
|
|
||||||
} else {
|
|
||||||
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
|
|
||||||
timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 10; // New default fallback 10 as requested
|
|
||||||
}
|
|
||||||
|
|
||||||
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
|
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
|
||||||
|
|
||||||
// Explicitly save session before moving to next middleware
|
// Explicitly save session to ensure store sync
|
||||||
req.session.save((err) => {
|
req.session.save();
|
||||||
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);
|
||||||
}
|
}
|
||||||
@ -119,24 +96,9 @@ app.use(async (req, res, next) => {
|
|||||||
// Apply CSRF Protection
|
// Apply CSRF Protection
|
||||||
app.use(csrfProtection);
|
app.use(csrfProtection);
|
||||||
|
|
||||||
// Request Logger with Session Remaining Time
|
// Request Logger
|
||||||
app.use((req, res, next) => {
|
app.use((req, res, next) => {
|
||||||
const now = new Date();
|
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||||
const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', '').replace('T', ' ');
|
|
||||||
|
|
||||||
let sessionInfo = '';
|
|
||||||
if (req.session && req.session.cookie && req.session.cookie.expires) {
|
|
||||||
const remainingMs = req.session.cookie.expires - now;
|
|
||||||
if (remainingMs > 0) {
|
|
||||||
const remMin = Math.floor(remainingMs / 60000);
|
|
||||||
const remSec = Math.floor((remainingMs % 60000) / 1000);
|
|
||||||
sessionInfo = ` [Session: ${remMin}m ${remSec}s left]`;
|
|
||||||
} else {
|
|
||||||
sessionInfo = ` [Session: Expired]`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[${kstDate}]${sessionInfo} ${req.method} ${req.url}`);
|
|
||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -194,14 +156,6 @@ 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 (
|
||||||
@ -226,8 +180,7 @@ const initTables = async () => {
|
|||||||
department VARCHAR(100),
|
department VARCHAR(100),
|
||||||
position VARCHAR(100),
|
position VARCHAR(100),
|
||||||
phone VARCHAR(255),
|
phone VARCHAR(255),
|
||||||
role ENUM('supervisor', 'admin', 'user') DEFAULT 'user',
|
role ENUM('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
|
||||||
@ -244,29 +197,12 @@ 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, '관리자', 'supervisor', 'IT팀', '관리자']
|
[adminId, hashedPass, '시스템 관리자', 'admin', 'IT팀', '관리자']
|
||||||
);
|
);
|
||||||
console.log('✅ Default Admin Created as Supervisor');
|
console.log('✅ Default Admin Created (admin / admin123)');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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 [allowedModulesCols] = await db.query("SHOW COLUMNS FROM users LIKE 'allowed_modules'");
|
|
||||||
if (allowedModulesCols.length === 0) {
|
|
||||||
await db.query("ALTER TABLE users ADD COLUMN allowed_modules JSON NULL AFTER session_timeout");
|
|
||||||
console.log("✅ Added 'allowed_modules' column to users");
|
|
||||||
|
|
||||||
// For existing users, grant access to all modules by default
|
|
||||||
const allModules = JSON.stringify(['asset', 'production', 'cctv', 'material', 'inventory', 'quality']);
|
|
||||||
await db.query("UPDATE users SET allowed_modules = ?", [allModules]);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('✅ Tables Initialized');
|
console.log('✅ Tables Initialized');
|
||||||
// Create asset_manuals table
|
// Create asset_manuals table
|
||||||
const manualTable = `
|
const manualTable = `
|
||||||
@ -281,105 +217,43 @@ const initTables = async () => {
|
|||||||
`;
|
`;
|
||||||
await db.query(manualTable);
|
await db.query(manualTable);
|
||||||
|
|
||||||
// Create asset_accessories table
|
// Create camera_settings 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 cctv_settings (
|
CREATE TABLE IF NOT EXISTS camera_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(255),
|
password VARCHAR(100),
|
||||||
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 cctv_settings LIKE 'transport_mode'");
|
const [camColumns] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'transport_mode'");
|
||||||
if (camColumns.length === 0) {
|
if (camColumns.length === 0) {
|
||||||
await db.query("ALTER TABLE cctv_settings ADD COLUMN transport_mode ENUM('tcp', 'udp', 'auto') DEFAULT 'tcp' AFTER stream_path");
|
await db.query("ALTER TABLE camera_settings ADD COLUMN transport_mode ENUM('tcp', 'udp', 'auto') DEFAULT 'tcp' AFTER stream_path");
|
||||||
await db.query("ALTER TABLE cctv_settings ADD COLUMN rtsp_encoding BOOLEAN DEFAULT FALSE AFTER transport_mode");
|
await db.query("ALTER TABLE camera_settings ADD COLUMN rtsp_encoding BOOLEAN DEFAULT FALSE AFTER transport_mode");
|
||||||
await db.query("ALTER TABLE cctv_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); // Default to low for stability
|
await db.query("ALTER TABLE camera_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); // Default to low for stability
|
||||||
console.log("✅ Added 'transport_mode', 'quality', and 'rtsp_encoding' columns to cctv_settings");
|
console.log("✅ Added 'transport_mode', 'quality', and 'rtsp_encoding' columns to camera_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 cctv_settings LIKE 'quality'");
|
const [qualCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'quality'");
|
||||||
if (qualCol.length === 0) {
|
if (qualCol.length === 0) {
|
||||||
await db.query("ALTER TABLE cctv_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode");
|
await db.query("ALTER TABLE camera_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode");
|
||||||
console.log("✅ Added 'quality' column to cctv_settings");
|
console.log("✅ Added 'quality' column to camera_settings");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check for 'zone' column
|
|
||||||
const [zoneCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'zone'");
|
|
||||||
if (zoneCol.length === 0) {
|
|
||||||
await db.query("ALTER TABLE cctv_settings ADD COLUMN zone VARCHAR(50) DEFAULT '기본 구역' AFTER name");
|
|
||||||
console.log("✅ Added 'zone' column to cctv_settings");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check for 'display_order' column
|
// Check for 'display_order' column
|
||||||
const [orderCol] = await db.query("SHOW COLUMNS FROM cctv_settings LIKE 'display_order'");
|
const [orderCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'display_order'");
|
||||||
if (orderCol.length === 0) {
|
if (orderCol.length === 0) {
|
||||||
await db.query("ALTER TABLE cctv_settings ADD COLUMN display_order INT DEFAULT 0 AFTER quality");
|
await db.query("ALTER TABLE camera_settings ADD COLUMN display_order INT DEFAULT 0 AFTER quality");
|
||||||
console.log("✅ Added 'display_order' column to cctv_settings");
|
console.log("✅ Added 'display_order' column to camera_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)
|
||||||
@ -443,31 +317,15 @@ const initTables = async () => {
|
|||||||
console.log("✅ Added 'subscriber_id' column to system_modules");
|
console.log("✅ Added 'subscriber_id' column to system_modules");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize default modules
|
// Initialize default modules if empty (Disabled by default as per request behavior)
|
||||||
const defaultModules = [
|
const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1');
|
||||||
{ code: 'asset', name: '자산 관리' },
|
if (existingModules.length === 0) {
|
||||||
{ code: 'production', name: '생산 관리' },
|
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`;
|
||||||
{ code: 'cctv', name: 'CCTV' },
|
await db.query(insert, ['asset', '자산 관리', true, 'dev']);
|
||||||
{ code: 'material', name: '자재/재고 관리' },
|
await db.query(insert, ['production', '생산 관리', false, null]);
|
||||||
{ code: 'quality', name: '품질 관리' }
|
await db.query(insert, ['monitoring', 'CCTV', false, null]);
|
||||||
];
|
|
||||||
|
|
||||||
// Cleanup merged inventory module if it exists
|
|
||||||
await db.query("DELETE FROM system_modules WHERE code = 'inventory'");
|
|
||||||
|
|
||||||
for (const mod of defaultModules) {
|
|
||||||
const [exists] = await db.query('SELECT 1 FROM system_modules WHERE code = ?', [mod.code]);
|
|
||||||
if (exists.length === 0) {
|
|
||||||
await db.query('INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)',
|
|
||||||
[mod.code, mod.name, false, null]);
|
|
||||||
console.log(`✅ Initialized module: ${mod.name} (${mod.code})`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// One-time update: Rename 'monitoring' code to 'cctv' (migration)
|
|
||||||
await db.query("UPDATE system_modules SET code = 'cctv' WHERE code = 'monitoring' AND NOT EXISTS (SELECT 1 FROM (SELECT * FROM system_modules) AS tmp WHERE code = 'cctv')");
|
|
||||||
await db.query("DELETE FROM system_modules WHERE code = 'monitoring' AND EXISTS (SELECT 1 FROM (SELECT * FROM system_modules) AS tmp WHERE code = 'cctv')");
|
|
||||||
|
|
||||||
console.log('✅ Tables Initialized');
|
console.log('✅ Tables Initialized');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('❌ Table Initialization Failed:', err);
|
console.error('❌ Table Initialization Failed:', err);
|
||||||
@ -475,21 +333,8 @@ const initTables = async () => {
|
|||||||
};
|
};
|
||||||
initTables();
|
initTables();
|
||||||
|
|
||||||
const packageJson = require('./package.json');
|
|
||||||
|
|
||||||
app.get('/api/health', (req, res) => {
|
app.get('/api/health', (req, res) => {
|
||||||
// Light-weight health check (Read from package.json to simulate 'installed' version)
|
res.json({ status: 'ok', version: '1.2.0', timestamp: '2026-01-22 21:18' });
|
||||||
const kstOffset = 9 * 60 * 60 * 1000;
|
|
||||||
const kstDate = new Date(Date.now() + kstOffset);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
status: 'ok',
|
|
||||||
version: packageJson.version, // Use static version from file
|
|
||||||
node_version: process.version,
|
|
||||||
platform: process.platform,
|
|
||||||
arch: process.arch,
|
|
||||||
timestamp: kstDate.toISOString().replace('T', ' ').split('.')[0]
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Routes
|
// Routes
|
||||||
@ -540,10 +385,6 @@ 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'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -1,15 +1,3 @@
|
|||||||
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();
|
||||||
@ -17,17 +5,13 @@ 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 = (requiredRole) => {
|
const hasRole = (...roles) => {
|
||||||
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' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const userRole = req.session.user.role;
|
if (roles.includes(req.session.user.role)) {
|
||||||
const userLevel = HIERARCHY[userRole] || 0;
|
|
||||||
const requiredLevel = HIERARCHY[requiredRole] || 999;
|
|
||||||
|
|
||||||
if (userLevel >= requiredLevel) {
|
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -35,4 +19,4 @@ const hasRole = (requiredRole) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = { isAuthenticated, hasRole, ROLES };
|
module.exports = { isAuthenticated, hasRole };
|
||||||
|
|||||||
@ -28,7 +28,6 @@ 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'}`);
|
||||||
|
|
||||||
|
|||||||
@ -23,22 +23,7 @@ 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' });
|
||||||
@ -47,14 +32,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, parent_id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity } = req.body;
|
const { id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO assets (id, parent_id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity)
|
INSERT INTO assets (id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
await db.query(sql, [id, parent_id || null, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity || 1]);
|
await db.query(sql, [id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url]);
|
||||||
res.status(201).json({ message: 'Asset created', id });
|
res.status(201).json({ message: 'Asset created', id });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -64,15 +49,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 { parent_id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity } = req.body;
|
const { name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const sql = `
|
const sql = `
|
||||||
UPDATE assets
|
UPDATE assets
|
||||||
SET parent_id=?, name=?, category=?, model_name=?, serial_number=?, manufacturer=?, location=?, purchase_date=?, manager=?, status=?, specs=?, purchase_price=?, image_url=?, quantity=?
|
SET name=?, category=?, model_name=?, serial_number=?, manufacturer=?, location=?, purchase_date=?, manager=?, status=?, specs=?, purchase_price=?, image_url=?
|
||||||
WHERE id=?
|
WHERE id=?
|
||||||
`;
|
`;
|
||||||
const [result] = await db.query(sql, [parent_id || null, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, quantity || 1, req.params.id]);
|
const [result] = await db.query(sql, [name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, req.params.id]);
|
||||||
|
|
||||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Asset not found' });
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||||
res.json({ message: 'Asset updated' });
|
res.json({ message: 'Asset updated' });
|
||||||
@ -294,46 +279,4 @@ router.delete('/manuals/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ==========================================
|
|
||||||
// 4. Accessories
|
|
||||||
// ==========================================
|
|
||||||
|
|
||||||
// Get Accessories for an Asset
|
|
||||||
router.get('/assets/:asset_id/accessories', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const [rows] = await db.query('SELECT * FROM asset_accessories WHERE asset_id = ? ORDER BY created_at ASC', [req.params.asset_id]);
|
|
||||||
res.json(rows);
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Database error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add Accessory to Asset
|
|
||||||
router.post('/assets/:asset_id/accessories', async (req, res) => {
|
|
||||||
const { name, spec, quantity } = req.body;
|
|
||||||
const { asset_id } = req.params;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const sql = `INSERT INTO asset_accessories (asset_id, name, spec, quantity) VALUES (?, ?, ?, ?)`;
|
|
||||||
const [result] = await db.query(sql, [asset_id, name, spec, quantity || 1]);
|
|
||||||
res.status(201).json({ message: 'Accessory added', id: result.insertId });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Database error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Delete Accessory
|
|
||||||
router.delete('/accessories/:id', async (req, res) => {
|
|
||||||
try {
|
|
||||||
const [result] = await db.query('DELETE FROM asset_accessories WHERE id = ?', [req.params.id]);
|
|
||||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Accessory not found' });
|
|
||||||
res.json({ message: 'Accessory deleted' });
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
res.status(500).json({ error: 'Database error' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@ -3,21 +3,12 @@ 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('cctv'), async (req, res) => {
|
router.get('/', requireModule('monitoring'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT * FROM cctv_settings ORDER BY display_order ASC, created_at DESC');
|
const [rows] = await db.query('SELECT * FROM camera_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' });
|
||||||
@ -41,7 +32,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 cctv_settings SET display_order = ? WHERE id = ?', [order, id]);
|
await db.query('UPDATE camera_settings SET display_order = ? WHERE id = ?', [order, id]);
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.query('COMMIT');
|
await db.query('COMMIT');
|
||||||
@ -52,73 +43,13 @@ 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 cctv_settings WHERE id = ?', [req.params.id]);
|
const [rows] = await db.query('SELECT * FROM camera_settings WHERE id = ?', [req.params.id]);
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' });
|
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' });
|
||||||
@ -127,13 +58,10 @@ 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, zone, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
|
const { name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
|
||||||
try {
|
try {
|
||||||
const encryptedUser = username ? cryptoUtil.encryptMasterKey(username) : username;
|
const sql = `INSERT INTO camera_settings (name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||||
const encryptedPass = password ? cryptoUtil.encryptMasterKey(password) : password;
|
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 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);
|
||||||
@ -143,13 +71,10 @@ 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, zone, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
|
const { name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
|
||||||
try {
|
try {
|
||||||
const encryptedUser = username ? cryptoUtil.encryptMasterKey(username) : username;
|
const sql = `UPDATE camera_settings SET name=?, ip_address=?, port=?, username=?, password=?, stream_path=?, transport_mode=?, rtsp_encoding=?, quality=? WHERE id=?`;
|
||||||
const encryptedPass = password ? cryptoUtil.encryptMasterKey(password) : password;
|
const [result] = await db.query(sql, [name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality, req.params.id]);
|
||||||
|
|
||||||
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)
|
||||||
@ -170,7 +95,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 cctv_settings SET is_active = ? WHERE id = ?', [is_active, req.params.id]);
|
const [result] = await db.query('UPDATE camera_settings SET is_active = ? WHERE id = ?', [is_active, req.params.id]);
|
||||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
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');
|
||||||
@ -190,7 +115,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 cctv_settings WHERE id = ?', [req.params.id]);
|
const [result] = await db.query('DELETE FROM camera_settings WHERE id = ?', [req.params.id]);
|
||||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
||||||
|
|
||||||
// Stop stream
|
// Stop stream
|
||||||
@ -210,11 +135,10 @@ 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 cctv_settings WHERE id = ?', [req.params.id]);
|
const [rows] = await db.query('SELECT ip_address FROM camera_settings WHERE id = ?', [req.params.id]);
|
||||||
if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' });
|
if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' });
|
||||||
|
|
||||||
const ip = rows[0].ip_address;
|
const ip = rows[0].ip_address;
|
||||||
|
|||||||
@ -3,7 +3,6 @@ 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)
|
||||||
@ -77,7 +76,7 @@ class StreamRelay {
|
|||||||
if (!stream) return;
|
if (!stream) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [rows] = await db.query('SELECT * FROM cctv_settings WHERE id = ?', [cameraId]);
|
const [rows] = await db.query('SELECT * FROM camera_settings WHERE id = ?', [cameraId]);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
console.error(`Camera ${cameraId} not found`);
|
console.error(`Camera ${cameraId} not found`);
|
||||||
return;
|
return;
|
||||||
@ -97,10 +96,8 @@ class StreamRelay {
|
|||||||
|
|
||||||
let rtspUrl = 'rtsp://';
|
let rtspUrl = 'rtsp://';
|
||||||
if (camera.username && camera.password) {
|
if (camera.username && camera.password) {
|
||||||
const decUser = cryptoUtil.decryptMasterKey(camera.username);
|
const user = camera.rtsp_encoding ? encodeURIComponent(camera.username) : camera.username;
|
||||||
const decPass = cryptoUtil.decryptMasterKey(camera.password);
|
const pass = camera.rtsp_encoding ? encodeURIComponent(camera.password) : 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'}`;
|
||||||
|
|||||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.4.3.1",
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.4.3.1",
|
"version": "0.1.0",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.13.2",
|
"axios": "^1.13.2",
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "server",
|
"name": "server",
|
||||||
"version": "0.4.4.0",
|
"version": "0.1.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@ -5,14 +5,54 @@ 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');
|
||||||
|
|
||||||
const cryptoUtil = require('../utils/cryptoUtil');
|
// --- Crypto Utilities ---
|
||||||
|
// 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);
|
||||||
|
|
||||||
async function encrypt(text) {
|
const ALGORITHM = 'aes-256-cbc';
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function decrypt(text) {
|
function decrypt(text) {
|
||||||
return await cryptoUtil.decrypt(text);
|
if (!text) return 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) {
|
||||||
@ -37,7 +77,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 = await decrypt(user.phone);
|
if (user.phone) user.phone = decrypt(user.phone);
|
||||||
|
|
||||||
// Save user to session
|
// Save user to session
|
||||||
req.session.user = user;
|
req.session.user = user;
|
||||||
@ -63,36 +103,20 @@ router.post('/login', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1.5. Check Session
|
// 1.5. Check Session (New)
|
||||||
router.get('/check', async (req, res) => {
|
router.get('/check', (req, res) => {
|
||||||
try {
|
if (req.session.user) {
|
||||||
if (req.session.user) {
|
// Ensure CSRF token exists, if not generate one (edge case)
|
||||||
// Ensure CSRF token exists
|
if (!req.session.csrfToken) {
|
||||||
if (!req.session.csrfToken) {
|
req.session.csrfToken = generateToken();
|
||||||
req.session.csrfToken = generateToken();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 });
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
res.json({
|
||||||
console.error('Check session error:', err);
|
isAuthenticated: true,
|
||||||
res.status(500).json({ success: false, message: 'Server error' });
|
user: req.session.user,
|
||||||
|
csrfToken: req.session.csrfToken
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
res.json({ isAuthenticated: false });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -108,58 +132,27 @@ 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 {
|
||||||
const [rows] = await db.query('SELECT id, name, department, position, phone, role, session_timeout, allowed_modules, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
|
// 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, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
|
||||||
|
|
||||||
if (!rows || rows.length === 0) {
|
const users = rows.map(u => ({
|
||||||
return res.json([]);
|
...u,
|
||||||
}
|
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('Failed to list users:', err);
|
console.error(err);
|
||||||
res.status(500).json({ error: '데이터를 불러오는 중 오류가 발생했습니다.' });
|
res.status(500).json({ error: 'Database 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, session_timeout, allowed_modules } = req.body;
|
const { id, password, name, department, position, phone, role } = 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' });
|
||||||
@ -173,16 +166,14 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hashedPassword = hashPassword(password);
|
const hashedPassword = hashPassword(password);
|
||||||
const encryptedPhone = await encrypt(phone);
|
const encryptedPhone = encrypt(phone);
|
||||||
|
|
||||||
const sql = `
|
const sql = `
|
||||||
INSERT INTO users (id, password, name, department, position, phone, role, session_timeout, allowed_modules)
|
INSERT INTO users (id, password, name, department, position, phone, role)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const modulesJson = allowed_modules ? JSON.stringify(allowed_modules) : JSON.stringify(['asset', 'production', 'cctv']);
|
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, modulesJson]);
|
|
||||||
|
|
||||||
res.status(201).json({ message: 'User created' });
|
res.status(201).json({ message: 'User created' });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -193,21 +184,41 @@ 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, session_timeout, allowed_modules } = req.body;
|
const { password, name, department, position, phone, role } = req.body;
|
||||||
const userId = req.params.id;
|
const userId = req.params.id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ... (existing logic)
|
// Fetch current to keep old values if not provided? Frontend usually sends all.
|
||||||
|
// We update everything provided.
|
||||||
|
|
||||||
|
// Build query dynamically or just assume full update
|
||||||
let updates = [];
|
let updates = [];
|
||||||
let params = [];
|
let params = [];
|
||||||
if (password) { updates.push('password = ?'); params.push(hashPassword(password)); }
|
|
||||||
if (name) { updates.push('name = ?'); params.push(name); }
|
if (password) {
|
||||||
if (department !== undefined) { updates.push('department = ?'); params.push(department); }
|
updates.push('password = ?');
|
||||||
if (position !== undefined) { updates.push('position = ?'); params.push(position); }
|
params.push(hashPassword(password));
|
||||||
if (phone !== undefined) { updates.push('phone = ?'); params.push(await encrypt(phone)); }
|
}
|
||||||
if (role) { updates.push('role = ?'); params.push(role); }
|
if (name) {
|
||||||
if (session_timeout !== undefined) { updates.push('session_timeout = ?'); params.push(session_timeout); }
|
updates.push('name = ?');
|
||||||
if (allowed_modules !== undefined) { updates.push('allowed_modules = ?'); params.push(JSON.stringify(allowed_modules)); }
|
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' });
|
||||||
|
|
||||||
@ -216,53 +227,7 @@ 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' });
|
||||||
|
|||||||
@ -1,13 +1,11 @@
|
|||||||
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');
|
||||||
@ -23,94 +21,16 @@ 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 ---
|
|
||||||
// Use .env.local for local machine settings (Ignored by Git)
|
|
||||||
const localEnvPath = path.join(__dirname, '../.env.local');
|
|
||||||
const defaultEnvPath = path.join(__dirname, '../.env');
|
|
||||||
|
|
||||||
const readEnv = () => {
|
|
||||||
// 1. Check for .env.local first
|
|
||||||
let path = fs.existsSync(localEnvPath) ? localEnvPath : defaultEnvPath;
|
|
||||||
|
|
||||||
// 2. Migration: If ONLY .env exists, we'll read from it and later save will create .env.local
|
|
||||||
if (!fs.existsSync(path)) return {};
|
|
||||||
|
|
||||||
const content = fs.readFileSync(path, '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) => {
|
|
||||||
// If .env.local doesn't exist, create it by copying .env (if exists) or from empty
|
|
||||||
if (!fs.existsSync(localEnvPath)) {
|
|
||||||
if (fs.existsSync(defaultEnvPath)) {
|
|
||||||
fs.copyFileSync(defaultEnvPath, localEnvPath);
|
|
||||||
} else {
|
|
||||||
fs.writeFileSync(localEnvPath, '', 'utf8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let content = fs.readFileSync(localEnvPath, '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(localEnvPath, 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', 'encryption_key', 'gitea_url', 'gitea_user', 'gitea_password')");
|
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout')");
|
||||||
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,
|
session_timeout: parseInt(settings.session_timeout) || 60 // Default 60 min
|
||||||
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);
|
||||||
@ -119,7 +39,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, encryption_key, db_config } = req.body;
|
const { subscriber_id, session_timeout } = req.body;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (subscriber_id !== undefined) {
|
if (subscriber_id !== undefined) {
|
||||||
@ -128,176 +48,7 @@ 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()]);
|
||||||
}
|
}
|
||||||
if (encryption_key !== undefined) {
|
res.json({ message: 'Settings saved' });
|
||||||
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' });
|
||||||
@ -310,12 +61,11 @@ 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', 'cctv', 'material', 'quality'];
|
const defaults = ['asset', 'production', 'monitoring'];
|
||||||
|
|
||||||
// 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'");
|
||||||
// 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 : null;
|
||||||
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);
|
||||||
@ -359,10 +109,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check Module match
|
// 2. Check Module match
|
||||||
// Allow legacy 'monitoring' licenses to activate 'cctv' module
|
if (result.module !== code) {
|
||||||
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}'` });
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -382,7 +129,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (result.subscriberId !== serverSubscriberId) {
|
if (result.subscriberId !== serverSubscriberId) {
|
||||||
return res.status(400).json({
|
return res.status(403).json({
|
||||||
error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.`
|
error: `구독자 ID 불일치: 라이선스 키는 [${result.subscriberId}] 전용이지만, 현재 서버는 [${serverSubscriberId}]로 설정되어 있습니다.`
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -415,21 +162,17 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
|||||||
const names = {
|
const names = {
|
||||||
'asset': '자산 관리',
|
'asset': '자산 관리',
|
||||||
'production': '생산 관리',
|
'production': '생산 관리',
|
||||||
'cctv': 'CCTV',
|
'monitoring': 'CCTV'
|
||||||
'material': '자재/재고 관리',
|
|
||||||
'quality': '품질 관리'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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]);
|
||||||
|
|
||||||
// 5. Sync status with License Manager
|
// 5. Sync status with License Manager
|
||||||
const licenseManagerUrl = process.env.LICENSE_MANAGER_URL || 'http://sokuree.com:3006/api';
|
const licenseManagerUrl = process.env.LICENSE_MANAGER_URL || 'http://localhost:3006/api';
|
||||||
try {
|
try {
|
||||||
console.log(`📡 Syncing with License Manager: ${licenseManagerUrl}/licenses/activate`);
|
|
||||||
await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey });
|
await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey });
|
||||||
console.log(`✅ Synced activation status for key: ${licenseKey.substring(0, 20)}...`);
|
console.log(`✅ Synced activation status for key: ${licenseKey.substring(0, 20)}...`);
|
||||||
} catch (syncErr) {
|
} catch (syncErr) {
|
||||||
console.error(`❌ Sync Error Object:`, syncErr);
|
|
||||||
const errorDetail = syncErr.response ?
|
const errorDetail = syncErr.response ?
|
||||||
`Status: ${syncErr.response.status}, Data: ${JSON.stringify(syncErr.response.data)}` :
|
`Status: ${syncErr.response.status}, Data: ${JSON.stringify(syncErr.response.data)}` :
|
||||||
syncErr.message;
|
syncErr.message;
|
||||||
@ -485,331 +228,4 @@ 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 (File-based for update simulation)
|
|
||||||
const projectRoot = path.join(__dirname, '../..');
|
|
||||||
let currentVersion = '0.0.0.0';
|
|
||||||
try {
|
|
||||||
const rootPkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
|
|
||||||
currentVersion = rootPkg.version;
|
|
||||||
} catch (e) {
|
|
||||||
currentVersion = '0.4.2.8';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.local" (
|
|
||||||
copy /Y "server\\.env.local" "%BACKUP_PATH%\\.env.local.backup.${timestamp}"
|
|
||||||
copy /Y "server\\.env.local" "server\\.env.local.tmp"
|
|
||||||
)
|
|
||||||
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.local.tmp" (
|
|
||||||
copy /Y "server\\.env.local.tmp" "server\\.env.local"
|
|
||||||
del "server\\.env.local.tmp"
|
|
||||||
) else if exist "%BACKUP_PATH%\\.env.local.backup.${timestamp}" (
|
|
||||||
copy /Y "%BACKUP_PATH%\\.env.local.backup.${timestamp}" "server\\.env.local"
|
|
||||||
)
|
|
||||||
|
|
||||||
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..."
|
|
||||||
# Backup .env.local (Priority)
|
|
||||||
if [ -f "server/.env.local" ]; then
|
|
||||||
echo "[Info] Backing up 'server/.env.local'."
|
|
||||||
cp "server/.env.local" "$BACKUP_DIR/.env.local.backup.${timestamp}"
|
|
||||||
cp "server/.env.local" "server/.env.local.tmp"
|
|
||||||
fi
|
|
||||||
# Backup .env (Fallback)
|
|
||||||
if [ -f "server/.env" ]; then
|
|
||||||
echo "[Info] Backing up 'server/.env'."
|
|
||||||
cp "server/.env" "$BACKUP_DIR/.env.backup.${timestamp}"
|
|
||||||
cp "server/.env" "server/.env.tmp"
|
|
||||||
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.local.tmp" ]; then
|
|
||||||
cp "server/.env.local.tmp" "server/.env.local"
|
|
||||||
rm "server/.env.local.tmp"
|
|
||||||
elif [ -f "$BACKUP_DIR/.env.local.backup.${timestamp}" ]; then
|
|
||||||
cp "$BACKUP_DIR/.env.local.backup.${timestamp}" "server/.env.local"
|
|
||||||
fi
|
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
const axios = require('axios');
|
|
||||||
|
|
||||||
async function test() {
|
|
||||||
const licenseManagerUrl = 'http://localhost:3006/api';
|
|
||||||
const licenseKey = 'test-key';
|
|
||||||
try {
|
|
||||||
console.log(`📡 Testing sync with ${licenseManagerUrl}/licenses/activate`);
|
|
||||||
const response = await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey });
|
|
||||||
console.log('✅ Success:', response.data);
|
|
||||||
} catch (err) {
|
|
||||||
if (err.response) {
|
|
||||||
console.error('❌ Error Response:', err.response.status, err.response.data);
|
|
||||||
} else {
|
|
||||||
console.error('❌ Error Message:', err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test();
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
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;
|
|
||||||
@ -1,27 +1,23 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
||||||
import { AuthProvider } from '../shared/auth/AuthContext';
|
import { ModuleGuard } from '../shared/auth/ModuleGuard';
|
||||||
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';
|
||||||
// Modules
|
import { AssetListPage } from '../modules/asset/pages/AssetListPage';
|
||||||
import { assetModule } from '../modules/asset/module';
|
import { AssetRegisterPage } from '../modules/asset/pages/AssetRegisterPage';
|
||||||
import { cctvModule } from '../modules/cctv/module';
|
import { AssetSettingsPage } from '../modules/asset/pages/AssetSettingsPage';
|
||||||
import { productionModule } from '../modules/production/module';
|
import { AssetDetailPage } from '../modules/asset/pages/AssetDetailPage';
|
||||||
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 { VersionPage } from '../platform/pages/VersionPage';
|
import { ProductionPage } from '../production/pages/ProductionPage';
|
||||||
|
import { MonitoringPage } from '../modules/cctv/pages/MonitoringPage';
|
||||||
|
import { AuthProvider } from '../shared/auth/AuthContext';
|
||||||
|
import { AuthGuard } from '../shared/auth/AuthGuard';
|
||||||
|
|
||||||
|
import { SystemProvider } from '../shared/context/SystemContext';
|
||||||
import { LicensePage } from '../system/pages/LicensePage';
|
import { LicensePage } from '../system/pages/LicensePage';
|
||||||
|
|
||||||
import '../platform/styles/global.css';
|
function App() {
|
||||||
|
|
||||||
const modules = [assetModule, cctvModule, productionModule];
|
|
||||||
|
|
||||||
export function App() {
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<SystemProvider>
|
<SystemProvider>
|
||||||
@ -33,20 +29,39 @@ export function App() {
|
|||||||
{/* Protected Routes */}
|
{/* Protected Routes */}
|
||||||
<Route element={
|
<Route element={
|
||||||
<AuthGuard>
|
<AuthGuard>
|
||||||
<MainLayout modulesList={modules} />
|
<MainLayout />
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
}>
|
}>
|
||||||
{/* Dynamic Module Routes */}
|
{/* Asset Management Routes */}
|
||||||
<Route path="/*" element={<ModuleLoader modules={modules} />} />
|
<Route element={<ModuleGuard moduleCode="asset"><Outlet /></ModuleGuard>}>
|
||||||
|
<Route path="/" element={<Navigate to="/asset/dashboard" replace />} />
|
||||||
|
<Route path="/asset/dashboard" element={<DashboardPage />} />
|
||||||
|
<Route path="/asset/register" element={<AssetRegisterPage />} />
|
||||||
|
<Route path="/asset/list" element={<AssetListPage />} />
|
||||||
|
<Route path="/asset/facilities" element={<AssetListPage />} />
|
||||||
|
<Route path="/asset/tools" element={<AssetListPage />} />
|
||||||
|
<Route path="/asset/general" element={<AssetListPage />} />
|
||||||
|
<Route path="/asset/settings" element={<AssetSettingsPage />} />
|
||||||
|
<Route path="/asset/detail/:assetId" element={<AssetDetailPage />} />
|
||||||
|
<Route path="/asset/consumables" element={<AssetListPage />} />
|
||||||
|
<Route path="/asset/instruments" element={<AssetListPage />} />
|
||||||
|
<Route path="/asset/vehicles" element={<AssetListPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Navigation Fallback within Layout */}
|
{/* Production Management Routes */}
|
||||||
<Route index element={<Navigate to="/asset/dashboard" replace />} />
|
<Route element={<ModuleGuard moduleCode="production"><Outlet /></ModuleGuard>}>
|
||||||
|
<Route path="/production/dashboard" element={<ProductionPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
{/* Platform Admin Routes */}
|
{/* Monitoring Routes */}
|
||||||
|
<Route element={<ModuleGuard moduleCode="monitoring"><Outlet /></ModuleGuard>}>
|
||||||
|
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
{/* Admin Routes */}
|
||||||
<Route path="/admin/users" element={<UserManagementPage />} />
|
<Route path="/admin/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>
|
||||||
|
|
||||||
|
|||||||
@ -18,7 +18,6 @@ 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)
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import React, { useState } from 'react';
|
||||||
import { Card } from '../../../shared/ui/Card';
|
import { Card } from '../../../shared/ui/Card';
|
||||||
import { Button } from '../../../shared/ui/Button';
|
import { Button } from '../../../shared/ui/Button';
|
||||||
import { ArrowLeft, Save, Upload, X, Printer, ZoomIn, Trash2, Plus } from 'lucide-react';
|
import { ArrowLeft, Save, Upload, X, Printer, ZoomIn, Trash2, Plus } from 'lucide-react';
|
||||||
@ -7,54 +7,16 @@ 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, accessories?: any[] };
|
asset: Asset & { image?: string, consumables?: any[] };
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
||||||
const navigate = useNavigate();
|
|
||||||
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 });
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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>
|
||||||
@ -98,28 +60,6 @@ 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' }}>
|
||||||
@ -129,9 +69,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>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
canEdit && <Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}>수정</Button>
|
<Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}>수정</Button>
|
||||||
)}
|
)}
|
||||||
<Button variant="secondary" size="sm" icon={<Printer size={16} />} onClick={() => window.print()}>출력</Button>
|
<Button variant="secondary" size="sm" icon={<Printer size={16} />}>출력</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="content-card print-friendly">
|
<Card className="content-card print-friendly">
|
||||||
@ -383,201 +323,51 @@ 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
|
|
||||||
navigate(`/asset/register?parentId=${asset.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>
|
||||||
|
|
||||||
{/* Accessories Section (For non-facility or all?) - User said skip hierarchical for others, use simple accessories */}
|
{/* Consumables Section */}
|
||||||
{!isFacility && (
|
<div className="consumables-section">
|
||||||
<div className="accessories-section">
|
<div className="flex justify-between items-center mb-2">
|
||||||
<div className="flex justify-between items-center mb-2">
|
<h3 className="section-title text-lg font-bold">관련 소모품 관리</h3>
|
||||||
<h3 className="section-title text-lg font-bold">부속품 관리</h3>
|
<Button size="sm" variant="secondary" icon={<Plus size={14} />}>소모품 추가</Button>
|
||||||
{canEdit && (
|
</div>
|
||||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => setShowAccModal(true)}>
|
<table className="doc-table w-full text-center border-collapse border border-slate-300">
|
||||||
부속품 추가
|
<thead>
|
||||||
</Button>
|
<tr>
|
||||||
)}
|
<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>
|
<th className="border border-slate-300 p-2 bg-slate-50">관리</th>
|
||||||
<tr>
|
</tr>
|
||||||
<th className="border border-slate-300 p-2 bg-slate-50">품명</th>
|
</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}>
|
||||||
|
<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>
|
||||||
{accessories.length > 0 ? (
|
</table>
|
||||||
accessories.map(item => (
|
</div>
|
||||||
<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
|
||||||
|
|||||||
@ -1,147 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { X, Search, Check } from 'lucide-react';
|
|
||||||
import { assetApi, type Asset } from '../../../shared/api/assetApi';
|
|
||||||
import { Button } from '../../../shared/ui/Button';
|
|
||||||
|
|
||||||
interface AssetSearchModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSelect: (asset: Asset) => void;
|
|
||||||
title?: string;
|
|
||||||
categoryFilter?: string; // Optional: filter by category name like '설비'
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AssetSearchModal({ isOpen, onClose, onSelect, title = '자산 검색', categoryFilter }: AssetSearchModalProps) {
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [assets, setAssets] = useState<Asset[]>([]);
|
|
||||||
const [filteredAssets, setFilteredAssets] = useState<Asset[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
loadAssets();
|
|
||||||
}
|
|
||||||
}, [isOpen]);
|
|
||||||
|
|
||||||
const loadAssets = async () => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const data = await assetApi.getAll();
|
|
||||||
|
|
||||||
// Apply category filter if provided
|
|
||||||
let result = data;
|
|
||||||
if (categoryFilter) {
|
|
||||||
result = data.filter(a => a.category === categoryFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
setAssets(result);
|
|
||||||
setFilteredAssets(result);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load assets for search:', error);
|
|
||||||
} finally {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!searchTerm) {
|
|
||||||
setFilteredAssets(assets);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lowerSearch = searchTerm.toLowerCase();
|
|
||||||
const filtered = assets.filter(a =>
|
|
||||||
a.name.toLowerCase().includes(lowerSearch) ||
|
|
||||||
a.id.toLowerCase().includes(lowerSearch) ||
|
|
||||||
(a.serialNumber && a.serialNumber.toLowerCase().includes(lowerSearch))
|
|
||||||
);
|
|
||||||
setFilteredAssets(filtered);
|
|
||||||
}, [searchTerm, assets]);
|
|
||||||
|
|
||||||
if (!isOpen) return null;
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl flex flex-col max-h-[80vh] overflow-hidden border border-slate-200">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="p-5 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
|
||||||
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
|
|
||||||
<Search size={20} className="text-blue-500" />
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1 transition-colors">
|
|
||||||
<X size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Search Bar */}
|
|
||||||
<div className="p-4 border-b border-slate-50 bg-white">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all font-medium"
|
|
||||||
placeholder="설비명 또는 관리번호로 검색..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* List Area */}
|
|
||||||
<div className="flex-1 overflow-y-auto p-2 bg-slate-50/30">
|
|
||||||
{isLoading ? (
|
|
||||||
<div className="p-12 text-center text-slate-500 font-medium">데이터를 불러오는 중...</div>
|
|
||||||
) : filteredAssets.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
{filteredAssets.map(asset => (
|
|
||||||
<button
|
|
||||||
key={asset.id}
|
|
||||||
onClick={() => onSelect(asset)}
|
|
||||||
className="flex items-center justify-between p-4 bg-white border border-slate-200 rounded-xl hover:border-blue-400 hover:shadow-md hover:bg-blue-50/30 transition-all group text-left"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-xs font-bold font-mono text-blue-600 bg-blue-50 px-2 py-0.5 rounded border border-blue-100">
|
|
||||||
{asset.id}
|
|
||||||
</span>
|
|
||||||
<span className="text-sm font-bold text-slate-500 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">
|
|
||||||
{asset.category}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-lg font-bold text-slate-800 group-hover:text-blue-700 transition-colors">
|
|
||||||
{asset.name}
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-3 text-sm text-slate-500">
|
|
||||||
<span>위치: <span className="text-slate-700 font-medium">{asset.location}</span></span>
|
|
||||||
{asset.serialNumber && <span>S/N: <span className="text-slate-700 font-medium">{asset.serialNumber}</span></span>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-slate-50 group-hover:bg-blue-500 group-hover:text-white p-2 rounded-full transition-all">
|
|
||||||
<Check size={20} />
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="p-20 text-center">
|
|
||||||
<div className="bg-slate-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
|
|
||||||
<Search size={24} className="text-slate-400" />
|
|
||||||
</div>
|
|
||||||
<p className="text-slate-500 font-medium font-lg">검색 결과가 없습니다.</p>
|
|
||||||
<p className="text-slate-400 text-sm mt-1">검색어를 다른 단어로 시도해 보세요.</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="p-4 border-t border-slate-100 bg-slate-50/50 flex justify-end">
|
|
||||||
<Button variant="secondary" onClick={onClose}>닫기</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
document.body
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,21 +4,15 @@ import { AssetListPage } from './pages/AssetListPage';
|
|||||||
import { AssetRegisterPage } from './pages/AssetRegisterPage';
|
import { AssetRegisterPage } from './pages/AssetRegisterPage';
|
||||||
import { AssetSettingsPage } from './pages/AssetSettingsPage';
|
import { AssetSettingsPage } from './pages/AssetSettingsPage';
|
||||||
import { AssetDetailPage } from './pages/AssetDetailPage';
|
import { AssetDetailPage } from './pages/AssetDetailPage';
|
||||||
import { PlaceholderPage } from '../../shared/ui/PlaceholderPage';
|
|
||||||
|
|
||||||
export const assetModule: IModuleDefinition = {
|
export const assetModule: IModuleDefinition = {
|
||||||
moduleName: 'asset-management',
|
moduleName: 'asset-management',
|
||||||
basePath: '/asset',
|
basePath: '/asset',
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/dashboard', element: <DashboardPage />, label: '대시보드' },
|
{ path: '/dashboard', element: <DashboardPage />, label: '대시보드', group: '기본' },
|
||||||
{ path: '/list', element: <AssetListPage />, label: '자산 목록', group: '기준정보' },
|
{ path: '/list', element: <AssetListPage />, label: '자산 목록', group: '기본' },
|
||||||
{ path: '/register', element: <AssetRegisterPage />, label: '자산 등록', group: '기준정보' },
|
{ path: '/register', element: <AssetRegisterPage />, label: '자산 등록', group: '기본' },
|
||||||
|
{ path: '/settings', element: <AssetSettingsPage />, label: '자산 설정', group: '기본' },
|
||||||
{ path: '/settings/modules', element: <PlaceholderPage title="모듈 관리" description="준비 중인 페이지입니다. 라이선스 관리와는 별도로 각 모듈의 세부 작동 방식을 설정하는 공간입니다." />, label: '모듈 관리', group: '설정' },
|
|
||||||
{ path: '/settings', element: <AssetSettingsPage />, label: '자산 설정', group: '설정' },
|
|
||||||
{ path: '/settings/iot', element: <PlaceholderPage title="IOT 센서 관리" description="자산에 부착된 IOT 센서의 상태를 모니터링하고 임계치를 설정할 수 있는 기능이 준비 중입니다." />, label: 'IOT 센서 관리', group: '설정' },
|
|
||||||
{ path: '/settings/facilities', element: <PlaceholderPage title="설비 관리" description="생산 설비의 효율 및 정비 주기를 전문적으로 관리하기 위한 전용 메뉴를 개발 중입니다." />, label: '설비 관리', group: '설정' },
|
|
||||||
|
|
||||||
{ path: '/detail/:assetId', element: <AssetDetailPage /> },
|
{ path: '/detail/:assetId', element: <AssetDetailPage /> },
|
||||||
|
|
||||||
{ path: '/facilities', element: <AssetListPage />, label: '시설물 관리', position: 'top' },
|
{ path: '/facilities', element: <AssetListPage />, label: '시설물 관리', position: 'top' },
|
||||||
@ -28,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', 'user']
|
requiredRoles: ['admin', 'manager']
|
||||||
};
|
};
|
||||||
|
|||||||
@ -6,25 +6,24 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header-right {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
justify-content: flex-start;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
text-align: right;
|
text-align: left;
|
||||||
margin-bottom: 2rem;
|
/* Explicitly set text align */
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title-text {
|
.page-title-text {
|
||||||
font-size: 1.75rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--sokuree-text-primary);
|
color: var(--color-text-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
.page-subtitle {
|
||||||
color: var(--sokuree-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: 1rem;
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-card {
|
.content-card {
|
||||||
|
|||||||
@ -3,8 +3,7 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
import { Card } from '../../../shared/ui/Card';
|
import { Card } from '../../../shared/ui/Card';
|
||||||
import { Button } from '../../../shared/ui/Button';
|
import { Button } from '../../../shared/ui/Button';
|
||||||
import { Input } from '../../../shared/ui/Input';
|
import { Input } from '../../../shared/ui/Input';
|
||||||
import { Search, Plus, Filter, Download, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight, Printer } from 'lucide-react';
|
import { Search, Plus, Filter, Download, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
import { useAuth } from '../../../shared/auth/AuthContext';
|
|
||||||
import './AssetListPage.css';
|
import './AssetListPage.css';
|
||||||
|
|
||||||
import { getCategories } from './AssetSettingsPage';
|
import { getCategories } from './AssetSettingsPage';
|
||||||
@ -20,14 +19,10 @@ 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
|
||||||
@ -38,17 +33,10 @@ 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();
|
||||||
if (Array.isArray(data)) {
|
setAssets(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);
|
||||||
}
|
}
|
||||||
@ -110,10 +98,8 @@ 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) {
|
||||||
// partial match fallback (e.g., '시설' in '시설 자산' or vice versa)
|
// Fallback for partial matches if needed, but let's try strict first based on user request.
|
||||||
const assetCat = asset.category || '';
|
if (!asset.category.includes(currentCategory)) return false;
|
||||||
const targetCat = currentCategory || '';
|
|
||||||
if (!assetCat.includes(targetCat) && !targetCat.includes(assetCat)) return false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -254,11 +240,13 @@ export function AssetListPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header-right">
|
<div className="page-header" style={{ justifyContent: 'flex-start', textAlign: 'left' }}>
|
||||||
<h1 className="page-title-text">{getPageTitle()}</h1>
|
<div>
|
||||||
<p className="page-subtitle">
|
<h1 className="page-title-text">{getPageTitle()}</h1>
|
||||||
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
|
<p className="page-subtitle">
|
||||||
</p>
|
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="content-card">
|
<Card className="content-card">
|
||||||
@ -280,11 +268,8 @@ export function AssetListPage() {
|
|||||||
>
|
>
|
||||||
필터
|
필터
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="secondary" icon={<Printer size={16} />} onClick={() => window.print()}>출력</Button>
|
|
||||||
<Button variant="secondary" icon={<Download size={16} />} onClick={handleExcelDownload}>엑셀 다운로드</Button>
|
<Button variant="secondary" icon={<Download size={16} />} onClick={handleExcelDownload}>엑셀 다운로드</Button>
|
||||||
{canRegister && (
|
<Button onClick={() => navigate('/asset/register')} icon={<Plus size={16} />}>자산 등록</Button>
|
||||||
<Button onClick={() => navigate('/asset/register')} icon={<Plus size={16} />}>자산 등록</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filter Popup */}
|
{/* Filter Popup */}
|
||||||
{isFilterOpen && (
|
{isFilterOpen && (
|
||||||
@ -410,18 +395,9 @@ 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={10} className="empty-state">
|
<td colSpan={9} className="empty-state">
|
||||||
데이터가 없습니다.
|
데이터가 없습니다.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@ -1,43 +0,0 @@
|
|||||||
/* 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;
|
|
||||||
}
|
|
||||||
@ -1,18 +1,15 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { 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 { Select } from '../../../shared/ui/Select';
|
import { Select } from '../../../shared/ui/Select';
|
||||||
import { ArrowLeft, Save, Upload, Search, X } 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 { AssetSearchModal } from '../components/AssetSearchModal';
|
|
||||||
import './AssetRegisterPage.css';
|
|
||||||
|
|
||||||
export function AssetRegisterPage() {
|
export function AssetRegisterPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
|
|
||||||
// Load Settings Data
|
// Load Settings Data
|
||||||
const categories = getCategories();
|
const categories = getCategories();
|
||||||
@ -21,10 +18,8 @@ export function AssetRegisterPage() {
|
|||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
id: '', // Asset ID (Auto-generated)
|
id: '', // Asset ID (Auto-generated)
|
||||||
parentId: searchParams.get('parentId') || '',
|
|
||||||
parentName: '', // For display
|
|
||||||
name: '',
|
name: '',
|
||||||
categoryId: searchParams.get('categoryId') || '', // Use ID for selection
|
categoryId: '', // Use ID for selection
|
||||||
model: '',
|
model: '',
|
||||||
serialNo: '',
|
serialNo: '',
|
||||||
locationId: '', // Use ID for selection
|
locationId: '', // Use ID for selection
|
||||||
@ -37,33 +32,6 @@ export function AssetRegisterPage() {
|
|||||||
manufacturer: ''
|
manufacturer: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadInitialData = async () => {
|
|
||||||
try {
|
|
||||||
const data = await assetApi.getAll();
|
|
||||||
|
|
||||||
// If parentId is provided via URL, find its name
|
|
||||||
const pId = searchParams.get('parentId');
|
|
||||||
if (pId) {
|
|
||||||
const parent = data.find(a => a.id === pId);
|
|
||||||
if (parent) {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
parentName: parent.name,
|
|
||||||
// If it's a sub-equipment, it's likely the same category as parent if parent is facility
|
|
||||||
categoryId: prev.categoryId || categories.find(c => c.name === '설비')?.id || ''
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Failed to load assets for initial data", err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
loadInitialData();
|
|
||||||
}, [searchParams, categories]);
|
|
||||||
|
|
||||||
// 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
|
||||||
@ -83,11 +51,15 @@ 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;
|
if (part.type === 'sequence') return part.value; // In real app, we fetch next seq. here we just show the format pattern e.g. 001
|
||||||
return '';
|
return '';
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
setFormData(prev => ({ ...prev, id: generatedId }));
|
// Ideally we would fetch the *actual* next sequence from API here.
|
||||||
|
// 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 }));
|
||||||
|
|
||||||
}, [formData.categoryId, formData.purchaseDate, idRule, categories]);
|
}, [formData.categoryId, formData.purchaseDate, idRule, categories]);
|
||||||
|
|
||||||
@ -115,7 +87,7 @@ export function AssetRegisterPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
if (e) e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
// Validation
|
// Validation
|
||||||
if (!formData.categoryId || !formData.name || !formData.locationId) {
|
if (!formData.categoryId || !formData.name || !formData.locationId) {
|
||||||
@ -124,9 +96,11 @@ 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);
|
||||||
@ -135,9 +109,8 @@ export function AssetRegisterPage() {
|
|||||||
|
|
||||||
const payload: Partial<Asset> = {
|
const payload: Partial<Asset> = {
|
||||||
id: formData.id,
|
id: formData.id,
|
||||||
parentId: isFacility ? formData.parentId : undefined,
|
|
||||||
name: formData.name,
|
name: formData.name,
|
||||||
category: selectedCategory ? selectedCategory.name : '미지정',
|
category: selectedCategory ? selectedCategory.name : '미지정', // Backend expects name
|
||||||
model: formData.model,
|
model: formData.model,
|
||||||
serialNumber: formData.serialNo,
|
serialNumber: formData.serialNo,
|
||||||
location: selectedLocation ? selectedLocation.name : '미지정',
|
location: selectedLocation ? selectedLocation.name : '미지정',
|
||||||
@ -159,274 +132,200 @@ export function AssetRegisterPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isFacility = categories.find(c => c.id === formData.categoryId)?.name === '설비';
|
|
||||||
|
|
||||||
const handleSelectParent = (parent: Asset) => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
parentId: parent.id,
|
|
||||||
parentName: parent.name
|
|
||||||
}));
|
|
||||||
setIsSearchModalOpen(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const clearParent = () => {
|
|
||||||
setFormData(prev => ({
|
|
||||||
...prev,
|
|
||||||
parentId: '',
|
|
||||||
parentName: ''
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container">
|
<div className="page-container">
|
||||||
<div className="page-header-right">
|
<div className="page-header">
|
||||||
<h1 className="page-title-text">자산 등록</h1>
|
<div>
|
||||||
<p className="page-subtitle">새로운 자산을 시스템에 등록합니다.</p>
|
<h1 className="page-title-text">자산 등록</h1>
|
||||||
|
<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 shadow-sm border border-slate-200 mb-8">
|
<Card className="w-full h-full shadow-sm border border-slate-200">
|
||||||
<form onSubmit={handleSubmit} className="p-2 sm:p-4 lg:p-6">
|
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
|
{/* Basic Info */}
|
||||||
{/* Basic Info Section */}
|
<div className="col-span-full border-b border-slate-100 pb-2 mb-2">
|
||||||
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2">
|
<h3 className="text-lg font-semibold text-slate-800">기본 정보</h3>
|
||||||
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
|
</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
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{isFacility && (
|
<Input
|
||||||
<div className="ui-field-container">
|
label="자산 관리 번호 (자동 생성)"
|
||||||
<label className="ui-label">상위 설비 (메인 설비)</label>
|
name="id"
|
||||||
<div className="flex gap-2">
|
value={formData.id}
|
||||||
<div className="relative flex-1">
|
disabled
|
||||||
<Input
|
placeholder="카테고리 선택 시 자동 생성됨"
|
||||||
name="parentName"
|
className="bg-slate-50 font-mono text-slate-600"
|
||||||
value={formData.parentName ? `[${formData.parentId}] ${formData.parentName}` : ''}
|
/>
|
||||||
placeholder="검색 아이콘을 클릭하여 선택하세요"
|
|
||||||
readOnly
|
|
||||||
className="bg-slate-50 cursor-default"
|
|
||||||
/>
|
|
||||||
{formData.parentId && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={clearParent}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-red-500"
|
|
||||||
>
|
|
||||||
<X size={16} />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="secondary"
|
|
||||||
onClick={() => setIsSearchModalOpen(true)}
|
|
||||||
className="shrink-0 h-[42px]"
|
|
||||||
icon={<Search size={18} />}
|
|
||||||
>
|
|
||||||
검색
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="자산 관리 번호 (자동 생성)"
|
label="자산명 *"
|
||||||
name="id"
|
name="name"
|
||||||
value={formData.id}
|
value={formData.name}
|
||||||
disabled
|
onChange={handleChange}
|
||||||
placeholder="카테고리 선택 시 자동 생성됨"
|
placeholder="예: CNC 머시닝 센터"
|
||||||
className="bg-slate-50 font-mono text-slate-600"
|
required
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="자산명 *"
|
label="제작사"
|
||||||
name="name"
|
name="manufacturer"
|
||||||
value={formData.name}
|
value={formData.manufacturer}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
placeholder="예: CNC 머시닝 센터"
|
/>
|
||||||
required
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="제작사"
|
label="모델명"
|
||||||
name="manufacturer"
|
name="model"
|
||||||
value={formData.manufacturer}
|
value={formData.model}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
<Input
|
||||||
label="모델명"
|
label="시리얼 번호 / 규격"
|
||||||
name="model"
|
name="serialNo"
|
||||||
value={formData.model}
|
value={formData.serialNo}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Input
|
{/* Image Upload Field */}
|
||||||
label="시리얼 번호 / 규격"
|
<div className="ui-field-container col-span-full">
|
||||||
name="serialNo"
|
<label className="ui-label">자산 이미지</label>
|
||||||
value={formData.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' }}>
|
||||||
onChange={handleChange}
|
{/* Preview Area - Fixed size container */}
|
||||||
/>
|
<div
|
||||||
|
className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
|
||||||
{/* Image Upload Field */}
|
style={{
|
||||||
<div className="ui-field-container col-span-full">
|
width: '400px',
|
||||||
<label className="ui-label">자산 이미지</label>
|
height: '350px',
|
||||||
<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' }}>
|
display: 'flex',
|
||||||
<div
|
alignItems: 'center',
|
||||||
className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
|
justifyContent: 'center'
|
||||||
style={{
|
}}
|
||||||
width: '400px',
|
>
|
||||||
height: '350px',
|
{formData.imagePreview ? (
|
||||||
display: 'flex',
|
<img
|
||||||
alignItems: 'center',
|
src={formData.imagePreview}
|
||||||
justifyContent: 'center'
|
alt="Preview"
|
||||||
}}
|
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 */}
|
|
||||||
<div className="form-actions-footer">
|
{/* Management Info */}
|
||||||
<Button
|
<div className="col-span-full border-b border-slate-100 pb-2 mb-2 mt-4">
|
||||||
variant="secondary"
|
<h3 className="text-lg font-semibold text-slate-800">관리 정보</h3>
|
||||||
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>
|
||||||
|
|
||||||
<AssetSearchModal
|
|
||||||
isOpen={isSearchModalOpen}
|
|
||||||
onClose={() => setIsSearchModalOpen(false)}
|
|
||||||
onSelect={handleSelectParent}
|
|
||||||
title="상위 설비 검색"
|
|
||||||
categoryFilter="설비"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,45 +1,45 @@
|
|||||||
/* Page Header - Right Aligned */
|
/* Page Header adjustments for Settings */
|
||||||
.page-header-right {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
gap: 1rem;
|
||||||
text-align: right;
|
padding-bottom: 0 !important;
|
||||||
margin-bottom: 2rem;
|
/* Override default bottom padding */
|
||||||
width: 100%;
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-title-text {
|
.header-top {
|
||||||
font-size: 1.75rem;
|
padding-bottom: 0.5rem;
|
||||||
font-weight: 700;
|
|
||||||
color: var(--sokuree-text-primary);
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-subtitle {
|
/* Settings Tabs */
|
||||||
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 {
|
.settings-tabs {
|
||||||
display: none;
|
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;
|
||||||
|
border-bottom-color: var(--color-brand-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Common Layout */
|
/* Common Layout */
|
||||||
@ -66,8 +66,8 @@
|
|||||||
|
|
||||||
/* Table Styles */
|
/* Table Styles */
|
||||||
.table-wrapper {
|
.table-wrapper {
|
||||||
border: 1px solid var(--sokuree-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--sokuree-radius-md);
|
border-radius: var(--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(--sokuree-border-color);
|
border-bottom: 1px solid var(--color-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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(--sokuree-text-primary);
|
color: var(--color-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(--sokuree-radius-md);
|
border-radius: var(--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(--sokuree-radius-md);
|
border-radius: var(--radius-md);
|
||||||
background-color: #fcfcfc;
|
background-color: #fcfcfc;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -282,16 +282,14 @@
|
|||||||
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(--sokuree-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--sokuree-radius-sm);
|
border-radius: var(--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(--sokuree-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
box-shadow: 0 0 0 2px rgba(var(--sokuree-brand-primary-rgb), 0.1);
|
|
||||||
}
|
}
|
||||||
@ -3,8 +3,7 @@ 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, Save, Loader2 } from 'lucide-react';
|
import { Plus, Trash2, Edit2, X, Check, GripVertical } 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';
|
||||||
@ -77,7 +76,6 @@ function SortableRuleItem({ id, children, className }: { id: string; children: R
|
|||||||
|
|
||||||
// ---------------------
|
// ---------------------
|
||||||
|
|
||||||
// Types
|
|
||||||
// Types
|
// Types
|
||||||
interface AssetCategory {
|
interface AssetCategory {
|
||||||
id: string;
|
id: string;
|
||||||
@ -98,7 +96,7 @@ interface AssetStatus {
|
|||||||
color: string;
|
color: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type IDRuleComponentType = 'company' | 'category' | 'year' | 'month' | 'sequence' | 'separator' | 'custom';
|
type IDRuleComponentType = 'company' | 'category' | 'year' | 'sequence' | 'separator' | 'custom';
|
||||||
|
|
||||||
interface IDRuleComponent {
|
interface IDRuleComponent {
|
||||||
id: string;
|
id: string;
|
||||||
@ -107,13 +105,7 @@ interface IDRuleComponent {
|
|||||||
label: string;
|
label: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AssetMaintenanceType {
|
// Initial Mock Data
|
||||||
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' },
|
||||||
@ -138,7 +130,7 @@ let GLOBAL_STATUSES: AssetStatus[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
let GLOBAL_ID_RULE: IDRuleComponent[] = [
|
let GLOBAL_ID_RULE: IDRuleComponent[] = [
|
||||||
{ id: 'r1', type: 'company', value: 'SKR', label: '회사약어' },
|
{ id: 'r1', type: 'company', value: 'HK', 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: '구분자' },
|
||||||
@ -147,6 +139,12 @@ 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' },
|
||||||
@ -172,68 +170,6 @@ 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('');
|
||||||
@ -509,12 +445,6 @@ 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);
|
||||||
@ -523,12 +453,10 @@ export function AssetSettingsPage() {
|
|||||||
|
|
||||||
|
|
||||||
const getPreviewId = () => {
|
const getPreviewId = () => {
|
||||||
const now = new Date();
|
|
||||||
const mockData = {
|
const mockData = {
|
||||||
company: 'HK', // Fallback
|
company: 'HK',
|
||||||
category: 'FAC',
|
category: 'FAC',
|
||||||
year: now.getFullYear().toString(),
|
year: new Date().getFullYear().toString(),
|
||||||
month: String(now.getMonth() + 1).padStart(2, '0'),
|
|
||||||
sequence: '001',
|
sequence: '001',
|
||||||
separator: '-'
|
separator: '-'
|
||||||
};
|
};
|
||||||
@ -538,33 +466,22 @@ 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 h-full flex flex-col">
|
<div className="page-container">
|
||||||
<div className="page-header-right">
|
<div className="settings-content mt-4">
|
||||||
<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 w-full">
|
<Card className="settings-card max-w-5xl">
|
||||||
<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>
|
||||||
@ -601,57 +518,40 @@ 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">
|
||||||
<input
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: 'auto' }}>
|
||||||
type="text"
|
<input
|
||||||
placeholder="약어 (예: SKR)"
|
type="text"
|
||||||
value={companyCodeInput}
|
placeholder="약어 (예: HK)"
|
||||||
onChange={e => {
|
value={companyCodeInput}
|
||||||
const val = e.target.value.toUpperCase();
|
onChange={e => setCompanyCodeInput(e.target.value.toUpperCase())}
|
||||||
setCompanyCodeInput(val);
|
className="custom-text-input"
|
||||||
updateCompanyValue(val);
|
maxLength={5}
|
||||||
}}
|
style={{ width: '100px' }}
|
||||||
className="custom-text-input"
|
/>
|
||||||
maxLength={5}
|
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('company', companyCodeInput, `회사약어 (${companyCodeInput})`)}>회사약어 ({companyCodeInput})</Button>
|
||||||
style={{ width: '80px' }}
|
</div>
|
||||||
/>
|
|
||||||
<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>
|
||||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('custom', '', '사용자 정의')}>추가</Button>
|
</div>
|
||||||
</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 w-full">
|
<Card className="settings-card max-w-4xl">
|
||||||
<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>
|
||||||
@ -766,25 +666,12 @@ 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 w-full">
|
<Card className="settings-card max-w-3xl">
|
||||||
<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>
|
||||||
@ -848,25 +735,12 @@ 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 w-full">
|
<Card className="settings-card max-w-5xl">
|
||||||
<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>
|
||||||
@ -960,25 +834,12 @@ 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 w-full">
|
<Card className="settings-card max-w-4xl">
|
||||||
<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>
|
||||||
@ -1062,19 +923,6 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -64,7 +64,7 @@ export function JSMpegPlayer({ url, width, height, className }: JSMpegPlayerProp
|
|||||||
}, [url, retryCount]); // Re-run when url or retryCount changes
|
}, [url, retryCount]); // Re-run when url or retryCount changes
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`video-container bg-transparent flex items-center justify-center overflow-hidden ${className || ''}`} style={{ width, height }}>
|
<div className={`video-container bg-black flex items-center justify-center overflow-hidden ${className || ''}`} style={{ width, height }}>
|
||||||
<canvas key={`${url}-${retryCount}`} ref={canvasRef} className="w-full h-full object-contain" />
|
<canvas key={`${url}-${retryCount}`} ref={canvasRef} className="w-full h-full object-contain" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,16 +1,11 @@
|
|||||||
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: 'cctv',
|
moduleName: 'monitoring-cctv',
|
||||||
label: 'CCTV',
|
|
||||||
basePath: '/monitoring',
|
basePath: '/monitoring',
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/live', element: <MonitoringPage />, label: '실시간 관제' },
|
{ path: '/live', element: <MonitoringPage />, label: '실시간 관제' },
|
||||||
{ path: '/manage', element: <CameraManagementPage />, label: '장치 관리' },
|
|
||||||
{ path: '/settings', element: <CctvSettingsPage />, label: '기본 설정' },
|
|
||||||
],
|
],
|
||||||
requiredRoles: ['admin', 'operator', 'user']
|
requiredRoles: ['admin', 'operator']
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,392 +0,0 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
|
||||||
import { Card } from '../../../shared/ui/Card';
|
|
||||||
import { Button } from '../../../shared/ui/Button';
|
|
||||||
import { Input } from '../../../shared/ui/Input';
|
|
||||||
import { apiClient } from '../../../shared/api/client';
|
|
||||||
import {
|
|
||||||
Plus, Edit2, Trash2, X, Check, Search,
|
|
||||||
Settings, Video, RefreshCcw, Power,
|
|
||||||
PowerOff, LayoutGrid
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { useAuth } from '../../../shared/auth/AuthContext';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
|
|
||||||
interface Camera {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
ip_address: string;
|
|
||||||
port: number;
|
|
||||||
username?: string;
|
|
||||||
password?: string;
|
|
||||||
stream_path?: string;
|
|
||||||
transport_mode?: 'tcp' | 'udp' | 'auto';
|
|
||||||
rtsp_encoding?: boolean;
|
|
||||||
quality?: 'low' | 'medium' | 'original';
|
|
||||||
is_active?: boolean | number;
|
|
||||||
display_order?: number;
|
|
||||||
zone?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CameraManagementPage() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [cameras, setCameras] = useState<Camera[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
|
||||||
const [availableZones, setAvailableZones] = useState<string[]>([]);
|
|
||||||
const fetchedRef = useRef(false);
|
|
||||||
|
|
||||||
// Modal State
|
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [formData, setFormData] = useState<Partial<Camera>>({
|
|
||||||
name: '',
|
|
||||||
ip_address: '',
|
|
||||||
port: 554,
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
stream_path: '/stream1',
|
|
||||||
transport_mode: 'tcp',
|
|
||||||
rtsp_encoding: false,
|
|
||||||
quality: 'low',
|
|
||||||
zone: '기본 구역'
|
|
||||||
});
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (fetchedRef.current) return;
|
|
||||||
fetchedRef.current = true;
|
|
||||||
fetchCameras();
|
|
||||||
fetchZones();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchZones = async () => {
|
|
||||||
try {
|
|
||||||
const res = await apiClient.get('/cameras/zones');
|
|
||||||
setAvailableZones(res.data.map((z: any) => z.name));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch zones', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchCameras = async () => {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await apiClient.get('/cameras');
|
|
||||||
setCameras(res.data);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch cameras', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenAdd = () => {
|
|
||||||
setFormData({
|
|
||||||
name: '',
|
|
||||||
ip_address: '',
|
|
||||||
port: 554,
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
stream_path: '/stream1',
|
|
||||||
transport_mode: 'tcp',
|
|
||||||
rtsp_encoding: false,
|
|
||||||
quality: 'low',
|
|
||||||
zone: '기본 구역'
|
|
||||||
});
|
|
||||||
setIsEditing(false);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOpenEdit = (camera: Camera) => {
|
|
||||||
setFormData(camera);
|
|
||||||
setIsEditing(true);
|
|
||||||
setIsModalOpen(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
|
||||||
if (!confirm('정말 이 카메라를 삭제하시겠습니까?')) return;
|
|
||||||
try {
|
|
||||||
await apiClient.delete(`/cameras/${id}`);
|
|
||||||
fetchCameras();
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to delete camera', error);
|
|
||||||
alert('삭제 실패');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleStatus = async (camera: Camera) => {
|
|
||||||
const newStatus = !(camera.is_active !== 0 && camera.is_active !== false);
|
|
||||||
try {
|
|
||||||
await apiClient.patch(`/cameras/${camera.id}/status`, { is_active: newStatus });
|
|
||||||
fetchCameras();
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to toggle status', err);
|
|
||||||
alert('상태 변경 실패');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
try {
|
|
||||||
if (isEditing) {
|
|
||||||
await apiClient.put(`/cameras/${formData.id}`, formData);
|
|
||||||
alert('수정되었습니다.');
|
|
||||||
} else {
|
|
||||||
await apiClient.post('/cameras', formData);
|
|
||||||
alert('등록되었습니다.');
|
|
||||||
}
|
|
||||||
setIsModalOpen(false);
|
|
||||||
fetchCameras();
|
|
||||||
} catch (error: any) {
|
|
||||||
alert(`오류: ${error.response?.data?.error || error.message}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const filteredCameras = cameras.filter(cam =>
|
|
||||||
cam.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
cam.ip_address.includes(searchTerm)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-container p-6 max-w-7xl mx-auto">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
|
||||||
<Video className="text-indigo-600" />
|
|
||||||
CCTV 통합 관리
|
|
||||||
</h1>
|
|
||||||
<p className="text-slate-500 mt-1">시스템에 등록된 모든 CCTV 장치를 목록 형태로 관리합니다.</p>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button variant="secondary" onClick={() => navigate('/monitoring/live')} icon={<LayoutGrid size={16} />}>실시간 관제</Button>
|
|
||||||
{(user?.role === 'admin' || user?.role === 'supervisor') && (
|
|
||||||
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}>장치 추가</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="mb-6 p-4 border-slate-200">
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<div className="relative flex-1">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
|
||||||
<Input
|
|
||||||
className="pl-10"
|
|
||||||
placeholder="카메라 이름 또는 IP 주소로 검색..."
|
|
||||||
value={searchTerm}
|
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button variant="secondary" onClick={fetchCameras} icon={<RefreshCcw size={16} className={loading ? 'animate-spin' : ''} />}>
|
|
||||||
새로고침
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="overflow-hidden shadow-sm border-slate-200">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full text-sm text-left">
|
|
||||||
<thead className="text-xs text-slate-500 uppercase bg-slate-50/80 border-b border-slate-200">
|
|
||||||
<tr>
|
|
||||||
<th className="px-6 py-4">상태</th>
|
|
||||||
<th className="px-6 py-4">카메라 명칭</th>
|
|
||||||
<th className="px-6 py-4">구역</th>
|
|
||||||
<th className="px-6 py-4">네트워크 정보 (IP:Port)</th>
|
|
||||||
<th className="px-6 py-4 text-center">관리</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100 bg-white">
|
|
||||||
{loading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="px-6 py-12 text-center text-slate-400">데이터를 불러오는 중...</td>
|
|
||||||
</tr>
|
|
||||||
) : filteredCameras.map((camera) => {
|
|
||||||
const isActive = camera.is_active !== 0 && camera.is_active !== false;
|
|
||||||
return (
|
|
||||||
<tr key={camera.id} className="hover:bg-slate-50/50 transition-colors">
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<button
|
|
||||||
onClick={() => handleToggleStatus(camera)}
|
|
||||||
className={`flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-bold ring-1 transition-all ${isActive
|
|
||||||
? 'bg-green-50 text-green-700 ring-green-200 hover:bg-green-100'
|
|
||||||
: 'bg-slate-100 text-slate-500 ring-slate-200 hover:bg-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{isActive ? <Power size={12} /> : <PowerOff size={12} />}
|
|
||||||
{isActive ? '운영 중' : '중지됨'}
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="font-bold text-slate-900">{camera.name}</div>
|
|
||||||
<div className="text-[11px] text-slate-500 mt-0.5">ID: {camera.id}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="px-2 py-1 bg-slate-100 text-slate-600 rounded-md text-[11px] font-medium border border-slate-200">
|
|
||||||
{camera.zone || '기본 구역'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 font-mono text-slate-700">
|
|
||||||
{camera.ip_address}:{camera.port}
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex justify-center gap-1">
|
|
||||||
{(user?.role === 'admin' || user?.role === 'supervisor') && (
|
|
||||||
<>
|
|
||||||
<button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(camera)}>
|
|
||||||
<Edit2 size={16} />
|
|
||||||
</button>
|
|
||||||
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(camera.id)}>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!loading && filteredCameras.length === 0 && (
|
|
||||||
<tr className="no-result-row">
|
|
||||||
<td colSpan={5} className="px-6 py-12 text-center text-slate-400">검색 결과가 없습니다.</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Modal */}
|
|
||||||
{isModalOpen && (
|
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
|
||||||
<Card className="w-full max-w-lg shadow-2xl border-slate-200 overflow-hidden">
|
|
||||||
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
|
||||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
|
||||||
<Settings size={20} className="text-indigo-600" />
|
|
||||||
{isEditing ? '장치 설정 수정' : '새 장치 등록'}
|
|
||||||
</h2>
|
|
||||||
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1">
|
|
||||||
<X size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">카메라 이름 <span className="text-red-500">*</span></label>
|
|
||||||
<Input
|
|
||||||
value={formData.name}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
placeholder="예: 정문 CCTV"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-2">
|
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">설치 구역 <span className="text-red-500">*</span></label>
|
|
||||||
<select
|
|
||||||
className="w-full bg-white border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition-all appearance-none"
|
|
||||||
value={formData.zone}
|
|
||||||
onChange={(e) => setFormData({ ...formData, zone: e.target.value })}
|
|
||||||
required
|
|
||||||
>
|
|
||||||
<option value="" disabled>구역을 선택하세요</option>
|
|
||||||
{availableZones.map(zone => (
|
|
||||||
<option key={zone} value={zone}>{zone}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">IP 주소 <span className="text-red-500">*</span></label>
|
|
||||||
<Input
|
|
||||||
value={formData.ip_address}
|
|
||||||
onChange={(e) => setFormData({ ...formData, ip_address: e.target.value })}
|
|
||||||
placeholder="192.168.1.100"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">포트 (RTSP)</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={formData.port}
|
|
||||||
onChange={(e) => setFormData({ ...formData, port: parseInt(e.target.value) || 554 })}
|
|
||||||
placeholder="554"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4 pt-2 border-t border-slate-50">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">RTSP 계정명</label>
|
|
||||||
<Input
|
|
||||||
value={formData.username}
|
|
||||||
onChange={(e) => setFormData({ ...formData, username: e.target.value })}
|
|
||||||
placeholder="admin"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">RTSP 비밀번호</label>
|
|
||||||
<Input
|
|
||||||
type="password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
||||||
placeholder="••••••••"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 pt-4 border-t border-slate-50">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">스트림 경로</label>
|
|
||||||
<Input
|
|
||||||
value={formData.stream_path}
|
|
||||||
onChange={(e) => setFormData({ ...formData, stream_path: e.target.value })}
|
|
||||||
placeholder="/stream1"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">전송 방식</label>
|
|
||||||
<select
|
|
||||||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-indigo-500/20"
|
|
||||||
value={formData.transport_mode}
|
|
||||||
onChange={(e) => setFormData({ ...formData, transport_mode: e.target.value as any })}
|
|
||||||
>
|
|
||||||
<option value="tcp">TCP (권장)</option>
|
|
||||||
<option value="udp">UDP</option>
|
|
||||||
<option value="auto">Auto</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">화질 등급</label>
|
|
||||||
<select
|
|
||||||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium outline-none focus:ring-2 focus:ring-indigo-500/20"
|
|
||||||
value={formData.quality}
|
|
||||||
onChange={(e) => setFormData({ ...formData, quality: e.target.value as any })}
|
|
||||||
>
|
|
||||||
<option value="low">Low (640p)</option>
|
|
||||||
<option value="medium">Medium (HD)</option>
|
|
||||||
<option value="original">Original</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="rtsp_enc"
|
|
||||||
checked={formData.rtsp_encoding}
|
|
||||||
onChange={(e) => setFormData({ ...formData, rtsp_encoding: e.target.checked })}
|
|
||||||
className="w-4 h-4 text-indigo-600 rounded border-slate-300 focus:ring-indigo-500"
|
|
||||||
/>
|
|
||||||
<label htmlFor="rtsp_enc" className="text-xs font-medium text-slate-600 cursor-pointer">특수문자 인코딩 사용 (경로에 @ 등이 포함된 경우)</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="pt-4 flex justify-end gap-2">
|
|
||||||
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>취소</Button>
|
|
||||||
<Button type="submit" className="bg-indigo-600" icon={<Check size={16} />}>{isEditing ? '변경사항 저장' : '등록 완료'}</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { Card } from '../../../shared/ui/Card';
|
|
||||||
import { Button } from '../../../shared/ui/Button';
|
|
||||||
import { Input } from '../../../shared/ui/Input';
|
|
||||||
import { apiClient } from '../../../shared/api/client';
|
|
||||||
import { Plus, Trash2, Save, GripVertical, Settings } from 'lucide-react';
|
|
||||||
|
|
||||||
interface ZoneConfig {
|
|
||||||
name: string;
|
|
||||||
layout: '1' | '1*2' | '2*2';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CctvSettingsPage() {
|
|
||||||
const [zones, setZones] = useState<ZoneConfig[]>([]);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchZones();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const fetchZones = async () => {
|
|
||||||
try {
|
|
||||||
const res = await apiClient.get('/cameras/zones');
|
|
||||||
setZones(res.data.map((z: any) => ({
|
|
||||||
name: z.name,
|
|
||||||
layout: z.layout || '2*2'
|
|
||||||
})));
|
|
||||||
} catch (err) {
|
|
||||||
console.error('Failed to fetch zones', err);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAddZone = () => {
|
|
||||||
setZones([...zones, { name: '', layout: '2*2' }]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveZone = (index: number) => {
|
|
||||||
const newZones = [...zones];
|
|
||||||
newZones.splice(index, 1);
|
|
||||||
setZones(newZones);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleZoneNameChange = (index: number, value: string) => {
|
|
||||||
const newZones = [...zones];
|
|
||||||
newZones[index].name = value;
|
|
||||||
setZones(newZones);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleZoneLayoutChange = (index: number, value: '1' | '1*2' | '2*2') => {
|
|
||||||
const newZones = [...zones];
|
|
||||||
newZones[index].layout = value;
|
|
||||||
setZones(newZones);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (loading) return;
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const filteredZones = zones.filter(z => z.name.trim() !== '');
|
|
||||||
if (filteredZones.length === 0) {
|
|
||||||
alert('최소 하나 이상의 구역이 필요합니다.');
|
|
||||||
setLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await apiClient.put('/cameras/zones', { zones: filteredZones });
|
|
||||||
alert('구역 및 레이아웃 설정이 저장되었습니다.');
|
|
||||||
fetchZones();
|
|
||||||
} catch (err) {
|
|
||||||
console.error(err);
|
|
||||||
alert('저장 실패');
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-container p-6 max-w-5xl mx-auto">
|
|
||||||
<div className="mb-6 text-indigo-600">
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900 flex items-center gap-2">
|
|
||||||
<Settings className="text-indigo-600" />
|
|
||||||
CCTV 기본 설정
|
|
||||||
</h1>
|
|
||||||
<p className="text-slate-500 mt-1">CCTV 모듈의 전역 설정을 관리합니다.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-6 border-slate-200">
|
|
||||||
<h2 className="text-lg font-bold text-slate-800 mb-2 font-noto">설치 구역 및 레이아웃 관리</h2>
|
|
||||||
<p className="text-sm text-slate-500 mb-6 font-medium">
|
|
||||||
관제 화면에서 사용할 구역과 각 구역별 화면 분할(레이아웃) 방식을 설정합니다.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="space-y-3 mb-8">
|
|
||||||
<div className="grid grid-cols-[40px_1fr_200px_40px] gap-4 px-2 mb-2 text-xs font-bold text-slate-400 uppercase tracking-wider">
|
|
||||||
<div className="text-center">#</div>
|
|
||||||
<div>구역 명칭</div>
|
|
||||||
<div>분할 레이아웃 (행*열)</div>
|
|
||||||
<div></div>
|
|
||||||
</div>
|
|
||||||
{zones.map((zone, index) => (
|
|
||||||
<div key={index} className="grid grid-cols-[40px_1fr_200px_40px] gap-4 items-center animate-in slide-in-from-left-2 duration-200" style={{ animationDelay: `${index * 50}ms` }}>
|
|
||||||
<div className="p-2 text-slate-300 flex justify-center">
|
|
||||||
<GripVertical size={20} />
|
|
||||||
</div>
|
|
||||||
<Input
|
|
||||||
value={zone.name}
|
|
||||||
onChange={(e) => handleZoneNameChange(index, e.target.value)}
|
|
||||||
placeholder="예: 정문, A구역 등"
|
|
||||||
className="flex-1"
|
|
||||||
/>
|
|
||||||
<select
|
|
||||||
value={zone.layout}
|
|
||||||
onChange={(e) => handleZoneLayoutChange(index, e.target.value as any)}
|
|
||||||
className="bg-white border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-indigo-500 transition-all font-medium"
|
|
||||||
>
|
|
||||||
<option value="1">1 (1x1)</option>
|
|
||||||
<option value="1*2">1*2 (1x2)</option>
|
|
||||||
<option value="2*2">2*2 (2x2)</option>
|
|
||||||
</select>
|
|
||||||
<button
|
|
||||||
onClick={() => handleRemoveZone(index)}
|
|
||||||
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all"
|
|
||||||
title="삭제"
|
|
||||||
>
|
|
||||||
<Trash2 size={20} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{zones.length === 0 && (
|
|
||||||
<div className="text-center py-12 text-slate-400 border-2 border-dashed border-slate-100 rounded-xl bg-slate-50/50">
|
|
||||||
등록된 구역이 없습니다. 구역을 추가해 주세요.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center pt-6 border-t border-slate-100">
|
|
||||||
<Button variant="secondary" onClick={handleAddZone} icon={<Plus size={18} />}>
|
|
||||||
구역 추가
|
|
||||||
</Button>
|
|
||||||
<Button onClick={handleSave} disabled={loading} icon={<Save size={18} />}>
|
|
||||||
{loading ? '저장 중...' : '설정 저장'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,8 +1,7 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } 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 { Video, LayoutGrid, ChevronDown } from 'lucide-react';
|
import { Plus, Settings, Trash2, X, Video } 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';
|
||||||
@ -21,7 +20,6 @@ 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
|
||||||
@ -40,7 +38,7 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className={`relative bg-[#0f172a] rounded-xl overflow-hidden border shadow-sm ${disabled ? 'border-slate-200' : 'border-slate-200 hover:border-indigo-500'} transition-all group h-full w-full`}>
|
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="bg-white rounded-xl shadow-lg overflow-hidden border border-slate-200">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -49,12 +47,22 @@ 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 [activeZone, setActiveZone] = useState<string>('');
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [availableZones, setAvailableZones] = useState<{ name: string, layout: string }[]>([]);
|
const [editingCamera, setEditingCamera] = useState<Camera | null>(null);
|
||||||
const [showLayoutMenu, setShowLayoutMenu] = useState(false);
|
const [formData, setFormData] = useState<Partial<Camera>>({
|
||||||
const fetchedRef = useRef(false);
|
name: '',
|
||||||
|
ip_address: '',
|
||||||
|
port: 554,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
stream_path: '/stream1',
|
||||||
|
transport_mode: 'tcp',
|
||||||
|
rtsp_encoding: false,
|
||||||
|
quality: 'low'
|
||||||
|
});
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const [streamVersions] = useState<{ [key: number]: number }>({});
|
const [streamVersions, setStreamVersions] = useState<{ [key: number]: number }>({});
|
||||||
|
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor),
|
useSensor(PointerSensor),
|
||||||
@ -64,8 +72,6 @@ export function MonitoringPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fetchedRef.current) return;
|
|
||||||
fetchedRef.current = true;
|
|
||||||
fetchCameras();
|
fetchCameras();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -78,49 +84,61 @@ export function MonitoringPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchZones = async () => {
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
try {
|
const { name, value } = e.target;
|
||||||
const res = await apiClient.get('/cameras/zones');
|
setFormData(prev => ({ ...prev, [name]: value }));
|
||||||
const zones = res.data.map((z: any) => ({
|
};
|
||||||
name: z.name,
|
|
||||||
layout: z.layout || '2*2'
|
|
||||||
}));
|
|
||||||
setAvailableZones(zones);
|
|
||||||
|
|
||||||
// Default to the first available zone to show video immediately
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
if (!activeZone && zones.length > 0) {
|
e.preventDefault();
|
||||||
setActiveZone(zones[0].name);
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (editingCamera) {
|
||||||
|
await apiClient.put(`/cameras/${editingCamera.id}`, formData);
|
||||||
|
// Force stream refresh for this camera
|
||||||
|
setStreamVersions(prev => ({ ...prev, [editingCamera.id]: Date.now() }));
|
||||||
|
} else {
|
||||||
|
await apiClient.post('/cameras', formData);
|
||||||
}
|
}
|
||||||
|
setShowForm(false);
|
||||||
|
setEditingCamera(null);
|
||||||
|
setFormData({
|
||||||
|
name: '',
|
||||||
|
ip_address: '',
|
||||||
|
port: 554,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
stream_path: '/stream1',
|
||||||
|
transport_mode: 'tcp',
|
||||||
|
rtsp_encoding: false,
|
||||||
|
quality: 'low'
|
||||||
|
});
|
||||||
|
fetchCameras();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch zones', err);
|
console.error('Failed to save camera', err);
|
||||||
|
const errMsg = (err as any).response?.data?.error || (err as any).message || '카메라 저장 실패';
|
||||||
|
alert(`오류 발생: ${errMsg}`);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Determine layout based on active zone's setting
|
const handleEdit = (camera: Camera) => {
|
||||||
const currentZoneConfig = availableZones.find(z => z.name === activeZone);
|
setEditingCamera(camera);
|
||||||
const viewLayout = currentZoneConfig ? currentZoneConfig.layout : '2*2';
|
setFormData(camera);
|
||||||
|
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.patch(`/cameras/zones/${activeZone}/layout`, { layout: newLayout });
|
await apiClient.delete(`/cameras/${id}`);
|
||||||
|
fetchCameras();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to update layout', err);
|
console.error('Failed to delete camera', 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;
|
||||||
@ -208,186 +226,185 @@ export function MonitoringPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchCameras();
|
fetchCameras();
|
||||||
fetchZones();
|
|
||||||
checkServerVersion();
|
checkServerVersion();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
const HeaderDropdown = () => {
|
|
||||||
const portalRoot = document.getElementById('header-portal-root');
|
|
||||||
if (!portalRoot) return null;
|
|
||||||
|
|
||||||
// Tabs: [Zones...]
|
|
||||||
const zoneTabs = availableZones.map(z => z.name);
|
|
||||||
const layoutOptions = [
|
|
||||||
{ id: '1', label: '1개', icon: <div className="w-4 h-4 border border-white/40" /> },
|
|
||||||
{ id: '1*2', label: '1*2', icon: <div className="w-4 h-4 border border-white/40 flex"><div className="w-1/2 border-r border-white/40" /></div> },
|
|
||||||
{ id: '2*2', label: '2*2', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-2 grid-rows-2"><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /><div className="border-r border-white/40" /></div> },
|
|
||||||
{ id: '3*3', label: '3*3', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-3 grid-rows-3"><div className="border-r border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-r border-b border-white/40" /><div className="border-b border-white/40" /></div> },
|
|
||||||
{ id: '4*4', label: '4*4', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-4 grid-rows-4" /> },
|
|
||||||
{ id: '5*5', label: '5*5', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-5 grid-rows-5" /> },
|
|
||||||
{ id: '6*6', label: '6*6', icon: <div className="w-4 h-4 border border-white/40 grid grid-cols-6 grid-rows-6" /> }
|
|
||||||
];
|
|
||||||
|
|
||||||
return createPortal(
|
|
||||||
<div className="flex items-center w-full h-full pointer-events-none">
|
|
||||||
{/* Left Area: Zone Tabs */}
|
|
||||||
<div className="flex gap-1 h-full items-end pb-1 overflow-x-auto scrollbar-hide pointer-events-auto border-l border-slate-200 ml-4 pl-4">
|
|
||||||
{zoneTabs.map(name => (
|
|
||||||
<button
|
|
||||||
key={name}
|
|
||||||
onClick={() => setActiveZone(name)}
|
|
||||||
className={`px-4 py-2 text-[13px] font-bold transition-all border-b-2 whitespace-nowrap ${activeZone === name
|
|
||||||
? 'text-indigo-600 border-indigo-600 bg-indigo-50/20'
|
|
||||||
: 'text-slate-400 border-transparent hover:text-slate-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Spacer to push everything else to the right */}
|
|
||||||
<div className="flex-1" />
|
|
||||||
|
|
||||||
{/* Right Area: Layout Selector */}
|
|
||||||
<div className="px-3 pointer-events-auto flex items-center">
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowLayoutMenu(!showLayoutMenu)}
|
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border ${showLayoutMenu ? 'bg-indigo-600 text-white border-indigo-600' : 'bg-slate-50 text-slate-600 border-slate-200 hover:border-indigo-400'} ${!activeZone ? 'opacity-50 grayscale cursor-not-allowed' : ''}`}
|
|
||||||
disabled={!activeZone}
|
|
||||||
>
|
|
||||||
<LayoutGrid size={14} />
|
|
||||||
레이아웃: {!activeZone ? '--' : viewLayout}
|
|
||||||
<ChevronDown size={14} className={`transition-transform duration-200 ${showLayoutMenu ? 'rotate-180' : ''}`} />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{showLayoutMenu && activeZone && (
|
|
||||||
<div className="absolute right-0 top-full mt-2 w-48 bg-[#1a1a1a] rounded-xl shadow-2xl border border-white/10 p-2 z-[100] animate-in fade-in zoom-in duration-200">
|
|
||||||
<div className="px-2 py-1.5 text-[9px] font-bold text-white/30 uppercase tracking-[0.2em] border-b border-white/5 mb-1">
|
|
||||||
Monitor Arrangement
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 gap-1">
|
|
||||||
{layoutOptions.map(opt => (
|
|
||||||
<button
|
|
||||||
key={opt.id}
|
|
||||||
onClick={() => handleLayoutChange(opt.id)}
|
|
||||||
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-[11px] font-medium transition-all ${viewLayout === opt.id ? 'bg-indigo-600 text-white' : 'text-white/60 hover:bg-white/5 hover:text-white'}`}
|
|
||||||
>
|
|
||||||
<div className="w-4 flex justify-center opacity-60">{opt.icon}</div>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>,
|
|
||||||
portalRoot
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const getLayoutInfo = () => {
|
|
||||||
if (viewLayout === '1') return { cols: 'grid-cols-1', total: 1 };
|
|
||||||
if (viewLayout === '1*2') return { cols: 'grid-cols-2', total: 2 };
|
|
||||||
|
|
||||||
// Multi-grid like 2*2, 3*3, 4*4
|
|
||||||
const parts = viewLayout.split('*');
|
|
||||||
if (parts.length === 2) {
|
|
||||||
const n = parseInt(parts[0]);
|
|
||||||
return { cols: `grid-cols-${n}`, total: n * n };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { cols: 'grid-cols-2', total: 4 };
|
|
||||||
};
|
|
||||||
|
|
||||||
const layoutInfo = getLayoutInfo();
|
|
||||||
const totalSlots = Math.max(layoutInfo.total, filteredCameras.length);
|
|
||||||
// Fill slots: actual cameras + empty placeholders if needed to complete the grid rows
|
|
||||||
const slots = Array.from({ length: totalSlots }).map((_, i) => filteredCameras[i] || null);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full bg-[#f1f5f9] flex flex-col min-h-0">
|
<div className="p-6">
|
||||||
<HeaderDropdown />
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||||
<div className="flex-1 p-2 overflow-hidden flex flex-col min-h-0">
|
<Video className="text-blue-600" />
|
||||||
<DndContext
|
CCTV <span className="text-xs text-slate-400 font-normal">({user?.role})</span>
|
||||||
sensors={sensors}
|
</h1>
|
||||||
collisionDetection={closestCenter}
|
{user?.role === 'admin' && (
|
||||||
onDragEnd={handleDragEnd}
|
<button
|
||||||
>
|
onClick={() => {
|
||||||
<SortableContext
|
setEditingCamera(null);
|
||||||
items={filteredCameras.map(c => c.id)}
|
setFormData({
|
||||||
strategy={rectSortingStrategy}
|
name: '',
|
||||||
disabled={true}
|
ip_address: '',
|
||||||
|
port: 554,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
stream_path: '/stream1'
|
||||||
|
});
|
||||||
|
setShowForm(true);
|
||||||
|
}}
|
||||||
|
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 transition"
|
||||||
>
|
>
|
||||||
<div className={`grid h-full w-full gap-4 ${layoutInfo.cols} ${totalSlots > layoutInfo.total ? 'overflow-y-auto' : ''}`}
|
<Plus size={18} />
|
||||||
style={{ gridAutoRows: (viewLayout === '1' || viewLayout === '1*2') ? '100%' : `${100 / Math.sqrt(layoutInfo.total)}%` }}>
|
카메라 추가
|
||||||
{slots.map((camera, index) => (
|
</button>
|
||||||
<div key={camera ? camera.id : `empty-${index}`} className="h-full w-full">
|
)}
|
||||||
{camera ? (
|
</div>
|
||||||
<SortableCamera camera={camera} disabled={true}>
|
|
||||||
<div className="relative w-full h-full flex flex-col group text-white/90">
|
|
||||||
{/* VMS Slot Header */}
|
|
||||||
<div className="absolute top-0 left-0 right-0 z-10 bg-black/40 backdrop-blur-sm border-b border-white/5 px-3 py-1.5 flex justify-between items-center opacity-0 group-hover:opacity-100 transition-opacity">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className={`w-2 h-2 rounded-full ${(camera.is_active !== 0 && camera.is_active !== false) ? 'bg-green-500 animate-pulse' : 'bg-red-500'}`}></div>
|
|
||||||
<span className="text-[11px] font-bold truncate max-w-[150px]">{camera.name}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(user?.role === 'admin' || user?.role === 'supervisor') && (
|
{/* Camera Grid with DnD */}
|
||||||
<div className="flex gap-1" onPointerDown={(e) => e.stopPropagation()}>
|
<DndContext
|
||||||
<button
|
sensors={sensors}
|
||||||
onClick={() => handlePing(camera.id)}
|
collisionDetection={closestCenter}
|
||||||
className="p-1 text-white/50 hover:text-green-400 transition-colors"
|
onDragEnd={handleDragEnd}
|
||||||
title="Ping"
|
>
|
||||||
>
|
<SortableContext
|
||||||
<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>
|
items={cameras.map(c => c.id)}
|
||||||
</button>
|
strategy={rectSortingStrategy}
|
||||||
<button
|
>
|
||||||
onClick={() => handleToggleStatus(camera)}
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
|
||||||
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'}`}
|
{cameras.map(camera => (
|
||||||
title={(camera.is_active !== 0 && camera.is_active !== false) ? "Pause" : "Play"}
|
<SortableCamera key={camera.id} camera={camera} disabled={user?.role !== 'admin'}>
|
||||||
>
|
<div className="relative aspect-video bg-black group">
|
||||||
{(camera.is_active !== 0 && camera.is_active !== false) ? (
|
<JSMpegPlayer
|
||||||
<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>
|
key={`${camera.id}-${streamVersions[camera.id] || 0}`}
|
||||||
) : (
|
url={getStreamUrl(camera.id)}
|
||||||
<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>
|
className="w-full h-full"
|
||||||
)}
|
/>
|
||||||
</button>
|
{/* Overlay Controls */}
|
||||||
</div>
|
{user?.role === 'admin' && (
|
||||||
)}
|
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2" onPointerDown={(e) => e.stopPropagation()}>
|
||||||
</div>
|
<button
|
||||||
|
onClick={() => handleToggleStatus(camera)}
|
||||||
{/* Streaming Video */}
|
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-1 bg-[#0f172a] flex items-center justify-center">
|
title={(camera.is_active !== 0 && camera.is_active !== false) ? "스트리밍 중지 (Pause)" : "스트리밍 시작 (Play)"}
|
||||||
<JSMpegPlayer
|
>
|
||||||
key={`${camera.id}-${streamVersions[camera.id] || 0}`}
|
{(camera.is_active !== 0 && camera.is_active !== false) ? (
|
||||||
url={getStreamUrl(camera.id)}
|
<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>
|
||||||
className="w-full h-full object-contain"
|
) : (
|
||||||
/>
|
<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>
|
)}
|
||||||
|
</button>
|
||||||
{/* Small persistent ID or Name overlay */}
|
<button
|
||||||
<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">
|
onClick={() => handlePing(camera.id)}
|
||||||
CH {index + 1} | {camera.ip_address}
|
className="bg-green-600/80 text-white p-2 rounded-full hover:bg-green-700"
|
||||||
</div>
|
title="연결 테스트 (Ping)"
|
||||||
</div>
|
>
|
||||||
</SortableCamera>
|
<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>
|
||||||
<div className="h-full w-full bg-white rounded-xl border border-slate-200 flex flex-col items-center justify-center text-slate-300 select-none shadow-sm">
|
<button
|
||||||
<Video size={32} className="opacity-20 mb-2" />
|
onClick={() => handleEdit(camera)}
|
||||||
<span className="text-[11px] font-bold opacity-40 uppercase tracking-widest">No Buffer</span>
|
className="bg-black/50 text-white p-2 rounded-full hover:bg-black/70"
|
||||||
|
title="설정"
|
||||||
|
>
|
||||||
|
<Settings size={16} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(camera.id)}
|
||||||
|
className="bg-red-500/80 text-white p-2 rounded-full hover:bg-red-600"
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<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>
|
|
||||||
</DndContext>
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
</div>
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">카메라 이름</label>
|
||||||
|
<input type="text" name="name" value={formData.name} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="예: 정문 CCTV" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><label className="block text-sm font-medium text-slate-700 mb-1">IP 주소</label><input type="text" name="ip_address" value={formData.ip_address} onChange={handleInputChange} required className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="192.168.1.100" /></div>
|
||||||
|
<div><label className="block text-sm font-medium text-slate-700 mb-1">포트</label><input type="number" name="port" value={formData.port} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="554" /></div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP 사용자명</label><input type="text" name="username" value={formData.username} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="admin" /></div>
|
||||||
|
<div><label className="block text-sm font-medium text-slate-700 mb-1">RTSP 비밀번호</label><input type="password" name="password" value={formData.password} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" /></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-700 mb-1">스트림 경로</label>
|
||||||
|
<input type="text" name="stream_path" value={formData.stream_path} onChange={handleInputChange} className="w-full border border-slate-300 rounded-lg px-3 py-2 outline-none focus:ring-2 focus:ring-blue-500" placeholder="/stream1" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced Settings */}
|
||||||
|
<div className="bg-slate-50 p-4 rounded-lg border border-slate-200">
|
||||||
|
<h3 className="text-sm font-bold text-slate-700 mb-3 flex items-center gap-2">
|
||||||
|
<Settings size={14} /> 고급 설정
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-600 mb-1">전송 방식 (Transport)</label>
|
||||||
|
<select name="transport_mode" value={formData.transport_mode || 'tcp'} onChange={(e) => setFormData(prev => ({ ...prev, transport_mode: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
<option value="tcp">TCP (권장 - 안정적)</option>
|
||||||
|
<option value="udp">UDP (빠름 - 끊김가능)</option>
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" name="rtsp_encoding" checked={!!formData.rtsp_encoding} onChange={(e) => setFormData(prev => ({ ...prev, rtsp_encoding: e.target.checked }))} className="w-4 h-4 text-blue-600 rounded focus:ring-blue-500" />
|
||||||
|
<span className="text-sm text-slate-600">특수문자 인코딩 사용</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="block text-xs font-medium text-slate-600 mb-1">스트림 화질 (Quality)</label>
|
||||||
|
<select name="quality" value={formData.quality || 'low'} onChange={(e) => setFormData(prev => ({ ...prev, quality: e.target.value as any }))} className="w-full border border-slate-300 rounded-lg px-3 py-2 text-sm outline-none">
|
||||||
|
<option value="low">Low (640px) - 빠르고 안정적 (권장)</option>
|
||||||
|
<option value="medium">Medium (1280px) - HD 화질</option>
|
||||||
|
<option value="original">Original - 원본 화질 (네트워크 부하 큼)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3 mt-6">
|
||||||
|
<button type="button" onClick={() => setShowForm(false)} className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition">취소</button>
|
||||||
|
<button type="submit" disabled={loading} className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition disabled:opacity-50">{loading ? '저장 중...' : '저장하기'}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,30 +0,0 @@
|
|||||||
import { MaterialOverviewPage } from './pages/MaterialOverviewPage';
|
|
||||||
import { MaterialListPage } from './pages/MaterialListPage';
|
|
||||||
import { Package } from 'lucide-react';
|
|
||||||
import type { IModuleDefinition } from '../../core/types';
|
|
||||||
|
|
||||||
export const materialModule: IModuleDefinition = {
|
|
||||||
moduleName: 'material-management',
|
|
||||||
label: '자재/재고 관리',
|
|
||||||
basePath: '/material',
|
|
||||||
description: '공장 소모품 및 원자재 재고 관리 모듈',
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '/overview',
|
|
||||||
element: <MaterialOverviewPage />,
|
|
||||||
label: '자재 현황',
|
|
||||||
icon: <Package size={16} />
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/list',
|
|
||||||
element: <MaterialListPage />,
|
|
||||||
label: '자재 목록'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: '/consumables',
|
|
||||||
element: <MaterialListPage />,
|
|
||||||
label: '소모품 관리'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
requiredRoles: ['admin', 'manager', 'user']
|
|
||||||
};
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
import { Card } from '../../../shared/ui/Card';
|
|
||||||
import { Package, Search, Filter, Plus, Edit2, Trash2 } from 'lucide-react';
|
|
||||||
import { Button } from '../../../shared/ui/Button';
|
|
||||||
|
|
||||||
export function MaterialListPage() {
|
|
||||||
return (
|
|
||||||
<div className="page-container animate-fade-in">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">자재 / 소모품 관리</h1>
|
|
||||||
<p className="text-slate-500 mt-1">공구, 부품, 오일 등 공장 소모품 및 자재 재고를 관리합니다.</p>
|
|
||||||
</div>
|
|
||||||
<Button icon={<Plus size={18} />}>자재 등록</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-4 mb-6 bg-slate-50/50 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
|
|
||||||
type="text"
|
|
||||||
placeholder="자재명, 규격, 제조사 검색..."
|
|
||||||
className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button variant="secondary" icon={<Filter size={18} />}>필터</Button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
|
||||||
{/* Mock Data for Demonstration */}
|
|
||||||
{[
|
|
||||||
{ id: 'MAT-001', name: '절삭유 (Coolant)', spec: '50L drum', stock: 5, unit: 'EA', loc: '자재창고 A', status: 'normal' },
|
|
||||||
{ id: 'MAT-002', name: 'CNC 전용 공구홀더', spec: 'BT40', stock: 12, unit: 'EA', loc: '자재창고 B', status: 'low' },
|
|
||||||
{ id: 'MAT-003', name: '가공용 팁 (Insert)', spec: 'CNMG120408', stock: 2, unit: 'BOX', loc: '현장 캐비닛', status: 'critical' },
|
|
||||||
{ id: 'MAT-004', name: '스핀들 그리스', spec: '200g tube', stock: 8, unit: 'EA', loc: '자재창고 A', status: 'normal' },
|
|
||||||
].map(item => (
|
|
||||||
<Card key={item.id} className="hover:shadow-md transition-all border-slate-200 overflow-hidden">
|
|
||||||
<div className="p-4 border-b border-slate-100 bg-slate-50/30 flex justify-between items-start">
|
|
||||||
<div className="bg-white p-2 rounded-lg border border-slate-200 shadow-sm">
|
|
||||||
<Package className="text-blue-500" size={24} />
|
|
||||||
</div>
|
|
||||||
<div className={`px-2 py-1 rounded text-[10px] font-bold uppercase ${item.status === 'critical' ? 'bg-red-100 text-red-700' :
|
|
||||||
item.status === 'low' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'
|
|
||||||
}`}>
|
|
||||||
{item.status === 'critical' ? '재고 부족' : item.status === 'low' ? '보충 필요' : '정상'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="p-4">
|
|
||||||
<h3 className="font-bold text-slate-800 truncate mb-1" title={item.name}>{item.name}</h3>
|
|
||||||
<p className="text-xs text-slate-500 mb-4">{item.spec}</p>
|
|
||||||
|
|
||||||
<div className="flex justify-between items-center bg-slate-50 p-3 rounded-lg border border-slate-100">
|
|
||||||
<div>
|
|
||||||
<span className="text-[10px] text-slate-400 block uppercase font-bold tracking-tight">현재 재고</span>
|
|
||||||
<span className={`text-xl font-black ${item.status === 'critical' ? 'text-red-600' : 'text-slate-800'
|
|
||||||
}`}>{item.stock}</span>
|
|
||||||
<span className="text-xs text-slate-500 ml-1">{item.unit}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<span className="text-[10px] text-slate-400 block uppercase font-bold tracking-tight">위치</span>
|
|
||||||
<span className="text-xs font-bold text-slate-600">{item.loc}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-4 py-3 bg-slate-50/50 border-t border-slate-100 flex justify-end gap-1">
|
|
||||||
<button className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"><Edit2 size={14} /></button>
|
|
||||||
<button className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"><Trash2 size={14} /></button>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { Card } from '../../../shared/ui/Card';
|
|
||||||
import { Package, ArrowRightLeft, AlertTriangle, BarChart3 } from 'lucide-react';
|
|
||||||
|
|
||||||
export function MaterialOverviewPage() {
|
|
||||||
const stats = [
|
|
||||||
{ label: '전체 품목', value: '1,280', icon: <Package className="text-blue-600" />, change: '+12' },
|
|
||||||
{ label: '입출고 현황', value: '45', icon: <ArrowRightLeft className="text-emerald-600" />, change: '오늘' },
|
|
||||||
{ label: '재고 부족', value: '8', icon: <AlertTriangle className="text-amber-600" />, change: '-2' },
|
|
||||||
{ label: '재고 회전율', value: '85%', icon: <BarChart3 className="text-indigo-600" />, change: '+5%' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">재고 관리 개요</h1>
|
|
||||||
<p className="text-slate-500 mt-1">실시간 자재 및 제품 재고 현황을 확인합니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
||||||
{stats.map((stat, i) => (
|
|
||||||
<Card key={i} className="p-4 border-slate-200 shadow-sm">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="p-2 bg-slate-50 rounded-lg">
|
|
||||||
{stat.icon}
|
|
||||||
</div>
|
|
||||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${stat.change.includes('+') ? 'bg-emerald-50 text-emerald-600' :
|
|
||||||
stat.change.includes('-') ? 'bg-red-50 text-red-600' : 'bg-slate-100 text-slate-600'
|
|
||||||
}`}>
|
|
||||||
{stat.change}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<h3 className="text-sm font-medium text-slate-500">{stat.label}</h3>
|
|
||||||
<p className="text-2xl font-bold text-slate-900 mt-1">{stat.value}</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-12 text-center border-dashed border-2 border-slate-200 bg-slate-50/50">
|
|
||||||
<Package size={48} className="mx-auto text-slate-300 mb-4" />
|
|
||||||
<h3 className="text-lg font-bold text-slate-800">상세 재고 목록 준비 중</h3>
|
|
||||||
<p className="text-slate-500 mt-2">입출고 관리 및 실사 기능을 구현 중입니다.</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -7,5 +7,5 @@ export const productionModule: IModuleDefinition = {
|
|||||||
routes: [
|
routes: [
|
||||||
{ path: '/dashboard', element: <ProductionPage />, label: '생산 현황' },
|
{ path: '/dashboard', element: <ProductionPage />, label: '생산 현황' },
|
||||||
],
|
],
|
||||||
requiredRoles: ['admin', 'manager', 'user']
|
requiredRoles: ['admin', 'manager']
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,19 +0,0 @@
|
|||||||
import { QualityOverviewPage } from './pages/QualityOverviewPage';
|
|
||||||
import { ClipboardCheck } from 'lucide-react';
|
|
||||||
import type { IModuleDefinition } from '../../core/types';
|
|
||||||
|
|
||||||
export const qualityModule: IModuleDefinition = {
|
|
||||||
moduleName: 'quality-management',
|
|
||||||
label: '품질 관리',
|
|
||||||
basePath: '/quality',
|
|
||||||
description: '공정 및 제품 품질 관리 모듈',
|
|
||||||
routes: [
|
|
||||||
{
|
|
||||||
path: '/overview',
|
|
||||||
element: <QualityOverviewPage />,
|
|
||||||
label: '품질 현황',
|
|
||||||
icon: <ClipboardCheck size={16} />
|
|
||||||
}
|
|
||||||
],
|
|
||||||
requiredRoles: ['admin', 'manager', 'user']
|
|
||||||
};
|
|
||||||
@ -1,49 +0,0 @@
|
|||||||
import { Card } from '../../../shared/ui/Card';
|
|
||||||
import { ClipboardCheck, ShieldAlert, CheckCircle2, Factory } from 'lucide-react';
|
|
||||||
|
|
||||||
export function QualityOverviewPage() {
|
|
||||||
const stats = [
|
|
||||||
{ label: '금일 검사수', value: '342', icon: <ClipboardCheck className="text-blue-600" />, status: '정상' },
|
|
||||||
{ label: '불량 발생', value: '3', icon: <ShieldAlert className="text-red-600" />, status: '-1.2%' },
|
|
||||||
{ label: '합격률', value: '99.1%', icon: <CheckCircle2 className="text-emerald-600" />, status: '+0.5%' },
|
|
||||||
{ label: '공정 능력(Cpk)', value: '1.67', icon: <Factory className="text-indigo-600" />, status: '안정' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="p-6">
|
|
||||||
<div className="flex justify-between items-center mb-6">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">품질 관리 개요</h1>
|
|
||||||
<p className="text-slate-500 mt-1">제품 품질 검사 현황 및 공정 통계를 모니터링합니다.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
||||||
{stats.map((stat, i) => (
|
|
||||||
<Card key={i} className="p-4 border-slate-200 shadow-sm">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="p-2 bg-slate-50 rounded-lg">
|
|
||||||
{stat.icon}
|
|
||||||
</div>
|
|
||||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${stat.status.includes('+') ? 'bg-emerald-50 text-emerald-600' :
|
|
||||||
stat.status.includes('-') ? 'bg-red-50 text-red-600' : 'bg-blue-50 text-blue-600'
|
|
||||||
}`}>
|
|
||||||
{stat.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="mt-4">
|
|
||||||
<h3 className="text-sm font-medium text-slate-500">{stat.label}</h3>
|
|
||||||
<p className="text-2xl font-bold text-slate-900 mt-1">{stat.value}</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Card className="p-12 text-center border-dashed border-2 border-slate-200 bg-slate-50/50">
|
|
||||||
<ClipboardCheck size={48} className="mx-auto text-slate-300 mb-4" />
|
|
||||||
<h3 className="text-lg font-bold text-slate-800">품질 검사 수순 준비 중</h3>
|
|
||||||
<p className="text-slate-500 mt-2">수입/공정/출하 검사 성적서 기능을 구현 중입니다.</p>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -4,8 +4,8 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
background-color: var(--sokuree-bg-main);
|
background-color: var(--color-bg-base);
|
||||||
background: linear-gradient(135deg, var(--sokuree-bg-sidebar) 0%, var(--sokuree-brand-primary) 100%);
|
background: linear-gradient(135deg, var(--color-bg-sidebar) 0%, var(--color-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(--sokuree-radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-lg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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(--sokuree-bg-sidebar);
|
background-color: var(--color-bg-sidebar);
|
||||||
color: var(--sokuree-text-inverse);
|
color: var(--color-text-inverse);
|
||||||
border-radius: var(--sokuree-radius-md);
|
border-radius: var(--radius-md);
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header h1 {
|
.login-header h1 {
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--sokuree-text-primary);
|
color: var(--color-text-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-header p {
|
.login-header p {
|
||||||
color: var(--sokuree-text-secondary);
|
color: var(--color-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(--sokuree-text-primary);
|
color: var(--color-text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-wrapper {
|
.input-wrapper {
|
||||||
@ -72,33 +72,33 @@
|
|||||||
.input-icon {
|
.input-icon {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 1rem;
|
left: 1rem;
|
||||||
color: var(--sokuree-text-secondary);
|
color: var(--color-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(--sokuree-border-color);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--sokuree-radius-md);
|
border-radius: var(--radius-md);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
transition: all 0.2s;
|
transition: all 0.2s;
|
||||||
background-color: var(--sokuree-bg-card);
|
background-color: var(--color-bg-surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input:focus {
|
.form-group input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--sokuree-brand-primary);
|
border-color: var(--color-brand-primary);
|
||||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
|
box-shadow: 0 0 0 3px rgba(82, 109, 130, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn {
|
.login-btn {
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
padding: 0.875rem;
|
padding: 0.875rem;
|
||||||
background-color: var(--sokuree-bg-sidebar);
|
background-color: var(--color-bg-sidebar);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-radius: var(--sokuree-radius-md);
|
border-radius: var(--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: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
box-shadow: var(--shadow-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.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(--sokuree-radius-sm);
|
border-radius: var(--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(--sokuree-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
}
|
}
|
||||||
@ -13,13 +13,6 @@ 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();
|
||||||
@ -61,8 +54,6 @@ 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>
|
||||||
@ -79,8 +70,6 @@ export function LoginPage() {
|
|||||||
placeholder="비밀번호를 입력하세요"
|
placeholder="비밀번호를 입력하세요"
|
||||||
required
|
required
|
||||||
autoComplete="new-password"
|
autoComplete="new-password"
|
||||||
readOnly
|
|
||||||
onFocus={(e) => e.target.readOnly = false}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { AuthProvider, useAuth } from '../shared/auth/AuthContext';
|
import { AuthProvider } from '../shared/auth/AuthContext';
|
||||||
import { SystemProvider } from '../shared/context/SystemContext';
|
import { 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';
|
||||||
@ -9,33 +9,16 @@ import { LoginPage } from '../pages/auth/LoginPage';
|
|||||||
import { assetModule } from '../modules/asset/module';
|
import { assetModule } from '../modules/asset/module';
|
||||||
import { cctvModule } from '../modules/cctv/module';
|
import { cctvModule } from '../modules/cctv/module';
|
||||||
import { productionModule } from '../modules/production/module';
|
import { productionModule } from '../modules/production/module';
|
||||||
import { materialModule } from '../modules/material/module';
|
|
||||||
import { qualityModule } from '../modules/quality/module';
|
|
||||||
import ModuleLoader from './ModuleLoader';
|
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 = [
|
const modules = [assetModule, cctvModule, productionModule];
|
||||||
assetModule,
|
|
||||||
cctvModule,
|
|
||||||
productionModule,
|
|
||||||
materialModule,
|
|
||||||
qualityModule
|
|
||||||
];
|
|
||||||
|
|
||||||
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 (
|
||||||
@ -52,24 +35,17 @@ export const App = () => {
|
|||||||
<MainLayout modulesList={modules} />
|
<MainLayout modulesList={modules} />
|
||||||
</AuthGuard>
|
</AuthGuard>
|
||||||
}>
|
}>
|
||||||
{/* Home / Platform Introduction */}
|
{/* Dynamic Module Routes */}
|
||||||
<Route path="/home" element={<LandingPage />} />
|
<Route path="/*" element={<ModuleLoader modules={modules} />} />
|
||||||
|
|
||||||
{/* User Preferences (Accessible by everyone authenticated) */}
|
{/* Navigation Fallback within Layout */}
|
||||||
<Route path="/preferences" element={<GeneralPreferencesPage />} />
|
<Route index element={<Navigate to="/asset/dashboard" replace />} />
|
||||||
|
|
||||||
{/* 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 />} />
|
||||||
|
|
||||||
{/* Dynamic Module Routes */}
|
|
||||||
<Route path="/*" element={<ModuleLoader modules={modules} />} />
|
|
||||||
|
|
||||||
{/* Navigation Fallback within Layout */}
|
|
||||||
<Route index element={<DefaultRedirect />} />
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
{/* Fallback */}
|
{/* Fallback */}
|
||||||
|
|||||||
@ -33,12 +33,9 @@ export const ModuleLoader = ({ modules }: ModuleLoaderProps) => {
|
|||||||
|
|
||||||
if (!isActive) return null;
|
if (!isActive) return null;
|
||||||
|
|
||||||
// 2. 권한 체크 (Role 기반)
|
// 2. 沅뚰븳 泥댄겕 (Role 湲곕컲)
|
||||||
if (module.requiredRoles && user) {
|
if (module.requiredRoles && user && !module.requiredRoles.includes(user.role)) {
|
||||||
// supervisor는 모든 권한을 가짐
|
return null;
|
||||||
if (user.role !== 'supervisor' && !module.requiredRoles.includes(user.role)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -1,88 +1,25 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Card } from '../../shared/ui/Card';
|
import { Card } from '../../shared/ui/Card';
|
||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
import { Input } from '../../shared/ui/Input';
|
import { Input } from '../../shared/ui/Input';
|
||||||
import { apiClient } from '../../shared/api/client';
|
import { apiClient } from '../../shared/api/client';
|
||||||
import { useAuth } from '../../shared/auth/AuthContext';
|
import { Save, Clock, Info } from 'lucide-react';
|
||||||
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 { user: currentUser } = useAuth();
|
const [settings, setSettings] = useState({
|
||||||
const [settings, setSettings] = useState<SystemSettings>({
|
session_timeout: 60
|
||||||
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');
|
||||||
const data = res.data;
|
setSettings(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 {
|
||||||
@ -90,476 +27,95 @@ export function BasicSettingsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchEncryptionStatus = async () => {
|
const handleSave = async () => {
|
||||||
try {
|
|
||||||
const res = await apiClient.get('/system/encryption/status');
|
|
||||||
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) {
|
|
||||||
console.error('Failed to fetch encryption status', error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
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);
|
setSaving(true);
|
||||||
setSaveResults(prev => ({ ...prev, [section]: null }));
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.post('/system/settings', payload);
|
await apiClient.post('/system/settings', settings);
|
||||||
setSaveResults(prev => ({ ...prev, [section]: { success: true, message: successMessage } }));
|
alert('설정이 저장되었습니다.');
|
||||||
setTimeout(() => setSaveResults(prev => ({ ...prev, [section]: null })), 3000);
|
} catch (error) {
|
||||||
if (section !== 'database') fetchSettings();
|
console.error('Save failed', error);
|
||||||
} catch (error: any) {
|
alert('저장 중 오류가 발생했습니다.');
|
||||||
setSaveResults(prev => ({
|
|
||||||
...prev,
|
|
||||||
[section]: { success: false, message: error.response?.data?.error || '저장 중 오류 발생' }
|
|
||||||
}));
|
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (loading) return <div className="p-12 text-center text-slate-500">데이터를 불러오는 중...</div>;
|
if (loading) return <div className="p-6">로딩 중...</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-container p-6 max-w-5xl mx-auto">
|
<div className="page-container p-6 max-w-4xl mx-auto">
|
||||||
<div className="flex justify-between items-center mb-8">
|
<div className="mb-6">
|
||||||
<div>
|
<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>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-8">
|
<div className="space-y-6">
|
||||||
{/* v0.4.1.0 Info: Session Timeout relocation notice */}
|
|
||||||
<Card className="p-4 bg-indigo-50 border-indigo-100 flex items-start gap-3">
|
|
||||||
<Clock className="text-indigo-600 mt-1" size={20} />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-bold text-indigo-900">세션 관리 정책 변경 안내 (v0.4.1.0)</h3>
|
|
||||||
<p className="text-xs text-indigo-700 mt-1 leading-relaxed">
|
|
||||||
보안 강화 및 개인화 설정을 위해 전역 세션 로그아웃 설정이 <strong>개별 사용자 설정</strong>으로 이전되었습니다.<br />
|
|
||||||
좌측 메뉴 하단의 <strong>[기본 설정]</strong> 메뉴 또는 <strong>[사용자 관리]</strong> 메뉴에서 사용자별로 시간을 설정하실 수 있습니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Section 2: Encryption Master Key & Rotation (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-800 flex items-center gap-2">
|
|
||||||
<Key size={20} className="text-red-600" />
|
|
||||||
데이터 암호화 마스터 키 관리
|
|
||||||
</h2>
|
|
||||||
{!isEncryptionUnlocked && (
|
|
||||||
<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 ? (
|
<Card className="p-6">
|
||||||
<div className="animate-in fade-in duration-500">
|
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||||
<div className="p-6 space-y-6">
|
<Clock size={20} className="text-amber-500" />
|
||||||
<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>
|
</h2>
|
||||||
<span className="text-slate-500 font-bold block mb-1">현재 활성 키</span>
|
<div className="space-y-4">
|
||||||
<code className="bg-white p-2 rounded border border-slate-200 block truncate font-mono">{rotationStatus?.currentKey || '-'}</code>
|
<div>
|
||||||
</div>
|
<label className="block text-sm font-medium text-slate-700 mb-1">세션 타임아웃</label>
|
||||||
<div>
|
<div className="flex items-center gap-2 flex-wrap text-sm text-slate-600">
|
||||||
<span className="text-slate-500 font-bold block mb-1">영향 레코드</span>
|
<div className="flex items-center gap-1">
|
||||||
<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
|
<Input
|
||||||
value={settings.encryption_key}
|
type="number"
|
||||||
onChange={e => setSettings({ ...settings, encryption_key: e.target.value })}
|
min="0"
|
||||||
placeholder="새로운 키 입력"
|
max="24"
|
||||||
className="font-mono flex-1"
|
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"
|
||||||
/>
|
/>
|
||||||
<Button variant="secondary" onClick={generateNewKey} icon={<RefreshCcw size={14} />}>자동 생성</Button>
|
<span className="mr-2 whitespace-nowrap text-slate-700 font-medium">시간</span>
|
||||||
</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 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>
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 mt-1 pl-1">
|
||||||
|
( 총 {settings.session_timeout || 10}분 / 최소 5분 ~ 최대 24시간 )
|
||||||
|
</p>
|
||||||
</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>
|
</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>
|
</Card>
|
||||||
|
|
||||||
{/* Section 3: Database Infrastructure (Supervisor Protected) */}
|
<div className="flex justify-end">
|
||||||
<Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
|
<Button
|
||||||
<div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
|
onClick={handleSave}
|
||||||
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
disabled={saving}
|
||||||
<ShieldAlert size={20} className="text-indigo-600" />
|
icon={<Save size={18} />}
|
||||||
데이터베이스 인프라 구성
|
>
|
||||||
</h2>
|
{saving ? '저장 중...' : '설정 저장'}
|
||||||
{!isDbConfigUnlocked && (
|
</Button>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,157 +0,0 @@
|
|||||||
import { useState } from 'react';
|
|
||||||
import { Card } from '../../shared/ui/Card';
|
|
||||||
import { Button } from '../../shared/ui/Button';
|
|
||||||
import { Select } from '../../shared/ui/Select';
|
|
||||||
import { Input } from '../../shared/ui/Input';
|
|
||||||
import { Save, Home, User, Clock, ShieldAlert, RefreshCcw } from 'lucide-react';
|
|
||||||
import { useAuth } from '../../shared/auth/AuthContext';
|
|
||||||
import { apiClient } from '../../shared/api/client';
|
|
||||||
|
|
||||||
export function GeneralPreferencesPage() {
|
|
||||||
const { user } = useAuth();
|
|
||||||
const [preferences, setPreferences] = useState({
|
|
||||||
landingPage: localStorage.getItem(`landing_page_${user?.id}`) || '/home',
|
|
||||||
sessionTimeout: user?.session_timeout || 10
|
|
||||||
});
|
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
|
||||||
const [isSaved, setIsSaved] = useState(false);
|
|
||||||
|
|
||||||
const landingPageOptions = [
|
|
||||||
{ label: '플랫폼 소개 (기본)', value: '/home' },
|
|
||||||
{ label: '자산 대시보드', value: '/asset/dashboard' },
|
|
||||||
{ label: 'CCTV 모니터링', value: '/cctv/monitoring' },
|
|
||||||
{ label: '생산 대시보드', value: '/production/dashboard' }
|
|
||||||
];
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
setIsSaving(true);
|
|
||||||
try {
|
|
||||||
// 1. Save local preferences
|
|
||||||
localStorage.setItem(`landing_page_${user?.id}`, preferences.landingPage);
|
|
||||||
|
|
||||||
// 2. Save server-side preferences (Session Timeout)
|
|
||||||
await apiClient.put('/profile', {
|
|
||||||
session_timeout: preferences.sessionTimeout
|
|
||||||
});
|
|
||||||
|
|
||||||
setIsSaved(true);
|
|
||||||
setTimeout(() => setIsSaved(false), 3000);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save preferences', error);
|
|
||||||
alert('설정 저장 중 오류가 발생했습니다.');
|
|
||||||
} finally {
|
|
||||||
setIsSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="page-container p-6 max-w-4xl mx-auto">
|
|
||||||
<div className="mb-8">
|
|
||||||
<h1 className="text-2xl font-bold text-slate-900">기본 설정</h1>
|
|
||||||
<p className="text-slate-500 mt-1">사용자 개인의 플랫폼 환경을 설정합니다.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 gap-6">
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
|
|
||||||
<Home className="text-blue-600" size={24} />
|
|
||||||
<h2 className="text-lg font-bold">로그인 랜딩 페이지</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-md">
|
|
||||||
<p className="text-sm text-slate-600 mb-4">
|
|
||||||
로그인 시 또는 최상위 메뉴 이동 시 처음으로 표시될 화면을 선택하세요.
|
|
||||||
</p>
|
|
||||||
<Select
|
|
||||||
label="접속 시작 페이지"
|
|
||||||
value={preferences.landingPage}
|
|
||||||
onChange={(e) => setPreferences({ ...preferences, landingPage: e.target.value })}
|
|
||||||
options={landingPageOptions}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
|
|
||||||
<User className="text-indigo-600" size={24} />
|
|
||||||
<h2 className="text-lg font-bold">사용자 정보</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6 text-sm">
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500 block mb-1">사용자 이름</span>
|
|
||||||
<span className="font-bold text-slate-900">{user?.name}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500 block mb-1">계정 등급</span>
|
|
||||||
<span className="font-bold text-indigo-600 capitalize">{user?.role}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500 block mb-1">소속 부서</span>
|
|
||||||
<span className="font-medium text-slate-700">{user?.department || '미지정'}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span className="text-slate-500 block mb-1">현재 세션 만료</span>
|
|
||||||
<span className="font-bold text-blue-600">{user?.session_timeout}분</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="p-6">
|
|
||||||
<div className="flex items-center gap-3 mb-6 border-b border-slate-100 pb-4">
|
|
||||||
<Clock className="text-orange-600" size={24} />
|
|
||||||
<h2 className="text-lg font-bold">보안 및 세션 설정</h2>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="max-w-md">
|
|
||||||
<p className="text-sm text-slate-600 mb-4">
|
|
||||||
활동이 없을 경우 자동으로 로그아웃되는 시간을 설정합니다. (단위: 분)
|
|
||||||
</p>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={preferences.sessionTimeout}
|
|
||||||
onChange={(e) => setPreferences({ ...preferences, sessionTimeout: parseInt(e.target.value) || 0 })}
|
|
||||||
min={5}
|
|
||||||
max={1440}
|
|
||||||
className="!w-32"
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-bold text-slate-700">분 후 자동 로그아웃</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-slate-400 mt-2">
|
|
||||||
* 최소 5분에서 최대 1440분(24시간)까지 설정 가능합니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{user?.role !== 'user' && (
|
|
||||||
<Card className="p-4 bg-blue-50/50 border-blue-100">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<ShieldAlert className="text-blue-500 mt-0.5" size={18} />
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-bold text-blue-900">관리자 안내</h3>
|
|
||||||
<p className="text-xs text-blue-700 mt-1 leading-relaxed">
|
|
||||||
기존 '시스템 관리 - 기본 설정'에 있던 전역 세션 설정은 v0.4.1.0 업데이트로 인해 폐지되었습니다.<br />
|
|
||||||
이제 모든 사용자의 세션 시간은 각 사용자의 <strong>개별 설정</strong> 또는 <strong>사용자 관리</strong> 메뉴에서 개별적으로 관리됩니다.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex items-center justify-between bg-white p-4 rounded-xl border border-slate-200 shadow-sm mt-4">
|
|
||||||
<div className="text-sm">
|
|
||||||
{isSaved && <span className="text-green-600 font-bold animate-pulse">✓ 설정이 저장되었습니다.</span>}
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={isSaving}
|
|
||||||
icon={isSaving ? <RefreshCcw size={18} className="animate-spin" /> : <Save size={18} />}
|
|
||||||
>
|
|
||||||
{isSaving ? '저장 중...' : '설정 저장'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,275 +0,0 @@
|
|||||||
.landing-container {
|
|
||||||
padding: 2rem;
|
|
||||||
max-width: 1400px;
|
|
||||||
margin: 0 auto;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 4rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero Section */
|
|
||||||
.hero-section {
|
|
||||||
display: grid;
|
|
||||||
grid-template-cols: 1.2fr 0.8fr;
|
|
||||||
gap: 4rem;
|
|
||||||
align-items: center;
|
|
||||||
padding: 2rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.premium-badge {
|
|
||||||
background: linear-gradient(90deg, #6366f1, #8b5cf6, #ec4899);
|
|
||||||
color: white;
|
|
||||||
padding: 0.4rem 1rem;
|
|
||||||
border-radius: 99px;
|
|
||||||
font-size: 0.75rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.05em;
|
|
||||||
display: inline-block;
|
|
||||||
box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-title {
|
|
||||||
font-size: 3.5rem;
|
|
||||||
line-height: 1.1;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--sokuree-text-main);
|
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gradient-text {
|
|
||||||
background: linear-gradient(135deg, #2563eb, #7c3aed);
|
|
||||||
-webkit-background-clip: text;
|
|
||||||
background-clip: text;
|
|
||||||
-webkit-text-fill-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-description {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
color: var(--sokuree-text-muted);
|
|
||||||
line-height: 1.6;
|
|
||||||
margin-bottom: 2.5rem;
|
|
||||||
max-width: 600px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-actions .primary-btn {
|
|
||||||
background: #2563eb;
|
|
||||||
color: white;
|
|
||||||
padding: 1rem 2rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
box-shadow: 0 10px 20px rgba(37, 99, 235, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-actions .primary-btn:hover {
|
|
||||||
transform: translateY(-2px);
|
|
||||||
box-shadow: 0 15px 30px rgba(37, 99, 235, 0.3);
|
|
||||||
background: #1d4ed8;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Hero Visual */
|
|
||||||
.hero-visual {
|
|
||||||
position: relative;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.visual-core {
|
|
||||||
width: 280px;
|
|
||||||
height: 280px;
|
|
||||||
background: white;
|
|
||||||
border-radius: 40px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
box-shadow: 0 25px 60px rgba(0, 0, 0, 0.08), inset 0 0 40px rgba(255, 255, 255, 0.8);
|
|
||||||
transform: rotate(-10deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.core-icon {
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.core-glow {
|
|
||||||
position: absolute;
|
|
||||||
width: 300px;
|
|
||||||
height: 300px;
|
|
||||||
background: radial-gradient(circle, rgba(37, 99, 235, 0.15) 0%, transparent 70%);
|
|
||||||
border-radius: 50%;
|
|
||||||
z-index: 1;
|
|
||||||
filter: blur(20px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-card {
|
|
||||||
position: absolute;
|
|
||||||
background: white;
|
|
||||||
padding: 0.75rem 1.25rem;
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.75rem;
|
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05);
|
|
||||||
z-index: 3;
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-card.c1 {
|
|
||||||
top: -20px;
|
|
||||||
right: 40px;
|
|
||||||
animation: float 4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.floating-card.c2 {
|
|
||||||
bottom: 20px;
|
|
||||||
left: 40px;
|
|
||||||
animation: float 5s ease-in-out infinite reverse;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes float {
|
|
||||||
|
|
||||||
0%,
|
|
||||||
100% {
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
50% {
|
|
||||||
transform: translateY(-15px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Features Grid */
|
|
||||||
.features-grid {
|
|
||||||
display: grid;
|
|
||||||
grid-template-cols: repeat(4, 1fr);
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card {
|
|
||||||
padding: 2rem;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-card:hover {
|
|
||||||
transform: translateY(-8px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-icon-box {
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
background: var(--sokuree-bg-light);
|
|
||||||
border-radius: 12px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
margin-bottom: 1.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-title {
|
|
||||||
font-size: 1.15rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--sokuree-text-main);
|
|
||||||
margin-bottom: 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-desc {
|
|
||||||
font-size: 0.9rem;
|
|
||||||
color: var(--sokuree-text-muted);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Quick Access */
|
|
||||||
.quick-access {
|
|
||||||
background: var(--sokuree-bg-light);
|
|
||||||
padding: 3rem;
|
|
||||||
border-radius: 24px;
|
|
||||||
border: 1px solid rgba(0, 0, 0, 0.03);
|
|
||||||
}
|
|
||||||
|
|
||||||
.section-title {
|
|
||||||
font-size: 1.75rem;
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--sokuree-text-main);
|
|
||||||
margin-bottom: 2rem;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.access-cards {
|
|
||||||
display: grid;
|
|
||||||
grid-template-cols: repeat(3, 1fr);
|
|
||||||
gap: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.access-card {
|
|
||||||
background: white;
|
|
||||||
padding: 1.75rem;
|
|
||||||
border-radius: 16px;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
border: 1px solid transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.access-card:hover {
|
|
||||||
border-color: #2563eb;
|
|
||||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
.access-card:hover .access-arrow {
|
|
||||||
transform: translateX(4px);
|
|
||||||
color: #2563eb;
|
|
||||||
}
|
|
||||||
|
|
||||||
.access-info h4 {
|
|
||||||
font-size: 1.1rem;
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.access-info p {
|
|
||||||
font-size: 0.85rem;
|
|
||||||
color: var(--sokuree-text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.access-arrow {
|
|
||||||
color: #cbd5e1;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
.features-grid {
|
|
||||||
grid-template-cols: repeat(2, 1fr);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-section {
|
|
||||||
grid-template-cols: 1fr;
|
|
||||||
gap: 2rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-visual {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.access-cards {
|
|
||||||
grid-template-cols: 1fr;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-title {
|
|
||||||
font-size: 2.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,118 +0,0 @@
|
|||||||
import { Card } from '../../shared/ui/Card';
|
|
||||||
import { Box, CheckCircle2, Shield, Zap, BarChart3, Globe, ArrowRight } from 'lucide-react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
|
||||||
import './LandingPage.css';
|
|
||||||
|
|
||||||
export function LandingPage() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const features = [
|
|
||||||
{
|
|
||||||
icon: <Shield className="text-blue-500" size={24} />,
|
|
||||||
title: "보안 인프라 관리",
|
|
||||||
description: "강력한 암호화 엔진과 권한 계층 구조를 통해 기업의 핵심 자산을 안전하게 보호합니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Zap className="text-amber-500" size={24} />,
|
|
||||||
title: "실시간 모니터링",
|
|
||||||
description: "CCTV 연동 및 자산 상태 추적을 통해 현장의 모든 상황을 실시간으로 파악합니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <BarChart3 className="text-emerald-500" size={24} />,
|
|
||||||
title: "데이터 시각화",
|
|
||||||
description: "복잡한 자산 및 정비 이력을 직관적인 대시보드와 보고서로 변환하여 의사결정을 지원합니다."
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: <Globe className="text-indigo-500" size={24} />,
|
|
||||||
title: "통합 제어 플랫폼",
|
|
||||||
description: "자산, 생산, 보안을 하나의 플랫폼에서 통합 제어하여 운영 효율성을 극대화합니다."
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="landing-container animate-fade-in">
|
|
||||||
{/* Hero Section */}
|
|
||||||
<div className="hero-section">
|
|
||||||
<div className="hero-content">
|
|
||||||
<div className="badge-wrapper">
|
|
||||||
<span className="premium-badge">Next-Gen Agentic Platform</span>
|
|
||||||
</div>
|
|
||||||
<h1 className="hero-title">
|
|
||||||
SMART IMS <br />
|
|
||||||
<span className="gradient-text">Intelligent Management System</span>
|
|
||||||
</h1>
|
|
||||||
<p className="hero-description">
|
|
||||||
차세대 지능형 통합 관리 솔루션 Smart IMS에 오신 것을 환영합니다. <br />
|
|
||||||
우리는 데이터 중심의 스마트한 의무 자격 및 자산 관리 환경을 제공합니다.
|
|
||||||
</p>
|
|
||||||
<div className="hero-actions">
|
|
||||||
<button className="primary-btn" onClick={() => navigate('/asset/dashboard')}>
|
|
||||||
자산 대시보드 바로가기 <ArrowRight size={18} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hero-visual">
|
|
||||||
<div className="floating-card c1">
|
|
||||||
<CheckCircle2 className="text-green-500" size={20} />
|
|
||||||
<span>System Online</span>
|
|
||||||
</div>
|
|
||||||
<div className="floating-card c2">
|
|
||||||
<Zap className="text-amber-500" size={20} />
|
|
||||||
<span>Real-time Sync</span>
|
|
||||||
</div>
|
|
||||||
<div className="visual-core">
|
|
||||||
<Box size={120} className="core-icon" />
|
|
||||||
<div className="core-glow"></div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Features Grid */}
|
|
||||||
<div className="features-grid">
|
|
||||||
{features.map((feature, idx) => (
|
|
||||||
<Card key={idx} className="feature-card">
|
|
||||||
<div className="feature-icon-box">
|
|
||||||
{feature.icon}
|
|
||||||
</div>
|
|
||||||
<h3 className="feature-title">{feature.title}</h3>
|
|
||||||
<p className="feature-desc">{feature.description}</p>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Quick Access Section */}
|
|
||||||
<div className="quick-access">
|
|
||||||
<h2 className="section-title">주요 서비스 바로가기</h2>
|
|
||||||
<div className="access-cards">
|
|
||||||
<div className="access-card" onClick={() => navigate('/asset/list')}>
|
|
||||||
<div className="access-info">
|
|
||||||
<h4>자산 인벤토리</h4>
|
|
||||||
<p>전체 자산 현황 조회 및 등록</p>
|
|
||||||
</div>
|
|
||||||
<div className="access-arrow">
|
|
||||||
<ArrowRight size={20} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="access-card" onClick={() => navigate('/cctv/monitoring')}>
|
|
||||||
<div className="access-info">
|
|
||||||
<h4>CCTV 모니터링</h4>
|
|
||||||
<p>실시간 스트리밍 및 보안 감시</p>
|
|
||||||
</div>
|
|
||||||
<div className="access-arrow">
|
|
||||||
<ArrowRight size={20} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="access-card" onClick={() => navigate('/production/dashboard')}>
|
|
||||||
<div className="access-info">
|
|
||||||
<h4>생산 현황</h4>
|
|
||||||
<p>실시간 생산실적 및 가동률 확인</p>
|
|
||||||
</div>
|
|
||||||
<div className="access-arrow">
|
|
||||||
<ArrowRight size={20} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,12 +1,11 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Card } from '../../shared/ui/Card';
|
import { Card } from '../../shared/ui/Card';
|
||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
import { Input } from '../../shared/ui/Input';
|
import { Input } from '../../shared/ui/Input';
|
||||||
import { apiClient } from '../../shared/api/client';
|
import { apiClient } from '../../shared/api/client';
|
||||||
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw, Printer } from 'lucide-react';
|
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon } from 'lucide-react';
|
||||||
import type { User } from '../../shared/auth/AuthContext';
|
import type { User } from '../../shared/auth/AuthContext';
|
||||||
import { useAuth } from '../../shared/auth/AuthContext';
|
import './UserManagementPage.css'; // Will create CSS separately or inline styles initially. Let's assume global css or create specific.
|
||||||
import './UserManagementPage.css';
|
|
||||||
|
|
||||||
interface UserFormData {
|
interface UserFormData {
|
||||||
id: string;
|
id: string;
|
||||||
@ -15,16 +14,12 @@ interface UserFormData {
|
|||||||
department: string;
|
department: string;
|
||||||
position: string;
|
position: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
role: 'supervisor' | 'admin' | 'user';
|
role: 'admin' | 'user';
|
||||||
session_timeout: number;
|
|
||||||
allowed_modules: string[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
@ -36,14 +31,10 @@ export function UserManagementPage() {
|
|||||||
department: '',
|
department: '',
|
||||||
position: '',
|
position: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
role: 'user',
|
role: 'user'
|
||||||
session_timeout: 10,
|
|
||||||
allowed_modules: ['asset', 'production', 'cctv']
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (fetchedRef.current) return;
|
|
||||||
fetchedRef.current = true;
|
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -51,7 +42,7 @@ export function UserManagementPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await apiClient.get('/users');
|
const res = await apiClient.get('/users');
|
||||||
setUsers(Array.isArray(res.data) ? res.data : []);
|
setUsers(res.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch users', error);
|
console.error('Failed to fetch users', error);
|
||||||
alert('사용자 목록을 불러오지 못했습니다.');
|
alert('사용자 목록을 불러오지 못했습니다.');
|
||||||
@ -68,34 +59,28 @@ export function UserManagementPage() {
|
|||||||
department: '',
|
department: '',
|
||||||
position: '',
|
position: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
role: 'user',
|
role: 'user'
|
||||||
session_timeout: 10,
|
|
||||||
allowed_modules: ['asset', 'production', 'cctv']
|
|
||||||
});
|
});
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpenEdit = (user: any) => {
|
const handleOpenEdit = (user: User) => {
|
||||||
const u = user || {};
|
|
||||||
setFormData({
|
setFormData({
|
||||||
id: u.id || '',
|
id: user.id,
|
||||||
password: '',
|
password: '', // Password empty by default on edit
|
||||||
name: u.name || '',
|
name: user.name,
|
||||||
department: u.department || '',
|
department: user.department || '',
|
||||||
position: u.position || '',
|
position: user.position || '',
|
||||||
phone: u.phone || '',
|
phone: user.phone || '',
|
||||||
role: u.role || 'user',
|
role: user.role
|
||||||
session_timeout: u.session_timeout || 10,
|
|
||||||
allowed_modules: Array.isArray(u.allowed_modules) ? u.allowed_modules :
|
|
||||||
(typeof u.allowed_modules === 'string' && u.allowed_modules.startsWith('[') ? JSON.parse(u.allowed_modules) : ['asset', 'production', 'cctv'])
|
|
||||||
});
|
});
|
||||||
setIsEditing(true);
|
setIsEditing(true);
|
||||||
setIsModalOpen(true);
|
setIsModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
if (!id || !confirm('정말 이 사용자를 삭제하시겠습니까?')) return;
|
if (!confirm('정말 이 사용자를 삭제하시겠습니까?')) return;
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(`/users/${id}`);
|
await apiClient.delete(`/users/${id}`);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
@ -107,32 +92,28 @@ 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) return cleaned;
|
if (cleaned.length <= 3) {
|
||||||
else if (cleaned.length <= 7) return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
|
return cleaned;
|
||||||
else return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
|
} else if (cleaned.length <= 7) {
|
||||||
};
|
return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
|
||||||
|
} else {
|
||||||
const handleModuleToggle = (moduleCode: string) => {
|
return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
|
||||||
setFormData(prev => {
|
}
|
||||||
const currentModules = Array.isArray(prev.allowed_modules) ? prev.allowed_modules : [];
|
|
||||||
const isSelected = currentModules.includes(moduleCode);
|
|
||||||
if (isSelected) {
|
|
||||||
return { ...prev, allowed_modules: currentModules.filter(m => m !== moduleCode) };
|
|
||||||
} else {
|
|
||||||
return { ...prev, allowed_modules: [...currentModules, moduleCode] };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
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;
|
if (!payload.password) delete payload.password; // Don't send empty 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('등록되었습니다.');
|
||||||
@ -140,30 +121,9 @@ export function UserManagementPage() {
|
|||||||
setIsModalOpen(false);
|
setIsModalOpen(false);
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
alert(`오류: ${error.response?.data?.error || error.message}`);
|
console.error('Submit failed', error);
|
||||||
}
|
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -174,115 +134,57 @@ 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>
|
||||||
<div className="flex gap-2">
|
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}>사용자 등록</Button>
|
||||||
<Button variant="secondary" onClick={() => window.print()} icon={<Printer size={16} />}>출력</Button>
|
|
||||||
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}>사용자 등록</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="overflow-hidden shadow-sm border-slate-200">
|
<Card className="overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm text-left">
|
<table className="w-full text-sm text-left text-slate-600">
|
||||||
<thead className="text-xs text-slate-500 uppercase bg-slate-50/80 border-b border-slate-200">
|
<thead className="text-xs text-slate-700 uppercase bg-slate-50 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">마지막 접속</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 bg-white">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{loading ? (
|
{users.map((user) => (
|
||||||
<tr>
|
<tr key={user.id} className="hover:bg-slate-50 transition-colors">
|
||||||
<td colSpan={8} className="px-6 py-12 text-center text-slate-400">
|
<td className="px-6 py-4">
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="font-medium text-slate-900">{user.id}</div>
|
||||||
<RefreshCcw size={24} className="animate-spin text-indigo-500" />
|
<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'
|
||||||
<span>사용자 데이터를 불러오는 중...</span>
|
}`}>
|
||||||
|
{user.role === 'admin' ? <Shield size={10} className="mr-1" /> : <UserIcon size={10} className="mr-1" />}
|
||||||
|
{user.role === 'admin' ? '관리자' : '사용자'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4 font-medium">{user.name}</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="text-slate-900">{user.department || '-'}</div>
|
||||||
|
<div className="text-slate-500 text-xs">{user.position}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">{user.phone || '-'}</td>
|
||||||
|
<td className="px-6 py-4 text-slate-500">
|
||||||
|
{user.last_login ? new Date(user.last_login).toLocaleString() : '접속 기록 없음'}
|
||||||
|
</td>
|
||||||
|
<td className="px-6 py-4">
|
||||||
|
<div className="flex justify-center gap-2">
|
||||||
|
<button className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded" onClick={() => handleOpenEdit(user)} title="수정">
|
||||||
|
<Edit2 size={16} />
|
||||||
|
</button>
|
||||||
|
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded" onClick={() => handleDelete(user.id)} title="삭제">
|
||||||
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (Array.isArray(users) ? users : []).map((u: any) => {
|
))}
|
||||||
if (!u || !u.id) return null;
|
{users.length === 0 && !loading && (
|
||||||
|
|
||||||
// Defensive parsing for modules
|
|
||||||
let userModules: string[] = [];
|
|
||||||
try {
|
|
||||||
const rawMods = u.allowed_modules;
|
|
||||||
if (Array.isArray(rawMods)) {
|
|
||||||
userModules = rawMods.filter(m => typeof m === 'string');
|
|
||||||
} else if (typeof rawMods === 'string' && rawMods.trim().startsWith('[')) {
|
|
||||||
const parsed = JSON.parse(rawMods);
|
|
||||||
if (Array.isArray(parsed)) {
|
|
||||||
userModules = parsed.filter(m => typeof m === 'string');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
userModules = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Date formatting
|
|
||||||
let loginStr = '미접속';
|
|
||||||
try {
|
|
||||||
if (u.last_login) {
|
|
||||||
const d = new Date(u.last_login);
|
|
||||||
if (!isNaN(d.getTime()) && d.getFullYear() > 1970) {
|
|
||||||
loginStr = d.toLocaleString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
loginStr = '날짜오류';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<tr key={u.id} className="hover:bg-slate-50/50 transition-colors">
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="font-bold text-slate-900">{u.id}</div>
|
|
||||||
<div className="mt-1">{getRoleBadge(u.role || 'user')}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 font-semibold text-slate-800">{u.name || '-'}</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="text-slate-900 font-bold">{u.department || '-'}</div>
|
|
||||||
<div className="text-slate-600 text-[11px] font-medium">{u.position || '-'}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{userModules.map((m, mIdx) => (
|
|
||||||
<span key={`${u.id}-${m}-${mIdx}`} className="px-1.5 py-0.5 bg-slate-100 text-slate-600 text-[10px] rounded border border-slate-200 font-medium">
|
|
||||||
{m === 'asset' ? '자산' : m === 'production' ? '생산' : m === 'cctv' ? 'CCTV' : m}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{userModules.length === 0 && (
|
|
||||||
<span className="text-slate-300 text-[10px]">권한 없음</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4">
|
|
||||||
<span className="text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded ring-1 ring-indigo-100">{u.session_timeout || 10}</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-6 py-4 text-slate-700 font-medium">{u.phone || '-'}</td>
|
|
||||||
<td className="px-6 py-4 text-slate-400 text-[11px]">
|
|
||||||
{loginStr}
|
|
||||||
</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(u)}>
|
|
||||||
<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(u.id)}>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{!loading && (!Array.isArray(users) || users.length === 0) && (
|
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8} 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>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -292,92 +194,75 @@ export function UserManagementPage() {
|
|||||||
|
|
||||||
{/* Modal */}
|
{/* Modal */}
|
||||||
{isModalOpen && (
|
{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">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||||
<Card className="w-full max-w-lg shadow-2xl border-slate-200 overflow-hidden">
|
<Card className="w-full max-w-md shadow-2xl animate-in fade-in zoom-in duration-200">
|
||||||
<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 bg-white border border-slate-200 rounded-lg p-1">
|
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600">
|
||||||
<X size={18} />
|
<X size={20} />
|
||||||
</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 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.id}
|
||||||
name="user_id_new"
|
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||||
autoComplete="off"
|
disabled={isEditing} // ID cannot be changed on edit
|
||||||
readOnly={!isEditing}
|
placeholder="로그인 아이디"
|
||||||
onFocus={(e) => e.target.readOnly = false}
|
required
|
||||||
value={formData.id}
|
autoComplete="off"
|
||||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
/>
|
||||||
disabled={isEditing}
|
</div>
|
||||||
placeholder="로그인 아이디 입력"
|
|
||||||
required
|
<div>
|
||||||
/>
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
</div>
|
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
||||||
<div>
|
</label>
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">
|
<Input
|
||||||
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
type="password"
|
||||||
</label>
|
value={formData.password}
|
||||||
<Input
|
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||||
name="user_password_new"
|
placeholder={isEditing ? "(변경시에만 입력)" : "비밀번호"}
|
||||||
autoComplete="new-password"
|
required={!isEditing}
|
||||||
readOnly={!isEditing}
|
autoComplete="new-password"
|
||||||
onFocus={(e) => e.target.readOnly = false}
|
/>
|
||||||
type="password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
|
||||||
placeholder={isEditing ? "(변경시에만 입력)" : "초기 비밀번호 입력"}
|
|
||||||
required={!isEditing}
|
|
||||||
/>
|
|
||||||
</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-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">이름 <span className="text-red-500">*</span></label>
|
<label className="block text-sm font-medium text-slate-700 mb-1">이름 <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 })}
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div>
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">권한</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1">권한</label>
|
||||||
<select
|
<select
|
||||||
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"
|
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"
|
||||||
value={formData.role}
|
value={formData.role}
|
||||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'user' | 'admin' })}
|
||||||
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-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">부서</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1">부서</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-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">직위</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1">직위</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 })}
|
||||||
@ -386,58 +271,18 @@ export function UserManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">접근 허용 모듈</label>
|
<label className="block text-sm font-medium text-slate-700 mb-1">핸드폰 번호</label>
|
||||||
<div className="grid grid-cols-3 gap-2 mt-2">
|
<Input
|
||||||
{[
|
value={formData.phone}
|
||||||
{ code: 'asset', label: '자산 관리' },
|
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
|
||||||
{ code: 'production', label: '생산 관리' },
|
placeholder="예: 010-1234-5678"
|
||||||
{ code: 'cctv', label: 'CCTV 관제' }
|
maxLength={13}
|
||||||
].map(mod => {
|
/>
|
||||||
const currentModules = Array.isArray(formData.allowed_modules) ? formData.allowed_modules : [];
|
|
||||||
const isChecked = currentModules.includes(mod.code);
|
|
||||||
return (
|
|
||||||
<label key={mod.code} className={`flex items-center gap-2 p-2 border rounded-lg cursor-pointer transition-all ${isChecked ? 'bg-indigo-50 border-indigo-200 text-indigo-700' : 'bg-slate-50 border-slate-200 text-slate-500'}`}>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500"
|
|
||||||
checked={isChecked}
|
|
||||||
onChange={() => handleModuleToggle(mod.code)}
|
|
||||||
/>
|
|
||||||
<span className="text-sm font-medium">{mod.label}</span>
|
|
||||||
</label>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">핸드폰 번호</label>
|
|
||||||
<Input
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
|
|
||||||
placeholder="010-0000-0000"
|
|
||||||
maxLength={13}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5 flex items-center gap-1">
|
|
||||||
세션 만료 (분) <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
value={formData.session_timeout}
|
|
||||||
onChange={(e) => setFormData({ ...formData, session_timeout: parseInt(e.target.value) || 0 })}
|
|
||||||
min={5}
|
|
||||||
placeholder="기본 10"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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" className="bg-indigo-600" icon={<Check size={16} />}>{isEditing ? '저장' : '전송'}</Button>
|
<Button type="submit" icon={<Check size={16} />}>{isEditing ? '저장' : '등록'}</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -1,397 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -60,7 +60,6 @@ body {
|
|||||||
a {
|
a {
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
button {
|
button {
|
||||||
@ -97,142 +96,3 @@ 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,8 +3,6 @@ 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
|
||||||
@ -18,15 +16,11 @@ 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;
|
||||||
@ -40,10 +34,8 @@ 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 {
|
||||||
@ -77,32 +69,13 @@ export interface Manual {
|
|||||||
|
|
||||||
export const assetApi = {
|
export const assetApi = {
|
||||||
getAll: async (): Promise<Asset[]> => {
|
getAll: async (): Promise<Asset[]> => {
|
||||||
try {
|
const response = await apiClient.get<DBAsset[]>('/assets');
|
||||||
const response = await apiClient.get<DBAsset[]>('/assets');
|
return response.data.map(mapDBToAsset);
|
||||||
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}`);
|
||||||
const asset = mapDBToAsset(response.data);
|
return 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>) => {
|
||||||
@ -157,27 +130,12 @@ 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 || '',
|
||||||
@ -190,13 +148,11 @@ 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; }
|
||||||
|
|||||||
@ -25,18 +25,3 @@ 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);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|||||||
@ -1,15 +1,13 @@
|
|||||||
import { createContext, useContext, useState, type ReactNode, useEffect, useRef } from 'react';
|
import { createContext, useContext, useState, type ReactNode, useEffect } from 'react';
|
||||||
import { apiClient, setCsrfToken } from '../api/client';
|
import { apiClient, setCsrfToken } from '../api/client';
|
||||||
|
|
||||||
export interface User {
|
export interface User {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
role: 'supervisor' | 'admin' | 'user';
|
role: 'admin' | 'user';
|
||||||
department?: string;
|
department?: string;
|
||||||
position?: string;
|
position?: string;
|
||||||
phone?: string;
|
phone?: string;
|
||||||
session_timeout?: number;
|
|
||||||
allowed_modules?: string[];
|
|
||||||
last_login?: string;
|
last_login?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -26,76 +24,25 @@ 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 (isBackground = false) => {
|
const checkSession = async () => {
|
||||||
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) setCsrfToken(response.data.csrfToken);
|
if (response.data.csrfToken) {
|
||||||
if (response.data.sessionTimeout) {
|
setCsrfToken(response.data.csrfToken);
|
||||||
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) => {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { createContext, useContext, useState, useEffect, type ReactNode, useRef } from 'react';
|
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||||
import { apiClient } from '../api/client';
|
import { apiClient } from '../api/client';
|
||||||
import { useAuth } from '../auth/AuthContext';
|
import { useAuth } from '../auth/AuthContext';
|
||||||
|
|
||||||
@ -20,15 +20,15 @@ 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 {
|
||||||
const response = await apiClient.get('/system/modules');
|
const response = await apiClient.get('/system/modules');
|
||||||
console.log('[SystemContext] Modules from server:', response.data.modules);
|
|
||||||
if (response.data.modules) {
|
if (response.data.modules) {
|
||||||
setModules(response.data.modules);
|
setModules(response.data.modules);
|
||||||
} else {
|
} else {
|
||||||
|
// Fallback or assume old format (unsafe with new backend)
|
||||||
|
// Better to assume new format based on my changes
|
||||||
setModules(response.data.modules || response.data);
|
setModules(response.data.modules || response.data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -40,12 +40,8 @@ export function SystemProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isAuthenticated) {
|
if (isAuthenticated) {
|
||||||
// Only fetch if we haven't fetched for this authenticated state yet
|
|
||||||
if (isFetchingRef.current === 'authed') return;
|
|
||||||
isFetchingRef.current = 'authed';
|
|
||||||
refreshModules();
|
refreshModules();
|
||||||
} else {
|
} else {
|
||||||
isFetchingRef.current = 'guest';
|
|
||||||
setModules({});
|
setModules({});
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,49 +0,0 @@
|
|||||||
import { Construction, ArrowRight } from 'lucide-react';
|
|
||||||
|
|
||||||
interface PlaceholderPageProps {
|
|
||||||
title: string;
|
|
||||||
description: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center min-h-[60vh] p-8 text-center animate-in fade-in slide-in-from-bottom-4 duration-700">
|
|
||||||
<div className="w-20 h-20 bg-blue-50 rounded-3xl flex items-center justify-center mb-6 shadow-sm border border-blue-100">
|
|
||||||
<Construction className="text-blue-600 w-10 h-10" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h1 className="text-3xl font-extrabold text-slate-800 mb-4 tracking-tight">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<p className="text-slate-500 max-w-md text-lg leading-relaxed mb-8">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="bg-slate-50 border border-slate-200 rounded-2xl p-6 max-w-lg w-full">
|
|
||||||
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest mb-4">현재 진행 상황</h3>
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]"></div>
|
|
||||||
<span>기본 정적 레이아웃 설계 완료</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-sm text-slate-400">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-slate-300"></div>
|
|
||||||
<span>데이터베이스 스키마 정의 진행 중</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-3 text-sm text-slate-400">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-slate-300"></div>
|
|
||||||
<span>프론트엔드 비즈니스 로직 개발 예정</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => window.history.back()}
|
|
||||||
className="mt-10 flex items-center gap-2 text-blue-600 font-bold hover:gap-3 transition-all group"
|
|
||||||
>
|
|
||||||
이전 페이지로 돌아가기 <ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,10 +1,9 @@
|
|||||||
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, Printer } from 'lucide-react';
|
import { Check, X, Key, Shield, AlertTriangle, Terminal } from 'lucide-react';
|
||||||
|
|
||||||
export function LicensePage() {
|
export function LicensePage() {
|
||||||
// Force Update Check v0.4.3.4
|
|
||||||
const { modules, refreshModules } = useSystem();
|
const { modules, refreshModules } = useSystem();
|
||||||
const [selectedModule, setSelectedModule] = useState<string | null>(null);
|
const [selectedModule, setSelectedModule] = useState<string | null>(null);
|
||||||
const [licenseKey, setLicenseKey] = useState('');
|
const [licenseKey, setLicenseKey] = useState('');
|
||||||
@ -14,9 +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: '생산 계획, 실적 및 공정 관리' },
|
||||||
'cctv': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' },
|
'monitoring': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' }
|
||||||
'material': { title: '자재/재고 관리 모듈', desc: '원자재 입출고 및 실시간 재고 현황 모니터링' },
|
|
||||||
'quality': { title: '품질 관리 모듈', desc: '제품 품질 검사 및 통계 관리' }
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Subscriber Configuration State
|
// Subscriber Configuration State
|
||||||
@ -24,13 +21,6 @@ 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();
|
||||||
@ -96,104 +86,48 @@ 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">
|
||||||
<div className="flex justify-between items-center">
|
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
<Shield className="text-blue-600" />
|
||||||
<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 overflow-hidden">
|
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
|
||||||
<div className="flex justify-between items-center mb-4">
|
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
<Terminal size={20} className="text-slate-600" />
|
||||||
<Terminal size={20} className="text-slate-600" />
|
서버 환경 설정
|
||||||
서버 환경 설정
|
</h3>
|
||||||
</h3>
|
<div className="max-w-xl">
|
||||||
{!isConfigUnlocked && (
|
<div className="flex items-end gap-4">
|
||||||
<button
|
<div className="flex-1">
|
||||||
onClick={handleOpenVerify}
|
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||||
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"
|
서버 구독자 ID (Server Subscriber ID)
|
||||||
>
|
</label>
|
||||||
<Shield size={12} /> 조회 / 변경
|
<input
|
||||||
</button>
|
type="text"
|
||||||
)}
|
className="w-full border border-slate-300 rounded px-3 py-2"
|
||||||
{isConfigUnlocked && (
|
placeholder="예: SAMSUNG, DEMO_USER_01"
|
||||||
<span className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded font-bold flex items-center gap-1">
|
value={inputSubscriberId}
|
||||||
<Shield size={12} /> 최고관리자 권한
|
onChange={(e) => setInputSubscriberId(e.target.value)}
|
||||||
</span>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isConfigUnlocked ? (
|
|
||||||
<div className="max-w-xl animate-in fade-in duration-300">
|
|
||||||
<div className="flex items-end gap-4">
|
|
||||||
<div className="flex-1">
|
|
||||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
|
||||||
서버 구독자 ID (Server Subscriber ID)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
className="w-full border border-slate-300 rounded px-3 py-2"
|
|
||||||
placeholder="예: SAMSUNG, DEMO_USER_01"
|
|
||||||
value={inputSubscriberId}
|
|
||||||
onChange={(e) => setInputSubscriberId(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={handleSaveConfig}
|
|
||||||
disabled={isConfigSaving}
|
|
||||||
className="bg-slate-800 text-white px-6 py-2 rounded hover:bg-slate-900 transition disabled:opacity-50 h-[42px] whitespace-nowrap"
|
|
||||||
>
|
|
||||||
{isConfigSaving ? '저장 중...' : '설정 저장'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-slate-500 mt-2">
|
<button
|
||||||
* 주의: 구독자 ID를 변경하면 기존에 활성화된 모든 모듈의 라이선스 효력이 중지될 수 있습니다.<br />
|
onClick={handleSaveConfig}
|
||||||
* 반드시 라이선스 키에 포함된 ID와 일치하게 설정하십시오.
|
disabled={isConfigSaving}
|
||||||
</p>
|
className="bg-slate-800 text-white px-6 py-2 rounded hover:bg-slate-900 transition disabled:opacity-50 h-[42px]"
|
||||||
|
>
|
||||||
|
{isConfigSaving ? '저장 중...' : '설정 저장'}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<p className="text-xs text-slate-500 mt-1">
|
||||||
<div className="p-8 text-center bg-slate-100 rounded border border-slate-200 border-dashed">
|
* 라이선스 키에 포함된 ID와 일치해야 활성화됩니다.
|
||||||
<p className="text-slate-500 text-sm mb-2">서버 환경 설정은 시스템의 핵심 식별자입니다.</p>
|
</p>
|
||||||
<p className="text-slate-400 text-xs">보안을 위해 <strong>최고관리자(Supervisor)</strong> 인증 후 변경할 수 있습니다.</p>
|
</div>
|
||||||
</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">
|
||||||
@ -317,50 +251,6 @@ export function LicensePage() {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Supervisor Verification Modal */}
|
|
||||||
{showVerifyModal && (
|
|
||||||
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-sm flex items-center justify-center z-[60] animate-in fade-in duration-200">
|
|
||||||
<div className="bg-white rounded-lg shadow-2xl w-full max-w-sm overflow-hidden border border-slate-200">
|
|
||||||
<div className="bg-slate-800 p-4 text-white">
|
|
||||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
|
||||||
<Shield size={20} /> 최고관리자 인증
|
|
||||||
</h3>
|
|
||||||
<p className="text-slate-300 text-xs mt-1">서버 핵심 설정을 변경하려면 인증이 필요합니다.</p>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleVerifySupervisor} className="p-6">
|
|
||||||
<div className="mb-4">
|
|
||||||
<label className="block text-sm font-bold text-slate-700 mb-2">비밀번호</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
autoComplete="off"
|
|
||||||
autoFocus
|
|
||||||
className="w-full border border-slate-300 rounded px-3 py-2 focus:ring-2 focus:ring-slate-500 outline-none"
|
|
||||||
placeholder="최고관리자 비밀번호 입력"
|
|
||||||
value={verifyPassword}
|
|
||||||
onChange={(e) => setVerifyPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
{verifyError && <p className="text-red-500 text-xs mt-2 font-bold">{verifyError}</p>}
|
|
||||||
</div>
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => { setShowVerifyModal(false); setVerifyPassword(''); }}
|
|
||||||
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded text-sm font-medium"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
disabled={verifying}
|
|
||||||
className="px-4 py-2 bg-slate-800 text-white rounded hover:bg-slate-900 text-sm font-bold"
|
|
||||||
>
|
|
||||||
{verifying ? '인증 중...' : '확인'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,10 +103,6 @@
|
|||||||
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 {
|
||||||
@ -185,45 +181,26 @@
|
|||||||
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: center;
|
align-items: flex-end;
|
||||||
padding: 0 1.5rem;
|
/* Tabs at bottom */
|
||||||
|
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: 1.5rem;
|
gap: 0.5rem;
|
||||||
/* Better gap between tabs */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-item {
|
.tab-item {
|
||||||
padding: 0.75rem 0.5rem;
|
padding: 0.75rem 1.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;
|
||||||
@ -234,17 +211,20 @@
|
|||||||
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 {
|
||||||
|
|||||||
@ -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, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info, UserCog, Box, Package, ClipboardCheck } from 'lucide-react';
|
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield } from 'lucide-react';
|
||||||
import type { IModuleDefinition } from '../../core/types';
|
import type { IModuleDefinition } from '../../core/types';
|
||||||
import './MainLayout.css';
|
import './MainLayout.css';
|
||||||
|
|
||||||
@ -11,28 +11,10 @@ interface MainLayoutProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MainLayout({ modulesList }: MainLayoutProps) {
|
export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||||
// Force Update Check v0.4.3.4
|
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
const { modules } = useSystem();
|
const { modules } = useSystem();
|
||||||
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management', 'standard-info', 'settings-group']);
|
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']);
|
||||||
|
|
||||||
const isAdmin = user ? (['admin', 'supervisor'] as string[]).includes(user.role) : false;
|
|
||||||
const isSupervisor = user?.role === 'supervisor';
|
|
||||||
|
|
||||||
const checkModulePermission = (moduleKey: string) => {
|
|
||||||
// Supervisor and Admin always have access to see active modules
|
|
||||||
if (isSupervisor || isAdmin) return true;
|
|
||||||
// Check allowed_modules list for other roles (manager, user, etc.)
|
|
||||||
return user?.allowed_modules?.includes(moduleKey);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 =>
|
||||||
@ -48,19 +30,18 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
<div className="layout-container">
|
<div className="layout-container">
|
||||||
<aside className="sidebar">
|
<aside className="sidebar">
|
||||||
<div className="sidebar-header">
|
<div className="sidebar-header">
|
||||||
<Link to="/home" className="brand" style={{ textDecoration: 'none' }}>
|
<div className="brand">
|
||||||
<Box className="brand-icon" size={24} />
|
<Box className="brand-icon" size={24} />
|
||||||
<span className="brand-text">Smart IMS</span>
|
<span className="brand-text">Smart IMS</span>
|
||||||
</Link>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="sidebar-nav">
|
<nav className="sidebar-nav">
|
||||||
|
{/* Module: System Management (Platform Core) */}
|
||||||
{/* System Management (Platform Core) */}
|
{user?.role === 'admin' && (
|
||||||
{isAdmin && (
|
|
||||||
<div className="module-group">
|
<div className="module-group">
|
||||||
<button
|
<button
|
||||||
className="module-header" // Removed active class based on expanded state
|
className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`}
|
||||||
onClick={() => toggleModule('sys_mgmt')}
|
onClick={() => toggleModule('sys_mgmt')}
|
||||||
>
|
>
|
||||||
<div className="module-title">
|
<div className="module-title">
|
||||||
@ -84,184 +65,103 @@ 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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 1. Asset Management (자산관리) - Promoted to main section as requested */}
|
{/* Dynamic Modules Injection */}
|
||||||
{modulesList.filter(m => m.moduleName === 'asset-management').map((mod) => {
|
{modulesList.map((mod) => {
|
||||||
const moduleKey = mod.moduleName.split('-')[0];
|
const moduleKey = mod.moduleName.split('-')[0];
|
||||||
if (!isModuleActive(moduleKey) || !checkModulePermission(moduleKey)) return null;
|
if (!isModuleActive(moduleKey)) return null;
|
||||||
|
|
||||||
|
// Check roles
|
||||||
|
if (mod.requiredRoles && user && !mod.requiredRoles.includes(user.role)) return null;
|
||||||
|
|
||||||
|
const hasSubMenu = mod.routes.filter(r => r.label).length > 1;
|
||||||
const isExpanded = expandedModules.includes(mod.moduleName);
|
const isExpanded = expandedModules.includes(mod.moduleName);
|
||||||
|
|
||||||
// Group routes by 'group' property
|
|
||||||
const groups: Record<string, typeof mod.routes> = {};
|
|
||||||
const ungrouped: typeof mod.routes = [];
|
|
||||||
|
|
||||||
mod.routes.filter(r => r.label && (!r.position || r.position === 'sidebar')).forEach(r => {
|
|
||||||
if (r.group) {
|
|
||||||
if (!groups[r.group]) groups[r.group] = [];
|
|
||||||
groups[r.group].push(r);
|
|
||||||
} else {
|
|
||||||
ungrouped.push(r);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={mod.moduleName} className="module-group">
|
<div key={mod.moduleName} className="module-group">
|
||||||
<button
|
{hasSubMenu ? (
|
||||||
className={`module-header ${isExpanded ? 'bg-white/5' : ''}`}
|
<>
|
||||||
onClick={() => toggleModule(mod.moduleName)}
|
<button
|
||||||
>
|
className={`module-header ${isExpanded ? 'active' : ''}`}
|
||||||
<div className="module-title">
|
onClick={() => toggleModule(mod.moduleName)}
|
||||||
<Layers size={18} />
|
>
|
||||||
<span>자산 관리</span>
|
<div className="module-title">
|
||||||
</div>
|
<Layers size={18} />
|
||||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
<span>{mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1)} 관리</span>
|
||||||
</button>
|
</div>
|
||||||
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
</button>
|
||||||
|
{isExpanded && (
|
||||||
|
<div className="module-items">
|
||||||
|
{(() => {
|
||||||
|
const groupedRoutes: Record<string, typeof mod.routes> = {};
|
||||||
|
const ungroupedRoutes: typeof mod.routes = [];
|
||||||
|
|
||||||
{isExpanded && (
|
mod.routes.filter(r => r.label && (!r.position || r.position === 'sidebar')).forEach(route => {
|
||||||
<div className="module-items">
|
if (route.group) {
|
||||||
{/* Direct links (e.g. Dashboard) */}
|
if (!groupedRoutes[route.group]) groupedRoutes[route.group] = [];
|
||||||
{ungrouped.map(route => (
|
groupedRoutes[route.group].push(route);
|
||||||
<Link
|
} else {
|
||||||
key={`${mod.moduleName}-${route.path}`}
|
ungroupedRoutes.push(route);
|
||||||
to={`${mod.basePath}${route.path}`}
|
}
|
||||||
className={`nav-item ${location.pathname === `${mod.basePath}${route.path}` ? 'active' : ''}`}
|
});
|
||||||
>
|
|
||||||
<span>{route.label}</span>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Grouped links (기준정보, 설정) */}
|
return (
|
||||||
{Object.entries(groups).map(([groupName, routes]) => {
|
<>
|
||||||
const groupKey = `${mod.moduleName}-group-${groupName}`;
|
{ungroupedRoutes.map(route => (
|
||||||
const isGroupExpanded = expandedModules.includes(groupKey);
|
<Link
|
||||||
|
key={`${mod.moduleName}-${route.path}`}
|
||||||
|
to={`${mod.basePath}${route.path}`}
|
||||||
|
className={`nav-item ${location.pathname.startsWith(`${mod.basePath}${route.path}`) ? 'active' : ''}`}
|
||||||
|
>
|
||||||
|
{route.icon || <Box size={18} />}
|
||||||
|
<span>{route.label}</span>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
return (
|
{Object.entries(groupedRoutes).map(([groupName, routes]) => (
|
||||||
<div key={groupName} className="nested-module-group pl-2 mt-1">
|
<div key={groupName} className="menu-group-section">
|
||||||
<button
|
<div className="menu-group-label text-xs font-bold text-slate-500 uppercase px-3 py-2 mt-2 mb-1">
|
||||||
className={`nav-item w-full flex justify-between items-center ${isGroupExpanded ? 'bg-slate-800/40' : ''}`}
|
{groupName}
|
||||||
onClick={() => toggleModule(groupKey)}
|
</div>
|
||||||
>
|
{routes.map(route => (
|
||||||
<div className="flex items-center gap-2">
|
<Link
|
||||||
<span>{groupName}</span>
|
key={`${mod.moduleName}-${route.path}`}
|
||||||
</div>
|
to={`${mod.basePath}${route.path}`}
|
||||||
{isGroupExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
className={`nav-item ${location.pathname.startsWith(`${mod.basePath}${route.path}`) ? 'active' : ''}`}
|
||||||
</button>
|
>
|
||||||
|
{route.icon || <Box size={18} />}
|
||||||
{isGroupExpanded && (
|
<span>{route.label}</span>
|
||||||
<div className="pl-4 mt-1 border-l border-slate-700 ml-4 flex flex-col gap-1">
|
</Link>
|
||||||
{routes.map(route => {
|
))}
|
||||||
const isSettingsRoute = route.path.includes('settings');
|
</div>
|
||||||
const hasPermission = user?.role !== 'user' || !isSettingsRoute;
|
))}
|
||||||
if (!hasPermission) return null;
|
</>
|
||||||
|
);
|
||||||
return (
|
})()}
|
||||||
<Link
|
</div>
|
||||||
key={`${mod.moduleName}-${route.path}`}
|
)}
|
||||||
to={`${mod.basePath}${route.path}`}
|
</>
|
||||||
className={`nav-item py-1.5 text-xs ${location.pathname === `${mod.basePath}${route.path}` ? 'active' : ''}`}
|
) : (
|
||||||
>
|
<Link
|
||||||
<span>{route.label}</span>
|
to={`${mod.basePath}${mod.routes[0]?.path || ''}`}
|
||||||
</Link>
|
className={`module-header ${location.pathname.startsWith(mod.basePath) ? 'active' : ''}`}
|
||||||
);
|
style={{ textDecoration: 'none' }}
|
||||||
})}
|
>
|
||||||
</div>
|
<div className="module-title">
|
||||||
)}
|
<Video size={18} />
|
||||||
</div>
|
<span>{mod.moduleName.split('-')[0].toUpperCase()}</span>
|
||||||
);
|
</div>
|
||||||
})}
|
</Link>
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</nav>
|
||||||
<div className="sidebar-divider my-2 border-t border-slate-100 opacity-10"></div>
|
|
||||||
|
|
||||||
{/* Other Modules (CCTV, Production, Material, etc.) - Promoted to top level */}
|
|
||||||
{modulesList
|
|
||||||
.filter(m => m.moduleName !== 'asset-management')
|
|
||||||
.sort((a, b) => {
|
|
||||||
const order: Record<string, number> = {
|
|
||||||
'production-management': 1,
|
|
||||||
'material-management': 2,
|
|
||||||
'quality-management': 3,
|
|
||||||
'cctv': 4
|
|
||||||
};
|
|
||||||
return (order[a.moduleName] || 99) - (order[b.moduleName] || 99);
|
|
||||||
})
|
|
||||||
.map((mod) => {
|
|
||||||
const moduleKey = mod.moduleName.split('-')[0];
|
|
||||||
const active = isModuleActive(moduleKey);
|
|
||||||
const permitted = checkModulePermission(moduleKey);
|
|
||||||
console.log(`[Sidebar] Module: ${mod.moduleName}, Key: ${moduleKey}, Active: ${active}, Permitted: ${permitted}`);
|
|
||||||
if (!active || !permitted) return null;
|
|
||||||
if (mod.requiredRoles && !checkRole(mod.requiredRoles)) return null;
|
|
||||||
|
|
||||||
const isExpanded = expandedModules.includes(mod.moduleName);
|
|
||||||
let Icon = Layers;
|
|
||||||
if (mod.moduleName === 'cctv') Icon = Video;
|
|
||||||
if (mod.moduleName === 'material-management') Icon = Package;
|
|
||||||
if (mod.moduleName === 'production-management') Icon = Box;
|
|
||||||
if (mod.moduleName === 'quality-management') Icon = ClipboardCheck;
|
|
||||||
|
|
||||||
const label = mod.label || (
|
|
||||||
mod.moduleName === 'cctv' ? 'CCTV 모듈' :
|
|
||||||
mod.moduleName === 'production-management' ? '생산관리' :
|
|
||||||
mod.moduleName === 'material-management' ? '자재/재고 관리' :
|
|
||||||
mod.moduleName === 'quality-management' ? '품질 관리' : mod.moduleName
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={mod.moduleName} className="module-group">
|
|
||||||
<button
|
|
||||||
className={`module-header ${isExpanded ? 'bg-white/5' : ''}`}
|
|
||||||
onClick={() => toggleModule(mod.moduleName)}
|
|
||||||
>
|
|
||||||
<div className="module-title">
|
|
||||||
<Icon size={18} />
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{isExpanded && (
|
|
||||||
<div className="module-items">
|
|
||||||
{mod.routes.filter(r => r.label && (!r.position || r.position === 'sidebar')).map(route => (
|
|
||||||
<Link
|
|
||||||
key={`${mod.moduleName}-${route.path}`}
|
|
||||||
to={`${mod.basePath}${route.path}`}
|
|
||||||
className={`nav-item ${location.pathname === `${mod.basePath}${route.path}` ? 'active' : ''}`}
|
|
||||||
>
|
|
||||||
<span>{route.label}</span>
|
|
||||||
</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 >
|
|
||||||
|
|
||||||
<div className="sidebar-footer">
|
<div className="sidebar-footer">
|
||||||
<div className="user-info">
|
<div className="user-info">
|
||||||
@ -277,7 +177,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
<LogOut size={18} />
|
<LogOut size={18} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside >
|
</aside>
|
||||||
|
|
||||||
<main className="main-content">
|
<main className="main-content">
|
||||||
<header className="top-header">
|
<header className="top-header">
|
||||||
@ -288,9 +188,7 @@ 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 = location.pathname.includes('/settings')
|
const topRoutes = activeModule.routes.filter(r => r.position === 'top');
|
||||||
? []
|
|
||||||
: activeModule.routes.filter(r => r.position === 'top');
|
|
||||||
|
|
||||||
return topRoutes.map(route => (
|
return topRoutes.map(route => (
|
||||||
<Link
|
<Link
|
||||||
@ -303,33 +201,21 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
|||||||
));
|
));
|
||||||
})()}
|
})()}
|
||||||
|
|
||||||
{/* Asset Settings Specific Tabs */}
|
{/* Legacy manual override check (can be removed if Asset Settings is fully migrated to route config) */}
|
||||||
{location.pathname.startsWith('/asset/settings') && (
|
{location.pathname.includes('/asset/settings') && (
|
||||||
<div className="header-tabs">
|
<>
|
||||||
{[
|
{/* Keeping this just in case, but ideally should be managed via route config now?
|
||||||
{ id: 'basic', label: '기본 설정' },
|
Actually settings tabs are usually sub-pages of settings, not top-level module nav.
|
||||||
{ id: 'category', label: '카테고리 관리' },
|
Leaving for safety.
|
||||||
{ 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 />
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
</div >
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
temp_auth.js
BIN
temp_auth.js
Binary file not shown.
@ -1,39 +0,0 @@
|
|||||||
|
|
||||||
@echo off
|
|
||||||
echo [Update] Starting update to v0.4.2.6...
|
|
||||||
|
|
||||||
REM Ensure backup directory
|
|
||||||
set BACKUP_PATH=./backup
|
|
||||||
if not exist "%BACKUP_PATH%" mkdir "%BACKUP_PATH%" 2>nul
|
|
||||||
if not exist "%BACKUP_PATH%" (
|
|
||||||
echo [Warning] Global backup failed, using local backup.
|
|
||||||
set BACKUP_PATH=.\server\backups
|
|
||||||
if not exist ".\server\backups" mkdir ".\server\backups"
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [Update] Backing up Config...
|
|
||||||
if exist "server\.env" (
|
|
||||||
copy /Y "server\.env" "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22"
|
|
||||||
copy /Y "server\.env" "server\.env.tmp"
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [Update] Syncing Source Code...
|
|
||||||
git fetch "https://gitea.qideun.com/SOKUREE/smart_ims.git" --tags --force --prune
|
|
||||||
git checkout -f v0.4.2.6
|
|
||||||
|
|
||||||
echo [Update] Restoring Config...
|
|
||||||
if exist "server\.env.tmp" (
|
|
||||||
copy /Y "server\.env.tmp" "server\.env"
|
|
||||||
del "server\.env.tmp"
|
|
||||||
) else if exist "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22" (
|
|
||||||
copy /Y "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22" "server\.env"
|
|
||||||
)
|
|
||||||
|
|
||||||
echo [Update] Installing & Building...
|
|
||||||
call npm install
|
|
||||||
call npm run build
|
|
||||||
cd server
|
|
||||||
call npm install
|
|
||||||
|
|
||||||
echo [Update] Done.
|
|
||||||
|
|
||||||
Loading…
x
Reference in New Issue
Block a user