Initial
This commit is contained in:
commit
5ead239b71
67
.gitignore
vendored
Normal file
67
.gitignore
vendored
Normal file
@ -0,0 +1,67 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Dependencies
|
||||
node_modules
|
||||
.pnp
|
||||
.pnp.js
|
||||
|
||||
# Testing
|
||||
coverage
|
||||
.nyc_output
|
||||
|
||||
# Production & Build
|
||||
dist
|
||||
dist-ssr
|
||||
build
|
||||
out
|
||||
|
||||
# Environment Variables (CRITICAL)
|
||||
.env
|
||||
.env.local
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.production
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
# OS Generated
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
|
||||
# Project Specific - Server
|
||||
server/uploads/*
|
||||
!server/uploads/.gitkeep
|
||||
server/server.zip
|
||||
server/public_key.pem
|
||||
server/private_key.pem
|
||||
|
||||
# Project Specific - Camera/Stream
|
||||
*.ts
|
||||
!src/**/*.ts
|
||||
!src/**/*.tsx
|
||||
!vite.config.ts
|
||||
!server/**/*.ts
|
||||
# Exclude recorded streams if any are saved locally in dev
|
||||
stream_recordings/
|
||||
|
||||
# Agent Artifacts
|
||||
.gemini/
|
||||
.agent/
|
||||
73
README.md
Normal file
73
README.md
Normal file
@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
25
docs/CCTV_MODULE_GUIDE.md
Normal file
25
docs/CCTV_MODULE_GUIDE.md
Normal file
@ -0,0 +1,25 @@
|
||||
# CCTV 모바일 영상 및 카메라 설정 가이드
|
||||
|
||||
본 문서는 SmartAsset CCTV 모니터링 모듈의 고급 설정 기능을 설명합니다.
|
||||
|
||||
## 1. 전송 방식 (Transport Mode)
|
||||
오래된 카메라나 네트워크 환경에 따라 RTSP 데이터 전송 방식을 선택할 수 있습니다.
|
||||
- **TCP (권장)**: 데이터 손실 없이 안정적입니다. 기본값으로 설정되어 있습니다.
|
||||
- **UDP**: 속도는 빠를 수 있으나, 패킷 손실 시 화면 깨짐이나 딜레이가 누적될 수 있습니다.
|
||||
- **Auto**: 연결 시 서버가 자동으로 최적의 방식을 협상합니다.
|
||||
|
||||
## 2. 스트림 화질 (Quality Settings)
|
||||
네트워크 대역폭과 서버 부하를 고려하여 품질 수준을 조정할 수 있습니다.
|
||||
- **Low (640px / 800kbps)**: 가장 가볍고 빠릅니다. 다중 채널 동시 감시 시 권장합니다.
|
||||
- **Medium (1280px / 1500kbps)**: 밸런스가 좋은 HD급 화질입니다.
|
||||
- **Original (~6000kbps)**: 카메라 원본 화질입니다. 고성능 PC 환경에서 권장합니다.
|
||||
|
||||
## 3. 특수문자 처리 (RTSP Encoding)
|
||||
카메라의 ID/PW에 특수문자(예: `#`, `^`, `&`)가 포함되어 있어 접속되지 않는 경우, **URL 인코딩** 옵션을 활성화하십시오.
|
||||
|
||||
## 4. 카메라 위치 관리 (Drag-and-Drop)
|
||||
- **관리자 전용**: 관리자 권한을 가진 사용자만 카메라의 순서를 드래그 앤 드롭으로 변경할 수 있습니다.
|
||||
- **자동 저장**: 변경된 순서는 데이터베이스에 즉시 저장되어 모든 사용자의 다음 접속 시 유지됩니다.
|
||||
|
||||
---
|
||||
마지막 업데이트: 2026-01-22
|
||||
59
docs/DEPLOYMENT_GUIDE.md
Normal file
59
docs/DEPLOYMENT_GUIDE.md
Normal file
@ -0,0 +1,59 @@
|
||||
# 시스템 배포 가이드 (Deployment Guide)
|
||||
|
||||
본 문서는 빌드된 스마트어셋(SmartAsset) 솔루션을 실서버(Synology NAS 등)에 배포할 때 필요한 절차와 주의사항을 설명합니다.
|
||||
|
||||
## 1. 배포 대상 폴더 및 파일
|
||||
서버에 업로드해야 하는 핵심 구성 요소는 다음과 같습니다.
|
||||
|
||||
### 1.1. `dist/` 폴더 (프론트엔드)
|
||||
* **생성 방법**: `npm run build` 명령을 통해 생성됩니다.
|
||||
* **역할**: 사용자 브라우저에서 실행되는 정적 웹 파일(JS, CSS, HTML)입니다.
|
||||
* **위치**: 업로드 시 `server/` 폴더와 동일한 레벨 혹은 서버 설정에 맞게 배치합니다.
|
||||
|
||||
### 1.2. `server/` 폴더 (백엔드)
|
||||
* 프로젝트의 API 및 세션, 라이선스 검증을 담당하는 핵심 로직입니다.
|
||||
* **주의**: `node_modules`는 서버에서 직접 `npm install`을 실행하여 생성하는 것을 권장합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 보안 및 라이선스 필수 파일 (중요)
|
||||
배포 시 다음 파일들의 유무와 내용을 반드시 점검해야 합니다.
|
||||
|
||||
### 2.1. `server/.env` (환경 변수)
|
||||
* **설정**: DB 접속 정보(Host, User, Password)와 포트 번호가 포함되어 있습니다.
|
||||
* **주의**: 배포 환경에 맞는 DB 정보를 정확히 입력해야 서버가 시작됩니다.
|
||||
|
||||
### 2.2. `server/public_key.pem` (RSA 공개키)
|
||||
* **역할**: 발급된 라이선스 키가 진짜인지 검증하는 데 사용됩니다.
|
||||
* **필수**: 이 파일이 없으면 웹에서 라이선스 활성화 및 로그인이 불가능할 수 있습니다.
|
||||
* **보안**: 공개되어도 무방한 파일입니다. (이 파일만으로는 라이선스 생성 불가)
|
||||
|
||||
---
|
||||
|
||||
## 3. 배포 시 제외해야 할 파일 (보안 주의)
|
||||
다음 파일들은 **절대로 고객사 서버에 업로드하지 마십시오.**
|
||||
|
||||
1. **`tools/` 폴더 전체**: 라이선스 발급 도구가 들어 있어 고객이 직접 키를 생성할 위험이 있습니다.
|
||||
2. **`tools/keys/private_key.pem` (개인키)**: 라이선스를 생성할 수 있는 마스터키입니다. 유출 시 모든 보안이 무너집니다.
|
||||
|
||||
## 4. 시놀로지 역방향 프록시 설정 (CCTV 필수)
|
||||
도메인(`https://demo.sokuree.com`)을 통해 접속할 경우, **WebSocket** 통신이 허용되도록 설정해야 CCTV 영상이 출력됩니다.
|
||||
|
||||
1. **제어판** > **로그인 포털** > **고급** > **역방향 프록시** 이동.
|
||||
2. 해당 도메인 설정 선택 후 **[편집]** 클릭.
|
||||
3. **[사용자 정의 헤더]** 탭으로 이동.
|
||||
4. **[생성]** 버튼 옆의 드롭다운(화살표) 클릭 > **WebSocket** 선택.
|
||||
5. `Upgrade`, `Connection` 헤더가 자동으로 추가된 것을 확인하고 **[저장]**을 클릭합니다.
|
||||
* *이 설정이 없으면 CCTV 화면이 검은색으로 나오거나 연결 오류가 발생합니다.*
|
||||
|
||||
---
|
||||
|
||||
## 5. 서버 실행 절차 (PM2 기준)
|
||||
업로드가 완료된 후 서버 터미널(SSH)에서 다음 명령을 실행합니다.
|
||||
|
||||
1. **의존성 설치**: `cd server` 이동 후 `npm install`
|
||||
2. **서비스 시작**: `pm2 start index.js --name "smartasset"`
|
||||
3. **상태 확인**: `pm2 status`를 통해 서버가 `online` 상태인지 확인합니다.
|
||||
|
||||
---
|
||||
마지막 업데이트: 2026-01-22
|
||||
77
docs/IMPLEMENTATION_DETAILS.md
Normal file
77
docs/IMPLEMENTATION_DETAILS.md
Normal file
@ -0,0 +1,77 @@
|
||||
# 구현 계획 - 모듈(라이선스) 관리
|
||||
|
||||
이 시스템의 목표는 관리자가 "라이선스 관리" 페이지를 통해 특정 모듈(자산, 생산, CCTV)을 활성화/비활성화할 수 있도록 하는 것이며, **활성화를 위해서는 유효한 라이선스 키가 필수**입니다.
|
||||
|
||||
## 라이선스 시스템 아키텍처
|
||||
**서명된 토큰(Signed Token)** 방식(JWT 또는 AES 암호화와 유사)을 사용합니다.
|
||||
- **발급 (개발자)**: 생성 도구(CLI 스크립트)를 사용하여 키를 생성합니다.
|
||||
- 데이터: `{ module: 'cctv', type: 'demo', created: '2024-01-01', days: 30 }`
|
||||
- 출력: `U2FsdGVkX1...` (암호화된 문자열)
|
||||
- **활성화 (사용자)**: 사용자가 이 문자열을 입력합니다. 서버는 비밀 키(Secret Key)로 이를 복호화하고 날짜를 검증합니다.
|
||||
|
||||
## 사용자 검토 사항
|
||||
> [!IMPORTANT]
|
||||
> **라이선스 관리**: 보안을 위해 숨겨진 관리자 생성기(CLI 스크립트)를 사용하여 키를 발급합니다.
|
||||
> **만료 처리**: 시스템은 데이터베이스에 저장된 `expiry_date`를 확인하여 만료 여부를 판별합니다.
|
||||
|
||||
## 변경 제안 사항
|
||||
|
||||
### 데이터베이스 및 서버
|
||||
#### [신규] `system_modules` 테이블
|
||||
- `code` (VARCHAR, PK): 'asset', 'production', 'monitoring'
|
||||
- `is_active` (BOOLEAN)
|
||||
- `license_key` (TEXT): 원본 키 문자열.
|
||||
- `license_type` (VARCHAR): 'dev', 'sub', 'demo'
|
||||
- `expiry_date` (DATETIME): 'dev'(영구)인 경우 NULL.
|
||||
|
||||
#### [신규] `server/utils/licenseManager.js`
|
||||
- `generateLicense(module, type)`: 암호화된 문자열 반환.
|
||||
- `verifyLicense(key)`: 복호화하여 유효성 및 만료일 반환.
|
||||
|
||||
#### [신규] `server/routes/system.js`
|
||||
- `POST /activate`: 키 입력 -> 복호화 -> DB 업데이트.
|
||||
- `GET /modules`: 모듈 상태 및 만료 정보 반환.
|
||||
|
||||
### 프론트엔드
|
||||
#### [신규] `src/system/pages/LicensePage.tsx`
|
||||
- **모듈 목록**: 활성/비활성 스위치, 만료일 표시.
|
||||
- **활성화 모달**: 키 입력 필드 제공.
|
||||
- **(숨김) 생성기 탭**: 보안상 이유로 삭제됨, CLI로 대체.
|
||||
|
||||
#### [수정] `src/widgets/layout/MainLayout.tsx`
|
||||
- 모듈 활성화 상태에 따라 사이드바 메뉴 조건부 렌더링.
|
||||
|
||||
## 검증 계획
|
||||
1. **키 발급**: 생성기(CLI)를 사용하여 'Demo' 키(30일)와 'Dev' 키를 생성합니다.
|
||||
2. **활성화**: Demo 키를 적용합니다. 30일 후 만료되는지 확인합니다.
|
||||
3. **UI 확인**: 'CCTV' 메뉴가 사이드바에 나타나는지 확인합니다.
|
||||
|
||||
#### [신규] `tools/license_manager.cjs`
|
||||
- **통합 CLI**: 오프라인 키 관리 및 온라인 DB 상태 관리.
|
||||
- **명령어**:
|
||||
- `list`: DB의 모든 모듈 상태 조회.
|
||||
- `generate <module> <type>`: 새로운 서명된 라이선스 키 생성.
|
||||
- `decode <key>`: 라이선스 키 내용 확인 (복호화).
|
||||
- `activate <key>`: 키를 사용하여 DB에 직접 활성화 적용.
|
||||
- `delete <module>`: DB에서 모듈 비활성화/삭제.
|
||||
- **삭제됨**: 기존 `issue_license.cjs` (이 도구로 통합됨).
|
||||
|
||||
## 고급 카메라 설정 구현
|
||||
|
||||
### 1. 데이터베이스 스키마 확장
|
||||
`camera_settings` 테이블에 다음 컬럼을 추가하여 설정을 저장합니다.
|
||||
- `transport_mode`: ENUM('tcp', 'udp', 'auto') - RTSP 전송 프로토콜 제어.
|
||||
- `rtsp_encoding`: BOOLEAN - 비밀번호 특수문자 URL 인코딩 여부.
|
||||
- `quality`: ENUM('low', 'medium', 'original') - 스트리밍 해상도 및 비트레이트 제어.
|
||||
|
||||
### 2. 백엔드 스트림 엔진 (`streamRelay.js`)
|
||||
- **FFmpeg 튜닝**: JSMpeg(소프트웨어 디코더)의 안정성을 위해 비트레이트를 제한하고 키프레임 주기를 조정했습니다.
|
||||
- `-maxrate`, `-bufsize`: 네트워크 버스트 방지.
|
||||
- `-g 30`: 1초마다 키프레임 전송 (화면 깨짐 시 1초 내 자동 복구).
|
||||
- **심리스 리로드 (Seamless Reload)**:
|
||||
- 사용자 설정 변경(PUT) 시, 클라이언트(브라우저)와의 WebSocket 연결은 유지한 채 백그라운드의 FFmpeg 프로세스만 교체합니다.
|
||||
- `stopStream` 대신 `reloadStream`을 사용하여 끊김 없는 화질 변경 경험을 제공합니다.
|
||||
|
||||
### 3. 프론트엔드 UI
|
||||
- **CameraModal**: 고급 설정 섹션을 추가하여 사용자가 기술적인 설정(TCP/UDP, 해상도)을 직관적으로 제어할 수 있도록 폼을 개선했습니다.
|
||||
|
||||
79
docs/LICENSE_MANAGER_MANUAL.md
Normal file
79
docs/LICENSE_MANAGER_MANUAL.md
Normal file
@ -0,0 +1,79 @@
|
||||
# 라이선스 관리자 매뉴얼 (최종본)
|
||||
|
||||
본 문서는 스마트어셋(SmartAsset) 시스템의 라이선스 관리 기능을 사용하는 방법을 설명합니다. 모든 라이선스 관리는 **RSA 비대칭 암호화** 방식을 따르며, `tools/license_manager.cjs` CLI 도구를 통해 수행됩니다.
|
||||
|
||||
## 1. 사전 준비 및 구독자 설정 (중요)
|
||||
라이선스 키를 활성화하기 전에 서버가 어떤 고객사(구독자)에게 속해 있는지 식별하기 위해 **구독자 ID**를 설정해야 합니다.
|
||||
|
||||
1. **웹 관리자 페이지 접속**: 상단 메뉴의 `라이선스 관리` 섹션으로 이동합니다.
|
||||
2. **서버 환경 설정**:
|
||||
* 부여받은 `구독자 ID`(예: `SAMSUNG`, `TEST_SUB`)를 입력하고 **[설정 저장]**을 클릭합니다.
|
||||
* **주의**: 라이선스 키 발급 시 사용한 구독자 ID와 서버에 설정된 ID가 일치해야만 활성화가 가능합니다.
|
||||
|
||||
---
|
||||
|
||||
## 2. 기본 명령어 (CLI 사용법)
|
||||
모든 명령은 프로젝트 루트 경로에서 `node`를 사용하여 실행합니다.
|
||||
|
||||
### 2.1. 라이선스 발급 (`generate`) - 제공자 전용
|
||||
새로운 RSA 서명 방식의 라이선스 키를 생성합니다. 이 작업에는 `tools/keys/private_key.pem`이 필요합니다.
|
||||
|
||||
```bash
|
||||
# 기본 발급 (30일 데모용)
|
||||
node tools/license_manager.cjs generate monitoring demo --subscriber TEST_SUB
|
||||
|
||||
# 발급 및 즉시 활성화 (로컬 테스트용)
|
||||
node tools/license_manager.cjs generate asset sub --subscriber SAMSUNG --activate
|
||||
|
||||
# 만기일 직접 지정 발급
|
||||
node tools/license_manager.cjs generate production sub --subscriber SAMSUNG --expiry 2026-12-31
|
||||
|
||||
# 시한부 발급 (지정된 일수 내에 활성화하지 않으면 만료됨)
|
||||
node tools/license_manager.cjs generate monitoring sub --subscriber TEST_SUB --activation-window 3
|
||||
```
|
||||
* **모듈**: `asset`, `production`, `monitoring`
|
||||
* **유형**: `dev` (무제한), `sub` (구독형), `demo` (30일 체험)
|
||||
|
||||
### 2.2. 라이선스 검증 및 해독 (`decode`)
|
||||
키를 활성화하기 전에 내용을 검증하거나, 서명 변조 여부를 확인합니다.
|
||||
|
||||
```bash
|
||||
node tools/license_manager.cjs decode <라이선스_키_문자열>
|
||||
```
|
||||
* **Valid Signature : YES**가 나오면 정품 키이며, **NO**가 나오면 변조되었거나 잘못된 키입니다.
|
||||
|
||||
### 2.3. 현황 조회 및 상세 정보 (`list`, `show`)
|
||||
```bash
|
||||
# 전체 모듈 활성화 상태 및 최근 발급 이력 조회
|
||||
node tools/license_manager.cjs list
|
||||
|
||||
# 특정 발급 ID나 모듈의 상세 정보 확인
|
||||
node tools/license_manager.cjs show 5
|
||||
node tools/license_manager.cjs show monitoring
|
||||
```
|
||||
|
||||
### 2.4. 수동 활성화 및 삭제 (`activate`, `delete`)
|
||||
```bash
|
||||
# 발급받은 키를 서버에 적용 (웹 UI에서도 가능)
|
||||
node tools/license_manager.cjs activate <라이선스_키_문자열>
|
||||
|
||||
# 특정 모듈 비활성화 (기존 키는 삭제되지 않고 이력으로 이동)
|
||||
node tools/license_manager.cjs delete asset
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 보안 및 배포 가이드
|
||||
보안 강화를 위해 반드시 다음 사항을 준수하십시오.
|
||||
|
||||
### 3.1. 개인키와 공개키의 분리
|
||||
* **private_key.pem (제공자)**: 라이선스를 만드는 "인감"입니다. **절대로 고객사에 배포하지 마십시오.** `tools/` 폴더 자체를 배포에서 제외하는 것을 권장합니다.
|
||||
* **public_key.pem (서버)**: 라이선스가 진짜인지 확인하는 "인감 증명"입니다. 서버의 `server/public_key.pem` 경로에 반드시 위치해야 합니다.
|
||||
|
||||
### 3.2. 영구 보관 및 이력
|
||||
* 새로운 키로 교체될 때마다 기존 키 정보는 데이터베이스의 `license_history` 테이블에 자동으로 보관됩니다. (웹 UI에서 '변경 이력' 버튼으로 확인 가능)
|
||||
|
||||
---
|
||||
마지막 업데이트: 2026-01-22 (RSA 비대칭 암호화 및 구독자 기반 관리 적용)
|
||||
|
||||
|
||||
47
docs/LICENSE_SERVER_SETUP.md
Normal file
47
docs/LICENSE_SERVER_SETUP.md
Normal file
@ -0,0 +1,47 @@
|
||||
# 라이선스 중앙 인증 서버 설정 가이드
|
||||
|
||||
본 시스템은 고객 서버에서 라이선스 활성화 시, 중앙 관리 서버(Developer's DB)에 접속하여 키의 유효성을 2차 검증하는 기능을 제공합니다. 이를 위해 중앙 DB 서버에 테이블을 생성하고, 고객 서버의 설정 파일(`.env`)에 접속 정보를 입력해야 합니다.
|
||||
|
||||
## 1. 중앙 DB 테이블 생성 (Developer Server)
|
||||
|
||||
중앙 라이선스 DB(MySQL)에 접속하여 아래 SQL을 실행하여 `issued_licenses` 테이블을 생성하십시오.
|
||||
이 테이블은 생성된 모든 라이선스 키를 저장합니다.
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS issued_licenses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
license_key TEXT NOT NULL,
|
||||
module_code VARCHAR(50) NOT NULL,
|
||||
license_type VARCHAR(20) NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
used_at TIMESTAMP NULL,
|
||||
is_revoked BOOLEAN DEFAULT FALSE,
|
||||
INDEX idx_license_key (license_key(255))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
```
|
||||
|
||||
## 2. 고객 서버 설정 (Client Server)
|
||||
|
||||
고객사에 배포되는 서버의 `.env` 파일에 아래와 같이 중앙 라이선스 DB 접속 정보를 추가해야 합니다. 이 정보가 없으면 로컬 검증만 수행합니다.
|
||||
|
||||
**주의**: 고객 서버에서 외부(중앙 DB)로 접속이 가능하도록 방화벽 설정(3306 포트 등)이 되어야 합니다.
|
||||
|
||||
```env
|
||||
# Central License Verification DB
|
||||
LICENSE_DB_HOST=your-central-db-ip.com
|
||||
LICENSE_DB_PORT=3306
|
||||
LICENSE_DB_USER=license_readonly_user
|
||||
LICENSE_DB_PASSWORD=your_secure_password
|
||||
LICENSE_DB_NAME=your_license_db
|
||||
```
|
||||
|
||||
> **보안 권장사항**: `LICENSE_DB_USER`는 `issued_licenses` 테이블에 대한 **SELECT 권한만** 부여된 계정을 사용하십시오.
|
||||
|
||||
## 3. 작동 원리
|
||||
|
||||
1. **키 생성**: 개발자가 `tools/license_manager.cjs generate` 명령을 실행하면, 로컬 키 생성과 동시에 중앙 DB(`issued_licenses`)에 키가 저장됩니다. (개발자 PC에도 `.env` 설정 필요)
|
||||
2. **활성화 시도**: 고객이 라이선스 키를 입력하면, 서버는 먼저 로컬에서 서명을 검증합니다.
|
||||
3. **원격 검증**: 로컬 검증이 통과되면, 서버는 `.env`에 설정된 중앙 DB에 잠시 접속하여 해당 키가 존재하는지 확인합니다.
|
||||
4. **결과 처리**: 중앙 DB에 키가 존재하면 활성화가 완료되고, DB 연결은 즉시 해제됩니다.
|
||||
|
||||
이 방식을 통해 임의로 키를 생성해내더라도 중앙 DB에 등록되지 않은 키는 사용할 수 없게 됩니다.
|
||||
36
docs/task.md
Normal file
36
docs/task.md
Normal file
@ -0,0 +1,36 @@
|
||||
# SmartAsset Project - Task & Feature Roadmap
|
||||
|
||||
## 1. Core Platform & Security
|
||||
- [x] **Project Initialization**: Created Vite + React + TypeScript base.
|
||||
- [x] **Design System**: Implemented HSL-based vanilla CSS tokens and main layout.
|
||||
- [x] **Authenticated Session**: Node.js session-based authentication (HttpOnly Cookies).
|
||||
- [x] **CSRF Protection**: Implemented double-submit cookie pattern for all state-changing requests.
|
||||
- [x] **RBAC (Role Based Access Control)**: Restricted admin-only APIs (User/License management).
|
||||
- [x] **Production Ready**: Configured PM2, static file serving for Synology NAS.
|
||||
- [x] **Production Stability**: Resolved persistent `EADDRINUSE` (Port 3001) conflict by migrating backend to Port 3005.
|
||||
|
||||
## 2. License Management (Security Upgraded)
|
||||
- [x] **Subscriber Config**: Added Server-side Subscriber ID verification.
|
||||
- [x] **RSA Asymmetric Encryption**: Replaced AES with RSA 2048-bit digital signatures.
|
||||
- [x] Private Key for Provider (Signing).
|
||||
- [x] Public Key for Server (Verification).
|
||||
- [x] **License CLI Tool**:
|
||||
- `list`: Show module status and issued history.
|
||||
- `generate`: Sign keys with optional expiry and window.
|
||||
- `decode`: Verify signature and inspect payload.
|
||||
- `activate`: Replaces active license and archives history.
|
||||
- [x] **License History**: Automatic archiving of old/replaced keys in DB.
|
||||
- [x] **Manuals**: Comprehensive guides for license issuance, server setup, and deployment processes.
|
||||
|
||||
## 3. CCTV Monitoring (Module)
|
||||
- [x] **RTSP Relay**: WebSocket-based RTSP streaming using JSMpeg.
|
||||
- [x] **Quality Control**: User-selectable resolution and bitrate levels.
|
||||
- [x] **Connectivity Fixes**: RTSP password encoding and Transport mode (TCP/UDP) support.
|
||||
- [x] **Synology Compatibility**: Implemented auto-detection for SynoCommunity FFmpeg (v6/v7) to support RTSP protocols. (Note: Local Windows env may block FFmpeg network access).
|
||||
- [x] **Layout Persistence**: Drag-and-drop camera reordering saved to DB.
|
||||
- [x] **Admin Polish**: Restricted camera edit/delete to Admin role only.
|
||||
|
||||
## 4. UI/UX Refinement
|
||||
- [x] **Lint/Type Fixes**: Resolved all TypeScript warnings and React usage errors.
|
||||
- [x] **Clean Interface**: Glassmorphism elements and premium dark/light mode support.
|
||||
- [ ] **Next Steps**: Asset maintenance history module and inventory management.
|
||||
23
eslint.config.js
Normal file
23
eslint.config.js
Normal file
@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs.flat.recommended,
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
17
index.html
Normal file
17
index.html
Normal file
@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SmartAsset - 통합 자산관리 플랫폼</title>
|
||||
<script src="https://cdn.jsdelivr.net/gh/phoboslab/jsmpeg@master/jsmpeg.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
5650
package-lock.json
generated
Normal file
5650
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
package.json
Normal file
45
package.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "temp_app",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@types/file-saver": "^2.0.7",
|
||||
"@types/react-datepicker": "^6.2.0",
|
||||
"axios": "^1.13.2",
|
||||
"exceljs": "^4.4.0",
|
||||
"file-saver": "^2.0.5",
|
||||
"lucide-react": "^0.562.0",
|
||||
"react": "^19.2.0",
|
||||
"react-datepicker": "^9.1.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.12.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.5",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.1",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"dotenv": "^17.2.3",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.4.24",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.46.4",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
1
public/vite.svg
Normal file
1
public/vite.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
27
server/check_db.cjs
Normal file
27
server/check_db.cjs
Normal file
@ -0,0 +1,27 @@
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '.env') });
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
async function check() {
|
||||
console.log('Connecting to:', process.env.DB_NAME, 'on', process.env.DB_HOST);
|
||||
const conn = await mysql.createConnection({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: parseInt(process.env.DB_PORT || '3306')
|
||||
});
|
||||
|
||||
try {
|
||||
const [users] = await conn.execute('SELECT id, name FROM users');
|
||||
console.log('Users found:', users.map(u => u.id));
|
||||
|
||||
const [cameras] = await conn.execute('SELECT name FROM camera_settings');
|
||||
console.log('Cameras found:', cameras.map(c => c.name));
|
||||
} catch (e) {
|
||||
console.error('Error:', e.message);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
check();
|
||||
45
server/createAdmin.js
Normal file
45
server/createAdmin.js
Normal file
@ -0,0 +1,45 @@
|
||||
const db = require('./db');
|
||||
const crypto = require('crypto');
|
||||
require('dotenv').config();
|
||||
|
||||
const ID = 'admin';
|
||||
const PASSWORD = 'admin'; // Default password
|
||||
const NAME = '시스템 관리자';
|
||||
|
||||
function hashPassword(password) {
|
||||
return crypto.createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
async function createAdmin() {
|
||||
console.log(`Creating/Updating admin user...`);
|
||||
console.log(`ID: ${ID}`);
|
||||
console.log(`Password: ${PASSWORD}`);
|
||||
|
||||
try {
|
||||
const hashedPassword = hashPassword(PASSWORD);
|
||||
|
||||
// Check if exists
|
||||
const [existing] = await db.query('SELECT * FROM users WHERE id = ?', [ID]);
|
||||
|
||||
if (existing.length > 0) {
|
||||
console.log('User exists using UPDATE...');
|
||||
await db.query('UPDATE users SET password = ?, name = ?, role = "admin" WHERE id = ?', [hashedPassword, NAME, ID]);
|
||||
} else {
|
||||
console.log('User does not exist. creating...');
|
||||
await db.query(`
|
||||
INSERT INTO users (id, password, name, role, department, position)
|
||||
VALUES (?, ?, ?, 'admin', 'IT팀', '관리자')
|
||||
`, [ID, hashedPassword, NAME]);
|
||||
}
|
||||
|
||||
console.log('✅ Admin user setup complete.');
|
||||
console.log(`You can now login with ID: '${ID}' and password: '${PASSWORD}'`);
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to create admin:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
createAdmin();
|
||||
15
server/db.js
Normal file
15
server/db.js
Normal file
@ -0,0 +1,15 @@
|
||||
const mysql = require('mysql2');
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
module.exports = pool.promise();
|
||||
506
server/index.js
Normal file
506
server/index.js
Normal file
@ -0,0 +1,506 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
require('dotenv').config();
|
||||
|
||||
const db = require('./db');
|
||||
const authRoutes = require('./routes/auth');
|
||||
const cameraRoutes = require('./modules/cctv/routes');
|
||||
const StreamRelay = require('./modules/cctv/streamRelay');
|
||||
const { csrfProtection } = require('./middleware/csrfMiddleware');
|
||||
const { isAuthenticated } = require('./middleware/authMiddleware');
|
||||
|
||||
const app = express();
|
||||
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');
|
||||
|
||||
// Ensure uploads directory exists
|
||||
const uploadDir = path.join(__dirname, 'uploads');
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir);
|
||||
}
|
||||
|
||||
// Multer Configuration
|
||||
const multer = require('multer');
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
// Use timestamp + random number + extension to avoid encoding issues with Korean filenames
|
||||
const ext = path.extname(file.originalname);
|
||||
const filename = `${Date.now()}-${Math.round(Math.random() * 1000)}${ext}`;
|
||||
cb(null, filename);
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage: storage });
|
||||
|
||||
// Session Store Configuration
|
||||
const session = require('express-session');
|
||||
const MySQLStore = require('express-mysql-session')(session);
|
||||
|
||||
const sessionStoreOptions = {
|
||||
host: process.env.DB_HOST,
|
||||
port: process.env.DB_PORT || 3306,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME,
|
||||
clearExpired: true,
|
||||
checkExpirationInterval: 900000, // 15 min
|
||||
expiration: 86400000 // 1 day
|
||||
};
|
||||
|
||||
const sessionStore = new MySQLStore(sessionStoreOptions);
|
||||
|
||||
// Middleware
|
||||
app.use(cors({
|
||||
origin: true, // Allow all origins (or specific one)
|
||||
credentials: true // Important for cookies
|
||||
}));
|
||||
app.use(express.json());
|
||||
app.use('/uploads', express.static(uploadDir));
|
||||
|
||||
// Session Middleware
|
||||
app.use(session({
|
||||
key: 'smartasset_sid',
|
||||
secret: process.env.SESSION_SECRET || 'smartasset_session_secret_key',
|
||||
store: sessionStore,
|
||||
resave: false,
|
||||
saveUninitialized: true, // Save new sessions even if empty (helps with some client handshake issues)
|
||||
cookie: {
|
||||
httpOnly: true, // Prevent JS access
|
||||
secure: false, // Set true if using HTTPS
|
||||
maxAge: 1000 * 60 * 60 * 24, // 1 day
|
||||
sameSite: 'lax' // Recommended for better CSRF protection and reliability
|
||||
}
|
||||
}));
|
||||
|
||||
// Apply CSRF Protection
|
||||
app.use(csrfProtection);
|
||||
|
||||
// Request Logger
|
||||
app.use((req, res, next) => {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// Test DB Connection
|
||||
db.query('SELECT 1')
|
||||
.then(() => console.log('✅ Connected to Database'))
|
||||
.catch(err => {
|
||||
console.error('❌ Database Connection Failed:', err.message);
|
||||
console.error('Hint: Check your .env DB_HOST and ensure Synology MariaDB allows remote connections.');
|
||||
});
|
||||
|
||||
// Ensure Tables Exist
|
||||
const initTables = async () => {
|
||||
try {
|
||||
const assetTable = `
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id VARCHAR(20) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
category VARCHAR(50) NOT NULL,
|
||||
image_url VARCHAR(255),
|
||||
model_name VARCHAR(100),
|
||||
serial_number VARCHAR(100),
|
||||
manufacturer VARCHAR(100),
|
||||
location VARCHAR(100),
|
||||
purchase_date DATE,
|
||||
purchase_price BIGINT,
|
||||
manager VARCHAR(50),
|
||||
status ENUM('active', 'maintain', 'broken', 'disposed') DEFAULT 'active',
|
||||
calibration_cycle VARCHAR(50),
|
||||
specs TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`;
|
||||
const maintenanceTable = `
|
||||
CREATE TABLE IF NOT EXISTS maintenance_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_id VARCHAR(20) NOT NULL,
|
||||
maintenance_date DATE NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
content TEXT,
|
||||
image_url VARCHAR(255),
|
||||
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(assetTable);
|
||||
await db.query(maintenanceTable);
|
||||
|
||||
const [usersTableExists] = await db.query("SHOW TABLES LIKE 'users'");
|
||||
if (usersTableExists.length === 0) {
|
||||
const usersTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
department VARCHAR(100),
|
||||
position VARCHAR(100),
|
||||
phone VARCHAR(255),
|
||||
role ENUM('admin', 'user') DEFAULT 'user',
|
||||
last_login TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`;
|
||||
await db.query(usersTableSQL);
|
||||
console.log('✅ Users Table Created');
|
||||
|
||||
// Default Admin
|
||||
const adminId = 'admin';
|
||||
const [adminExists] = await db.query('SELECT 1 FROM users WHERE id = ?', [adminId]);
|
||||
if (adminExists.length === 0) {
|
||||
const crypto = require('crypto');
|
||||
const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex');
|
||||
await db.query(
|
||||
'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[adminId, hashedPass, '시스템 관리자', 'admin', 'IT팀', '관리자']
|
||||
);
|
||||
console.log('✅ Default Admin Created (admin / admin123)');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Tables Initialized');
|
||||
// Create asset_manuals table
|
||||
const manualTable = `
|
||||
CREATE TABLE IF NOT EXISTS asset_manuals (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_id VARCHAR(20) NOT NULL,
|
||||
file_name VARCHAR(255) NOT NULL,
|
||||
file_url VARCHAR(255) NOT NULL,
|
||||
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(manualTable);
|
||||
|
||||
// Create camera_settings table
|
||||
const cameraTable = `
|
||||
CREATE TABLE IF NOT EXISTS camera_settings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
ip_address VARCHAR(100) NOT NULL,
|
||||
port INT DEFAULT 554,
|
||||
username VARCHAR(100),
|
||||
password VARCHAR(100),
|
||||
stream_path VARCHAR(200) DEFAULT '/stream1',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`;
|
||||
await db.query(cameraTable);
|
||||
|
||||
// Check for 'transport_mode' and 'rtsp_encoding' columns and add if missing
|
||||
const [camColumns] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'transport_mode'");
|
||||
if (camColumns.length === 0) {
|
||||
await db.query("ALTER TABLE camera_settings ADD COLUMN transport_mode ENUM('tcp', 'udp', 'auto') DEFAULT 'tcp' AFTER stream_path");
|
||||
await db.query("ALTER TABLE camera_settings ADD COLUMN rtsp_encoding BOOLEAN DEFAULT FALSE AFTER transport_mode");
|
||||
await db.query("ALTER TABLE camera_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode"); // Default to low for stability
|
||||
console.log("✅ Added 'transport_mode', 'quality', and 'rtsp_encoding' columns to camera_settings");
|
||||
} else {
|
||||
// Check if quality exists (for subsequent updates)
|
||||
const [qualCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'quality'");
|
||||
if (qualCol.length === 0) {
|
||||
await db.query("ALTER TABLE camera_settings ADD COLUMN quality ENUM('low', 'medium', 'original') DEFAULT 'low' AFTER transport_mode");
|
||||
console.log("✅ Added 'quality' column to camera_settings");
|
||||
}
|
||||
}
|
||||
|
||||
// Check for 'display_order' column
|
||||
const [orderCol] = await db.query("SHOW COLUMNS FROM camera_settings LIKE 'display_order'");
|
||||
if (orderCol.length === 0) {
|
||||
await db.query("ALTER TABLE camera_settings ADD COLUMN display_order INT DEFAULT 0 AFTER quality");
|
||||
console.log("✅ Added 'display_order' column to camera_settings");
|
||||
}
|
||||
|
||||
// Create system_settings table (Key-Value store)
|
||||
const systemSettingsTable = `
|
||||
CREATE TABLE IF NOT EXISTS system_settings (
|
||||
setting_key VARCHAR(50) PRIMARY KEY,
|
||||
setting_value TEXT,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`;
|
||||
await db.query(systemSettingsTable);
|
||||
|
||||
// Create system_modules table
|
||||
const systemModulesTable = `
|
||||
CREATE TABLE IF NOT EXISTS system_modules (
|
||||
code VARCHAR(50) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
is_active BOOLEAN DEFAULT FALSE,
|
||||
license_key TEXT,
|
||||
license_type VARCHAR(20),
|
||||
expiry_date DATETIME,
|
||||
subscriber_id VARCHAR(50),
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`;
|
||||
await db.query(systemModulesTable);
|
||||
|
||||
// Create license_history table
|
||||
const licenseHistoryTable = `
|
||||
CREATE TABLE IF NOT EXISTS license_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
module_code VARCHAR(50) NOT NULL,
|
||||
license_key TEXT,
|
||||
license_type VARCHAR(20),
|
||||
subscriber_id VARCHAR(50),
|
||||
activated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
replaced_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`;
|
||||
await db.query(licenseHistoryTable);
|
||||
|
||||
// Create issued_licenses table (for tracking generated keys)
|
||||
const issuedLicensesTable = `
|
||||
CREATE TABLE IF NOT EXISTS issued_licenses (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
module_code VARCHAR(50) NOT NULL,
|
||||
license_key TEXT,
|
||||
license_type VARCHAR(20),
|
||||
subscriber_id VARCHAR(50),
|
||||
status VARCHAR(20) DEFAULT 'ISSUED', -- ISSUED, ACTIVE, EXPIRED, REVOKED
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
activation_deadline DATETIME
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`;
|
||||
await db.query(issuedLicensesTable);
|
||||
|
||||
// Check for 'subscriber_id' column in system_modules (for existing installations)
|
||||
const [modColumns] = await db.query("SHOW COLUMNS FROM system_modules LIKE 'subscriber_id'");
|
||||
if (modColumns.length === 0) {
|
||||
await db.query("ALTER TABLE system_modules ADD COLUMN subscriber_id VARCHAR(50) AFTER expiry_date");
|
||||
console.log("✅ Added 'subscriber_id' column to system_modules");
|
||||
}
|
||||
|
||||
// Initialize default modules if empty (Disabled by default as per request behavior)
|
||||
const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1');
|
||||
if (existingModules.length === 0) {
|
||||
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`;
|
||||
await db.query(insert, ['asset', '자산 관리', true, 'dev']);
|
||||
await db.query(insert, ['production', '생산 관리', false, null]);
|
||||
await db.query(insert, ['monitoring', 'CCTV', false, null]);
|
||||
}
|
||||
|
||||
console.log('✅ Tables Initialized');
|
||||
} catch (err) {
|
||||
console.error('❌ Table Initialization Failed:', err);
|
||||
}
|
||||
};
|
||||
initTables();
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', version: '1.2.0', timestamp: '2026-01-22 21:18' });
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api', authRoutes);
|
||||
app.use('/api/cameras', cameraRoutes);
|
||||
app.use('/api/system', require('./routes/system'));
|
||||
|
||||
// Protect following routes
|
||||
app.use(['/api/assets', '/api/maintenance', '/api/manuals', '/api/upload'], isAuthenticated);
|
||||
|
||||
// 0. File Upload Endpoint
|
||||
app.post('/api/upload', upload.single('image'), (req, res) => {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
// Return the full URL or relative path
|
||||
// Assuming server is accessed via same host, we just return the path
|
||||
const fileUrl = `/uploads/${req.file.filename}`;
|
||||
res.json({ url: fileUrl });
|
||||
});
|
||||
|
||||
// 1. Get All Assets
|
||||
app.get('/api/assets', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM assets ORDER BY created_at DESC');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Get Asset by ID
|
||||
app.get('/api/assets/:id', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM assets WHERE id = ?', [req.params.id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Create Asset
|
||||
app.post('/api/assets', async (req, res) => {
|
||||
const { id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body;
|
||||
|
||||
try {
|
||||
const sql = `
|
||||
INSERT INTO assets (id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
await db.query(sql, [id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url]);
|
||||
res.status(201).json({ message: 'Asset created', id });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Update Asset
|
||||
app.put('/api/assets/:id', async (req, res) => {
|
||||
const { name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url } = req.body;
|
||||
|
||||
try {
|
||||
const sql = `
|
||||
UPDATE assets
|
||||
SET name=?, category=?, model_name=?, serial_number=?, manufacturer=?, location=?, purchase_date=?, manager=?, status=?, specs=?, purchase_price=?, image_url=?
|
||||
WHERE id=?
|
||||
`;
|
||||
const [result] = await db.query(sql, [name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs, purchase_price, image_url, req.params.id]);
|
||||
|
||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Asset not found' });
|
||||
res.json({ message: 'Asset updated' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Get All Maintenance Records for an Asset
|
||||
app.get('/api/assets/:asset_id/maintenance', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM maintenance_history WHERE asset_id = ? ORDER BY maintenance_date DESC', [req.params.asset_id]);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 6. Get Single Maintenance Record by ID
|
||||
app.get('/api/maintenance/:id', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM maintenance_history WHERE id = ?', [req.params.id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Maintenance record not found' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 7. Create Maintenance Record
|
||||
app.post('/api/assets/:asset_id/maintenance', async (req, res) => {
|
||||
const { maintenance_date, type, content, images } = req.body;
|
||||
const { asset_id } = req.params;
|
||||
|
||||
try {
|
||||
const sql = `
|
||||
INSERT INTO maintenance_history (asset_id, maintenance_date, type, content, images)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
`;
|
||||
const [result] = await db.query(sql, [asset_id, maintenance_date, type, content, JSON.stringify(images || [])]);
|
||||
res.status(201).json({ message: 'Maintenance record created', id: result.insertId });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 8. Update Maintenance Record
|
||||
app.put('/api/maintenance/:id', async (req, res) => {
|
||||
const { maintenance_date, type, content, images } = req.body;
|
||||
|
||||
try {
|
||||
const sql = `
|
||||
UPDATE maintenance_history
|
||||
SET maintenance_date=?, type=?, content=?, images=?
|
||||
WHERE id=?
|
||||
`;
|
||||
const [result] = await db.query(sql, [maintenance_date, type, content, JSON.stringify(images || []), req.params.id]);
|
||||
|
||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Maintenance record not found' });
|
||||
res.json({ message: 'Maintenance record updated' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 9. Delete Maintenance Record
|
||||
app.delete('/api/maintenance/:id', async (req, res) => {
|
||||
try {
|
||||
const [result] = await db.query('DELETE FROM maintenance_history WHERE id = ?', [req.params.id]);
|
||||
|
||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Maintenance record not found' });
|
||||
res.json({ message: 'Maintenance record deleted' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 10. Get Manuals for an Asset
|
||||
app.get('/api/assets/:asset_id/manuals', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM asset_manuals WHERE asset_id = ? ORDER BY created_at DESC', [req.params.asset_id]);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 11. Add Manual to Asset
|
||||
app.post('/api/assets/:asset_id/manuals', async (req, res) => {
|
||||
const { file_name, file_url } = req.body;
|
||||
const { asset_id } = req.params;
|
||||
|
||||
try {
|
||||
const sql = `INSERT INTO asset_manuals (asset_id, file_name, file_url) VALUES (?, ?, ?)`;
|
||||
const [result] = await db.query(sql, [asset_id, file_name, file_url]);
|
||||
res.status(201).json({ message: 'Manual added', id: result.insertId });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 12. Delete Manual
|
||||
app.delete('/api/manuals/:id', async (req, res) => {
|
||||
try {
|
||||
const [result] = await db.query('DELETE FROM asset_manuals WHERE id = ?', [req.params.id]);
|
||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Manual not found' });
|
||||
res.json({ message: 'Manual deleted' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Serve Frontend Static Files (Production/Deployment)
|
||||
app.use(express.static(path.join(__dirname, '../dist')));
|
||||
|
||||
// The "catchall" handler: for any request that doesn't
|
||||
// match one above, send back React's index.html file.
|
||||
app.get(/(.*)/, (req, res) => {
|
||||
res.sendFile(path.join(__dirname, '../dist/index.html'));
|
||||
});
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`Server running on port ${PORT}`);
|
||||
});
|
||||
|
||||
// Initialize Stream Relay
|
||||
const streamRelay = new StreamRelay(server);
|
||||
app.set('streamRelay', streamRelay);
|
||||
52
server/init_users.js
Normal file
52
server/init_users.js
Normal file
@ -0,0 +1,52 @@
|
||||
const db = require('./db');
|
||||
const crypto = require('crypto');
|
||||
|
||||
async function init() {
|
||||
try {
|
||||
console.log('Initializing Users Table...');
|
||||
|
||||
const createTableSQL = `
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id VARCHAR(50) PRIMARY KEY COMMENT '아이디',
|
||||
password VARCHAR(255) NOT NULL COMMENT '비밀번호 (Hash)',
|
||||
name VARCHAR(100) NOT NULL COMMENT '이름',
|
||||
department VARCHAR(100) COMMENT '부서',
|
||||
position VARCHAR(100) COMMENT '직위',
|
||||
phone VARCHAR(255) COMMENT '핸드폰 (Encrypted)',
|
||||
role ENUM('admin', 'user') DEFAULT 'user' COMMENT '권한',
|
||||
last_login TIMESTAMP NULL COMMENT '마지막 로그인',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='사용자 관리';
|
||||
`;
|
||||
|
||||
await db.query(createTableSQL);
|
||||
console.log('✅ Users Table Created/Verified.');
|
||||
|
||||
const [rows] = await db.query('SELECT * FROM users WHERE id = ?', ['admin']);
|
||||
if (rows.length === 0) {
|
||||
// Create default admin
|
||||
const password = 'admin123';
|
||||
// Simple hash for MVP: SHA-256. In production, use bcrypt or scrypt with salt.
|
||||
const hashedPassword = crypto.createHash('sha256').update(password).digest('hex');
|
||||
|
||||
// Placeholder for encrypted phone
|
||||
const encryptedPhone = 'pending_encryption';
|
||||
|
||||
await db.query(
|
||||
'INSERT INTO users (id, password, name, role, department, position, phone) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
['admin', hashedPassword, '시스템 관리자', 'admin', 'IT팀', '관리자', encryptedPhone]
|
||||
);
|
||||
console.log('✅ Default Admin Account Created (admin / admin123)');
|
||||
} else {
|
||||
console.log('ℹ️ Admin account already exists.');
|
||||
}
|
||||
|
||||
process.exit(0);
|
||||
} catch (err) {
|
||||
console.error('❌ Initialization Failed:', err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
22
server/middleware/authMiddleware.js
Normal file
22
server/middleware/authMiddleware.js
Normal file
@ -0,0 +1,22 @@
|
||||
const isAuthenticated = (req, res, next) => {
|
||||
if (req.session && req.session.user) {
|
||||
return next();
|
||||
}
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
};
|
||||
|
||||
const hasRole = (...roles) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.session || !req.session.user) {
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
if (roles.includes(req.session.user.role)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
return res.status(403).json({ success: false, message: 'Forbidden: Insufficient permissions' });
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = { isAuthenticated, hasRole };
|
||||
34
server/middleware/csrfMiddleware.js
Normal file
34
server/middleware/csrfMiddleware.js
Normal file
@ -0,0 +1,34 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
// Generate a random token
|
||||
const generateToken = () => {
|
||||
return crypto.randomBytes(24).toString('hex');
|
||||
};
|
||||
|
||||
// Middleware to protect against CSRF
|
||||
const csrfProtection = (req, res, next) => {
|
||||
// Skip for GET, HEAD, OPTIONS (safe methods)
|
||||
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Skip for Login endpoint (initial session creation)
|
||||
if (req.path === '/api/login' || req.path === '/api/auth/login') {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Get token from header
|
||||
const tokenFromHeader = req.headers['x-csrf-token'];
|
||||
|
||||
// Get token from session
|
||||
const tokenFromSession = req.session.csrfToken;
|
||||
|
||||
if (!tokenFromSession || !tokenFromHeader || tokenFromSession !== tokenFromHeader) {
|
||||
console.error('CSRF mismatch:', { session: tokenFromSession, header: tokenFromHeader });
|
||||
return res.status(403).json({ success: false, message: 'Invalid CSRF Token' });
|
||||
}
|
||||
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { generateToken, csrfProtection };
|
||||
141
server/modules/cctv/routes.js
Normal file
141
server/modules/cctv/routes.js
Normal file
@ -0,0 +1,141 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../../db');
|
||||
const { isAuthenticated, hasRole } = require('../../middleware/authMiddleware');
|
||||
|
||||
// Get all cameras
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM camera_settings ORDER BY display_order ASC, created_at DESC');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Reorder cameras (Admin only)
|
||||
router.put('/reorder', hasRole('admin'), async (req, res) => {
|
||||
const { cameras } = req.body; // Array of { id, display_order } or just ordered IDs
|
||||
if (!Array.isArray(cameras)) {
|
||||
return res.status(400).json({ error: 'Invalid data format' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Use a transaction for safety
|
||||
await db.query('START TRANSACTION');
|
||||
|
||||
for (let i = 0; i < cameras.length; i++) {
|
||||
const cam = cameras[i];
|
||||
// If receiving array of IDs, use index as order
|
||||
const id = typeof cam === 'object' ? cam.id : cam;
|
||||
const order = i;
|
||||
|
||||
await db.query('UPDATE camera_settings SET display_order = ? WHERE id = ?', [order, id]);
|
||||
}
|
||||
|
||||
await db.query('COMMIT');
|
||||
res.json({ message: 'Cameras reordered' });
|
||||
} catch (err) {
|
||||
await db.query('ROLLBACK');
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get single camera
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
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' });
|
||||
res.json(rows[0]);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Add camera (Admin only)
|
||||
router.post('/', hasRole('admin'), async (req, res) => {
|
||||
const { name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
|
||||
try {
|
||||
const sql = `INSERT INTO camera_settings (name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`;
|
||||
const [result] = await db.query(sql, [name, ip_address, port || 554, username, password, stream_path || '/stream1', transport_mode || 'tcp', rtsp_encoding || false, quality || 'low']);
|
||||
res.status(201).json({ message: 'Camera added', id: result.insertId });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Update camera (Admin only)
|
||||
router.put('/:id', hasRole('admin'), async (req, res) => {
|
||||
const { name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality } = req.body;
|
||||
try {
|
||||
const sql = `UPDATE camera_settings SET name=?, ip_address=?, port=?, username=?, password=?, stream_path=?, transport_mode=?, rtsp_encoding=?, quality=? WHERE id=?`;
|
||||
const [result] = await db.query(sql, [name, ip_address, port, username, password, stream_path, transport_mode, rtsp_encoding, quality, req.params.id]);
|
||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
||||
|
||||
// Force stream reset (kick clients to trigger reconnect)
|
||||
const streamRelay = req.app.get('streamRelay');
|
||||
if (streamRelay) {
|
||||
console.log(`Settings changed for camera ${req.params.id}, resetting stream...`);
|
||||
streamRelay.resetStream(req.params.id);
|
||||
}
|
||||
|
||||
res.json({ message: 'Camera updated' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete camera (Admin only)
|
||||
router.delete('/:id', hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const [result] = await db.query('DELETE FROM camera_settings WHERE id = ?', [req.params.id]);
|
||||
if (result.affectedRows === 0) return res.status(404).json({ error: 'Camera not found' });
|
||||
|
||||
// Stop stream
|
||||
const streamRelay = req.app.get('streamRelay');
|
||||
if (streamRelay) {
|
||||
streamRelay.stopStream(req.params.id);
|
||||
}
|
||||
|
||||
res.json({ message: 'Camera deleted' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
const { exec } = require('child_process');
|
||||
|
||||
// ... existing routes ...
|
||||
|
||||
// 7. Ping Test (Troubleshooting)
|
||||
router.get('/:id/ping', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT ip_address FROM camera_settings WHERE id = ?', [req.params.id]);
|
||||
if (rows.length === 0) return res.status(404).json({ error: 'Camera not found' });
|
||||
|
||||
const ip = rows[0].ip_address;
|
||||
|
||||
// Simple ping command
|
||||
const platform = process.platform;
|
||||
const cmd = platform === 'win32' ? `ping -n 1 ${ip}` : `ping -c 1 ${ip}`;
|
||||
|
||||
exec(cmd, (error, stdout, stderr) => {
|
||||
res.json({
|
||||
ip,
|
||||
success: !error,
|
||||
output: stdout,
|
||||
error: stderr
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Ping failed' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
248
server/modules/cctv/streamRelay.js
Normal file
248
server/modules/cctv/streamRelay.js
Normal file
@ -0,0 +1,248 @@
|
||||
const WebSocket = require('ws');
|
||||
const ffmpeg = require('fluent-ffmpeg');
|
||||
const ffmpegPath = require('ffmpeg-static');
|
||||
const db = require('../../db');
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
// Determine FFmpeg path: Prefer system install on Synology, fallback to static
|
||||
// Determine FFmpeg path: Prioritize Community packages (RTSP support) > System (limited) > Static
|
||||
let systemFfmpegPath = ffmpegPath;
|
||||
|
||||
// Potential paths for fully-featured FFmpeg (SynoCommunity, etc.)
|
||||
// User 'find /usr' showed it's NOT in /usr/local/bin, so we must look in package dirs.
|
||||
const priorityPaths = [
|
||||
// SynoCommunity or Package Center standard paths
|
||||
'/var/packages/ffmpeg/target/bin/ffmpeg',
|
||||
'/var/packages/ffmpeg7/target/bin/ffmpeg',
|
||||
'/var/packages/ffmpeg6/target/bin/ffmpeg',
|
||||
|
||||
// Direct volume paths (common on Synology)
|
||||
'/volume1/@appstore/ffmpeg/bin/ffmpeg',
|
||||
'/volume1/@appstore/ffmpeg7/bin/ffmpeg',
|
||||
'/volume1/@appstore/ffmpeg6/bin/ffmpeg',
|
||||
|
||||
// Standard Linux paths
|
||||
'/usr/local/bin/ffmpeg',
|
||||
'/usr/bin/ffmpeg',
|
||||
'/bin/ffmpeg'
|
||||
];
|
||||
|
||||
let foundPath = false;
|
||||
for (const path of priorityPaths) {
|
||||
if (fs.existsSync(path)) {
|
||||
systemFfmpegPath = path;
|
||||
console.log(`[StreamRelay] Found FFmpeg at: ${path}`);
|
||||
// If we found a path that is NOT the system default (which we know is broken), likely we are good.
|
||||
// But even if it is system default, we use it if nothing else found.
|
||||
if (path !== '/usr/bin/ffmpeg' && path !== '/bin/ffmpeg') {
|
||||
console.log('[StreamRelay] Using generic/community FFmpeg (likely supports RTSP).');
|
||||
} else {
|
||||
console.warn('[StreamRelay] WARNING: Using system FFmpeg. RTSP might fail ("Protocol not found").');
|
||||
}
|
||||
foundPath = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundPath) {
|
||||
console.warn('[StreamRelay] No FFmpeg binary found in priority paths. Falling back to ffmpeg-static or default PATH.');
|
||||
}
|
||||
|
||||
ffmpeg.setFfmpegPath(systemFfmpegPath);
|
||||
|
||||
class StreamRelay {
|
||||
constructor(server) {
|
||||
this.wss = new WebSocket.Server({ server, path: '/api/stream' });
|
||||
this.streams = new Map(); // cameraId -> { process, clients: Set }
|
||||
|
||||
this.wss.on('connection', (ws, req) => {
|
||||
const cameraId = this.parseCameraId(req.url);
|
||||
if (!cameraId) {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Client connected to stream for camera ${cameraId}`);
|
||||
this.addClient(cameraId, ws);
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log(`Client disconnected from stream for camera ${cameraId}`);
|
||||
this.removeClient(cameraId, ws);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
parseCameraId(url) {
|
||||
// url format: /api/stream?cameraId=1 or /api/stream/1 (if path matching works that way, but ws path is fixed)
|
||||
// Let's use query param: ws://host/api/stream?cameraId=1
|
||||
try {
|
||||
const params = new URLSearchParams(url.split('?')[1]);
|
||||
return params.get('cameraId');
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async addClient(cameraId, ws) {
|
||||
let stream = this.streams.get(cameraId);
|
||||
if (!stream) {
|
||||
stream = { clients: new Set(), process: null };
|
||||
this.streams.set(cameraId, stream);
|
||||
await this.startFFmpeg(cameraId);
|
||||
}
|
||||
stream.clients.add(ws);
|
||||
}
|
||||
|
||||
removeClient(cameraId, ws) {
|
||||
const stream = this.streams.get(cameraId);
|
||||
if (stream) {
|
||||
stream.clients.delete(ws);
|
||||
if (stream.clients.size === 0) {
|
||||
this.stopStream(cameraId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Forcefully disconnect all clients to trigger reconnection
|
||||
resetStream(cameraId) {
|
||||
const stream = this.streams.get(cameraId);
|
||||
if (stream) {
|
||||
console.log(`Resetting stream for camera ${cameraId} (closing ${stream.clients.size} clients)...`);
|
||||
// Close all clients. This will trigger 'close' event on ws,
|
||||
// calling removeClient -> stopStream.
|
||||
for (const client of stream.clients) {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.close(1000, "Stream Reset");
|
||||
}
|
||||
}
|
||||
// Ensure stream is stopped even if no clients existed
|
||||
this.stopStream(cameraId);
|
||||
}
|
||||
}
|
||||
|
||||
async startFFmpeg(cameraId) {
|
||||
const stream = this.streams.get(cameraId);
|
||||
if (!stream) return;
|
||||
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM camera_settings WHERE id = ?', [cameraId]);
|
||||
if (rows.length === 0) {
|
||||
console.error(`Camera ${cameraId} not found`);
|
||||
return;
|
||||
}
|
||||
const camera = rows[0];
|
||||
|
||||
// Construct RTSP URL
|
||||
let rtspUrl = 'rtsp://';
|
||||
if (camera.username && camera.password) {
|
||||
const user = camera.rtsp_encoding ? encodeURIComponent(camera.username) : camera.username;
|
||||
const pass = camera.rtsp_encoding ? encodeURIComponent(camera.password) : camera.password;
|
||||
rtspUrl += `${user}:${pass}@`;
|
||||
}
|
||||
rtspUrl += `${camera.ip_address}:${camera.port || 554}${camera.stream_path || '/stream1'}`;
|
||||
|
||||
console.log(`Starting FFmpeg for camera ${cameraId} (${camera.name})...`);
|
||||
|
||||
const transportMode = camera.transport_mode === 'udp' ? 'udp' : 'tcp';
|
||||
|
||||
// Determine Quality Scaling
|
||||
let scaleFilter = [];
|
||||
let videoBitrate = '1000k';
|
||||
|
||||
const qual = camera.quality || 'low';
|
||||
|
||||
if (qual === 'low') {
|
||||
scaleFilter = ['-vf scale=640:-1'];
|
||||
videoBitrate = '1000k';
|
||||
} else if (qual === 'medium') {
|
||||
scaleFilter = ['-vf scale=1280:-1'];
|
||||
videoBitrate = '2500k';
|
||||
} else {
|
||||
// Original - Restore high bitrate as requested
|
||||
videoBitrate = '6000k';
|
||||
}
|
||||
|
||||
// Compatibility: Synology FFmpeg might not support -rtsp_transport flag or strict ordering
|
||||
// Try removing explicit transport mode if it fails, or rely on Auto.
|
||||
const inputOptions = [];
|
||||
if (transportMode !== 'auto') {
|
||||
// We restored this because we are now ensuring a proper FFmpeg version (v6/7 or static) is used.
|
||||
// REVERT: User reported local Windows issues with this flag enforced.
|
||||
// Commenting out to restore local functionality. Synology should still work via Auto or default.
|
||||
// inputOptions.push(`-rtsp_transport ${transportMode}`);
|
||||
}
|
||||
|
||||
console.log(`[StreamRelay] Using FFmpeg binary: ${systemFfmpegPath}`);
|
||||
|
||||
const command = ffmpeg(rtspUrl)
|
||||
.inputOptions(inputOptions)
|
||||
// .inputOptions([
|
||||
// `-rtsp_transport ${transportMode}`
|
||||
// ])
|
||||
.addOptions([
|
||||
'-c:v mpeg1video', // Video codec for JSMpeg
|
||||
'-f mpegts', // Output format
|
||||
'-codec:a mp2', // Audio codec
|
||||
`-b:v ${videoBitrate}`, // Dynamic Video bitrate
|
||||
`-maxrate ${videoBitrate}`, // Cap max bitrate
|
||||
`-bufsize ${videoBitrate}`,
|
||||
'-r 30', // FPS 30 (Restored)
|
||||
'-g 30', // GOP size 30 (1 keyframe per sec)
|
||||
'-bf 0', // No B-frames
|
||||
...scaleFilter
|
||||
])
|
||||
.on('start', (cmdLine) => {
|
||||
console.log('FFmpeg started:', cmdLine);
|
||||
})
|
||||
.on('error', (err) => {
|
||||
// Only log if not killed manually (checking active stream usually tricky, but error msg usually enough)
|
||||
if (!err.message.includes('SIGKILL')) {
|
||||
console.error('FFmpeg error:', err.message);
|
||||
// If error occurs, maybe stop stream?
|
||||
// For reload, we might retry? simple stop for now.
|
||||
this.stopStream(cameraId);
|
||||
}
|
||||
})
|
||||
.on('end', () => {
|
||||
console.log('FFmpeg exited');
|
||||
// We don't indiscriminately stopStream here because 'reload' causes an exit too.
|
||||
// But 'stopStream' checks if process matches?
|
||||
// Let's rely on the process being replaced or nullified.
|
||||
});
|
||||
|
||||
stream.process = command;
|
||||
|
||||
const ffstream = command.pipe();
|
||||
|
||||
ffstream.on('data', (data) => {
|
||||
stream.clients.forEach(client => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
} catch (err) {
|
||||
console.error('Failed to start stream:', err);
|
||||
this.stopStream(cameraId);
|
||||
}
|
||||
}
|
||||
|
||||
stopStream(cameraId) {
|
||||
const stream = this.streams.get(cameraId);
|
||||
if (stream) {
|
||||
console.log(`Stopping stream for camera ${cameraId}`);
|
||||
if (stream.process) {
|
||||
try {
|
||||
stream.process.kill('SIGKILL');
|
||||
} catch (e) {
|
||||
console.error('Error killing ffmpeg:', e);
|
||||
}
|
||||
}
|
||||
this.streams.delete(cameraId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StreamRelay;
|
||||
1814
server/package-lock.json
generated
Normal file
1814
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
server/package.json
Normal file
30
server/package.json
Normal file
@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"express-mysql-session": "^3.0.3",
|
||||
"express-session": "^1.18.2",
|
||||
"ffmpeg-static": "^5.3.0",
|
||||
"fluent-ffmpeg": "^2.1.3",
|
||||
"multer": "^2.0.2",
|
||||
"mysql2": "^3.16.1",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.11"
|
||||
}
|
||||
}
|
||||
251
server/routes/auth.js
Normal file
251
server/routes/auth.js
Normal file
@ -0,0 +1,251 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
const crypto = require('crypto');
|
||||
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
|
||||
const { generateToken } = require('../middleware/csrfMiddleware');
|
||||
|
||||
// --- 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 || 'smartasset_secret_key_0123456789'; // 32 chars needed
|
||||
// Ideally use a buffer from hex, but string is okay if 32 chars.
|
||||
// Let's pad it to ensure stability if env is missing.
|
||||
const keyBuffer = crypto.scryptSync(SECRET_KEY, 'salt', 32);
|
||||
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
|
||||
function encrypt(text) {
|
||||
if (!text) return text;
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
}
|
||||
|
||||
function decrypt(text) {
|
||||
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) {
|
||||
return crypto.createHash('sha256').update(password).digest('hex');
|
||||
}
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
// 1. Login
|
||||
router.post('/login', async (req, res) => {
|
||||
const { id, password } = req.body;
|
||||
try {
|
||||
const hashedPassword = hashPassword(password);
|
||||
const [rows] = await db.query('SELECT * FROM users WHERE id = ? AND password = ?', [id, hashedPassword]);
|
||||
|
||||
if (rows.length > 0) {
|
||||
const user = rows[0];
|
||||
// Update last_login
|
||||
await db.query('UPDATE users SET last_login = NOW() WHERE id = ?', [user.id]);
|
||||
|
||||
// Remove sensitive data
|
||||
delete user.password;
|
||||
|
||||
// Should we decrypt phone? Maybe not needed for session, but let's decrypt just in case UI needs it
|
||||
if (user.phone) user.phone = decrypt(user.phone);
|
||||
|
||||
// Save user to session
|
||||
req.session.user = user;
|
||||
|
||||
// Generate CSRF Token
|
||||
const csrfToken = generateToken();
|
||||
req.session.csrfToken = csrfToken;
|
||||
|
||||
// Explicitly save session before response (optional but safer for race conditions)
|
||||
req.session.save(err => {
|
||||
if (err) {
|
||||
console.error('Session save error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Session error' });
|
||||
}
|
||||
res.json({ success: true, user, csrfToken });
|
||||
});
|
||||
} else {
|
||||
res.status(401).json({ success: false, message: 'Invalid credentials' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Login error:', err);
|
||||
res.status(500).json({ success: false, message: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 1.5. Check Session (New)
|
||||
router.get('/check', (req, res) => {
|
||||
if (req.session.user) {
|
||||
// Ensure CSRF token exists, if not generate one (edge case)
|
||||
if (!req.session.csrfToken) {
|
||||
req.session.csrfToken = generateToken();
|
||||
}
|
||||
res.json({
|
||||
isAuthenticated: true,
|
||||
user: req.session.user,
|
||||
csrfToken: req.session.csrfToken
|
||||
});
|
||||
} else {
|
||||
res.json({ isAuthenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
// 1.6. Logout (New)
|
||||
router.post('/logout', (req, res) => {
|
||||
req.session.destroy(err => {
|
||||
if (err) {
|
||||
console.error('Logout error:', err);
|
||||
return res.status(500).json({ success: false, message: 'Logout failed' });
|
||||
}
|
||||
res.clearCookie('smartasset_sid'); // matching key in index.js
|
||||
res.json({ success: true, message: 'Logged out' });
|
||||
});
|
||||
});
|
||||
|
||||
// 2. List Users (Admin Only)
|
||||
router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
// ideally check req.user.role if we had middleware, for now assuming client logic protection + internal/local usage
|
||||
const [rows] = await db.query('SELECT id, name, department, position, phone, role, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
|
||||
|
||||
const users = rows.map(u => ({
|
||||
...u,
|
||||
phone: decrypt(u.phone) // Decrypt phone for admin view
|
||||
}));
|
||||
|
||||
res.json(users);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Create User
|
||||
router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { id, password, name, department, position, phone, role } = req.body;
|
||||
|
||||
if (!id || !password || !name) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
}
|
||||
|
||||
try {
|
||||
// Check if ID exists
|
||||
const [existing] = await db.query('SELECT id FROM users WHERE id = ?', [id]);
|
||||
if (existing.length > 0) {
|
||||
return res.status(409).json({ error: 'User ID already exists' });
|
||||
}
|
||||
|
||||
const hashedPassword = hashPassword(password);
|
||||
const encryptedPhone = encrypt(phone);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO users (id, password, name, department, position, phone, role)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
await db.query(sql, [id, hashedPassword, name, department, position, encryptedPhone, role || 'user']);
|
||||
|
||||
res.status(201).json({ message: 'User created' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Update User
|
||||
router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { password, name, department, position, phone, role } = req.body;
|
||||
const userId = req.params.id;
|
||||
|
||||
try {
|
||||
// Fetch current to keep old values if not provided? Frontend usually sends all.
|
||||
// We update everything provided.
|
||||
|
||||
// Build query dynamically or just assume full update
|
||||
let updates = [];
|
||||
let params = [];
|
||||
|
||||
if (password) {
|
||||
updates.push('password = ?');
|
||||
params.push(hashPassword(password));
|
||||
}
|
||||
if (name) {
|
||||
updates.push('name = ?');
|
||||
params.push(name);
|
||||
}
|
||||
if (department !== undefined) {
|
||||
updates.push('department = ?');
|
||||
params.push(department);
|
||||
}
|
||||
if (position !== undefined) {
|
||||
updates.push('position = ?');
|
||||
params.push(position);
|
||||
}
|
||||
if (phone !== undefined) {
|
||||
updates.push('phone = ?');
|
||||
params.push(encrypt(phone));
|
||||
}
|
||||
if (role) {
|
||||
updates.push('role = ?');
|
||||
params.push(role);
|
||||
}
|
||||
|
||||
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);
|
||||
res.json({ message: 'User updated' });
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Delete User
|
||||
router.delete('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
// Prevent deleting last admin? Optional.
|
||||
// Prevent deleting self?
|
||||
|
||||
await db.query('DELETE FROM users WHERE id = ?', [req.params.id]);
|
||||
res.json({ message: 'User deleted' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
201
server/routes/system.js
Normal file
201
server/routes/system.js
Normal file
@ -0,0 +1,201 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const db = require('../db');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
|
||||
const { generateLicense, verifyLicense } = require('../utils/licenseManager');
|
||||
const { checkRemoteKey } = require('../utils/remoteLicense');
|
||||
|
||||
// Load Public Key for Verification
|
||||
const publicKeyPath = path.join(__dirname, '../public_key.pem');
|
||||
let publicKey = null;
|
||||
try {
|
||||
if (fs.existsSync(publicKeyPath)) {
|
||||
publicKey = fs.readFileSync(publicKeyPath, 'utf8');
|
||||
} else {
|
||||
console.error('WARNING: public_key.pem not found in server root. License verification will fail.');
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading public key:', e);
|
||||
}
|
||||
|
||||
// 0. Server Configuration (Subscriber ID)
|
||||
router.get('/config', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
||||
res.json({ subscriber_id: rows.length > 0 ? rows[0].setting_value : '' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/config', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { subscriber_id } = req.body;
|
||||
if (!subscriber_id) return res.status(400).json({ error: 'Subscriber ID is required' });
|
||||
|
||||
try {
|
||||
const sql = `INSERT INTO system_settings (setting_key, setting_value) VALUES ('subscriber_id', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`;
|
||||
await db.query(sql, [subscriber_id]);
|
||||
res.json({ message: 'Subscriber ID saved' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Get All Modules Status
|
||||
router.get('/modules', isAuthenticated, async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM system_modules');
|
||||
|
||||
const modules = {};
|
||||
const defaults = ['asset', 'production', 'monitoring'];
|
||||
|
||||
// Get stored subscriber ID
|
||||
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
||||
const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null;
|
||||
|
||||
defaults.forEach(code => {
|
||||
const found = rows.find(r => r.code === code);
|
||||
if (found) {
|
||||
modules[code] = {
|
||||
active: !!found.is_active,
|
||||
type: found.license_type,
|
||||
expiry: found.expiry_date,
|
||||
subscriber_id: found.subscriber_id // Return who verified it
|
||||
};
|
||||
} else {
|
||||
modules[code] = { active: false, type: null, expiry: null, subscriber_id: null };
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ modules, serverSubscriberId });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Activate Module (Apply License)
|
||||
// Only admin can manage system modules
|
||||
router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { code } = req.params;
|
||||
let { licenseKey } = req.body;
|
||||
|
||||
if (!licenseKey) {
|
||||
return res.status(400).json({ error: 'License key is required' });
|
||||
}
|
||||
|
||||
licenseKey = licenseKey.trim();
|
||||
|
||||
// 1. Verify Key validity
|
||||
const result = verifyLicense(licenseKey, publicKey);
|
||||
if (!result.isValid) {
|
||||
return res.status(400).json({ error: `Invalid License: ${result.reason}` });
|
||||
}
|
||||
|
||||
// 2. Check Module match
|
||||
if (result.module !== code) {
|
||||
return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` });
|
||||
}
|
||||
|
||||
// 3. Check Subscriber match
|
||||
try {
|
||||
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
||||
const serverSubscriberId = subRows.length > 0 ? subRows[0].setting_value : null;
|
||||
|
||||
if (!serverSubscriberId) {
|
||||
return res.status(400).json({ error: 'Server Subscriber ID is not set. Please configure it in settings first.' });
|
||||
}
|
||||
|
||||
if (result.subscriberId !== serverSubscriberId) {
|
||||
return res.status(403).json({
|
||||
error: `License Subscriber Mismatch. Key is for '${result.subscriberId}', but Server is '${serverSubscriberId}'.`
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Archive Current License if exists
|
||||
const [current] = await db.query('SELECT * FROM system_modules WHERE code = ?', [code]);
|
||||
if (current.length > 0 && current[0].is_active && current[0].license_key) {
|
||||
const old = current[0];
|
||||
const historySql = `
|
||||
INSERT INTO license_history (module_code, license_key, license_type, subscriber_id, activated_at)
|
||||
VALUES (?, ?, ?, ?, NOW())
|
||||
`;
|
||||
// Store "activated_at" as roughly the time it was active until now (or simply log the event time)
|
||||
// Actually, let's treat "activated_at" in history as "when it was archived/replaced".
|
||||
await db.query(historySql, [old.code, old.license_key, old.license_type, old.subscriber_id]);
|
||||
}
|
||||
|
||||
// Upsert into system_modules
|
||||
const sql = `
|
||||
INSERT INTO system_modules (code, name, is_active, license_key, license_type, expiry_date, subscriber_id)
|
||||
VALUES (?, ?, true, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
is_active = true,
|
||||
license_key = VALUES(license_key),
|
||||
license_type = VALUES(license_type),
|
||||
expiry_date = VALUES(expiry_date),
|
||||
subscriber_id = VALUES(subscriber_id)
|
||||
`;
|
||||
|
||||
// Map codes to names
|
||||
const names = {
|
||||
'asset': '자산 관리',
|
||||
'production': '생산 관리',
|
||||
'monitoring': 'CCTV'
|
||||
};
|
||||
|
||||
await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]);
|
||||
|
||||
// 5. Update Issued License Status (if exists in issued_licenses table)
|
||||
// This ensures CLI 'list' command reflects activation even if done via UI
|
||||
await db.query("UPDATE issued_licenses SET status = 'ACTIVE' WHERE license_key = ?", [licenseKey]);
|
||||
|
||||
res.json({ success: true, message: 'Module activated', type: result.type, expiry: result.expiryDate });
|
||||
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Get Module History
|
||||
router.get('/modules/:code/history', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT * FROM license_history WHERE module_code = ? ORDER BY id DESC LIMIT 10', [req.params.code]);
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Deactivate Module
|
||||
router.post('/modules/:code/deactivate', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { code } = req.params;
|
||||
try {
|
||||
// Archive on deactivate too?
|
||||
const [current] = await db.query('SELECT * FROM system_modules WHERE code = ?', [code]);
|
||||
if (current.length > 0 && current[0].is_active) {
|
||||
const old = current[0];
|
||||
const historySql = `
|
||||
INSERT INTO license_history (module_code, license_key, license_type, subscriber_id, activated_at)
|
||||
VALUES (?, ?, ?, ?, NOW())
|
||||
`;
|
||||
await db.query(historySql, [old.code, old.license_key, old.license_type, old.subscriber_id]);
|
||||
}
|
||||
|
||||
await db.query('UPDATE system_modules SET is_active = false WHERE code = ?', [code]);
|
||||
res.json({ success: true, message: 'Module deactivated' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
module.exports = router;
|
||||
53
server/schema.sql
Normal file
53
server/schema.sql
Normal file
@ -0,0 +1,53 @@
|
||||
-- Database Creation
|
||||
CREATE DATABASE IF NOT EXISTS smart_asset_db;
|
||||
USE smart_asset_db;
|
||||
|
||||
-- Assets Table
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id VARCHAR(20) PRIMARY KEY COMMENT '관리번호 (예: AST-2024-001)',
|
||||
name VARCHAR(100) NOT NULL COMMENT '자산명',
|
||||
category VARCHAR(50) NOT NULL COMMENT '카테고리 (설비, IT 등)',
|
||||
image_url VARCHAR(255) COMMENT '이미지 경로',
|
||||
model_name VARCHAR(100) COMMENT '모델명',
|
||||
serial_number VARCHAR(100) COMMENT 'S/N',
|
||||
manufacturer VARCHAR(100) COMMENT '제작사',
|
||||
location VARCHAR(100) COMMENT '설치 위치',
|
||||
purchase_date DATE COMMENT '도입일(입고일)',
|
||||
purchase_price BIGINT COMMENT '구입 가격',
|
||||
manager VARCHAR(50) COMMENT '관리책임자',
|
||||
status ENUM('active', 'maintain', 'broken', 'disposed') DEFAULT 'active' COMMENT '관리상태',
|
||||
calibration_cycle VARCHAR(50) COMMENT '교정주기',
|
||||
specs TEXT COMMENT '스펙/사양',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='자산 관리 대장';
|
||||
|
||||
-- Maintenance History Table (New)
|
||||
CREATE TABLE IF NOT EXISTS maintenance_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_id VARCHAR(20) NOT NULL COMMENT '자산 ID (FK)',
|
||||
maintenance_date DATE NOT NULL COMMENT '정비 일자',
|
||||
type VARCHAR(50) NOT NULL COMMENT '정비 구분 (정기점검, 수리 등)',
|
||||
content TEXT COMMENT '정비 내용',
|
||||
image_url VARCHAR(255) COMMENT '첨부 사진',
|
||||
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 COMMENT='정비 이력';
|
||||
|
||||
-- Camera Settings Table (New)
|
||||
CREATE TABLE IF NOT EXISTS camera_settings (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL COMMENT '카메라 이름',
|
||||
ip_address VARCHAR(100) NOT NULL COMMENT 'IP 주소',
|
||||
port INT DEFAULT 554 COMMENT 'RTSP 포트',
|
||||
username VARCHAR(100) COMMENT 'RTSP 사용자명',
|
||||
password VARCHAR(100) COMMENT 'RTSP 비밀번호',
|
||||
stream_path VARCHAR(200) DEFAULT '/stream1' COMMENT '스트림 경로 (예: /stream1)',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='카메라 설정';
|
||||
|
||||
-- Sample Data Seeding (Optional)
|
||||
INSERT INTO assets (id, name, category, model_name, serial_number, manufacturer, location, purchase_date, manager, status, specs)
|
||||
VALUES
|
||||
('AST-2024-001', 'CNC 머시닝 센터-0', '설비', 'M-1000', 'S/N-998877', 'HASS', '제1공장 A라인', '2023-11-15', '김담당', 'active', '5축 가공 / 12,000 RPM / CAT40');
|
||||
109
server/utils/licenseManager.js
Normal file
109
server/utils/licenseManager.js
Normal file
@ -0,0 +1,109 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* Generate a signed license key (Base64 Payload + Base64 Signature)
|
||||
* @param {string} moduleCode - 'asset', 'production', 'monitoring'
|
||||
* @param {string} type - 'dev', 'sub', 'demo'
|
||||
* @param {string} subscriberId - Subscriber ID
|
||||
* @param {number} [activationDays] - Optional
|
||||
* @param {string} [customExpiryDate] - Optional YYYY-MM-DD
|
||||
* @param {string|Buffer} privateKey - PEM formatted private key
|
||||
* @returns {string} Signed License Token
|
||||
*/
|
||||
function generateLicense(moduleCode, type, subscriberId, activationDays = null, customExpiryDate = null, privateKey) {
|
||||
if (!privateKey) {
|
||||
throw new Error('Private Key is required to generate license.');
|
||||
}
|
||||
|
||||
const payloadData = {
|
||||
m: moduleCode, // Module
|
||||
t: type, // Type
|
||||
s: subscriberId, // Subscriber ID
|
||||
d: Date.now() // Issue Date
|
||||
};
|
||||
|
||||
if (customExpiryDate) {
|
||||
const date = new Date(customExpiryDate + 'T23:59:59');
|
||||
if (!isNaN(date.getTime())) payloadData.e = date.getTime();
|
||||
} else {
|
||||
if (type === 'demo') payloadData.e = Date.now() + (30 * 24 * 60 * 60 * 1000);
|
||||
else if (type === 'sub') payloadData.e = Date.now() + (365 * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
if (activationDays) {
|
||||
payloadData.ad = Date.now() + (activationDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
const payloadStr = JSON.stringify(payloadData);
|
||||
const sign = crypto.createSign('SHA256');
|
||||
sign.update(payloadStr);
|
||||
sign.end();
|
||||
|
||||
const signature = sign.sign(privateKey, 'base64');
|
||||
const payloadB64 = Buffer.from(payloadStr).toString('base64');
|
||||
|
||||
return `${payloadB64}.${signature}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a signed license key
|
||||
* @param {string} licenseToken - The token string (Payload.Signature)
|
||||
* @param {string|Buffer} publicKey - PEM formatted public key
|
||||
* @returns {object} { isValid, reason, ...payload }
|
||||
*/
|
||||
function verifyLicense(licenseToken, publicKey) {
|
||||
if (!publicKey) {
|
||||
return { isValid: false, reason: 'Server Configuration Error: Public Key missing' };
|
||||
}
|
||||
|
||||
if (!licenseToken || !licenseToken.includes('.')) {
|
||||
return { isValid: false, reason: 'Invalid Key Format' };
|
||||
}
|
||||
|
||||
const [payloadB64, signature] = licenseToken.split('.');
|
||||
|
||||
try {
|
||||
const payloadStr = Buffer.from(payloadB64, 'base64').toString('utf8');
|
||||
|
||||
// Verify Signature
|
||||
const verify = crypto.createVerify('SHA256');
|
||||
verify.update(payloadStr);
|
||||
verify.end();
|
||||
|
||||
const isValidSignature = verify.verify(publicKey, signature, 'base64');
|
||||
if (!isValidSignature) {
|
||||
return { isValid: false, reason: 'Invalid Signature (Key Modified or Wrong Public Key)' };
|
||||
}
|
||||
|
||||
// Logic Checks (Expiry, Activation)
|
||||
const payload = JSON.parse(payloadStr);
|
||||
const { m, t, d, s, ad, e } = payload;
|
||||
const subscriberId = s || null;
|
||||
|
||||
if (ad && Date.now() > ad) {
|
||||
return { isValid: false, reason: `Activation Period Expired (${new Date(ad).toLocaleDateString()})`, module: m, type: t, subscriberId };
|
||||
}
|
||||
|
||||
let expiryDate = null;
|
||||
if (e) {
|
||||
expiryDate = new Date(e);
|
||||
if (Date.now() > e) {
|
||||
return { isValid: false, reason: 'Expired', expiryDate, subscriberId };
|
||||
}
|
||||
} else {
|
||||
// Fallback for old keys? No, this is a breaking change. Old keys fail signature anyway.
|
||||
// Just handle 'dev' type (no expiry)
|
||||
if (t !== 'dev') {
|
||||
// If not dev and no e, treat as permanent or error?
|
||||
// Logic above always sets 'e' for sub/demo.
|
||||
}
|
||||
}
|
||||
|
||||
return { isValid: true, module: m, type: t, subscriberId, expiryDate };
|
||||
|
||||
} catch (e) {
|
||||
return { isValid: false, reason: 'Corrupted Key Payload' };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { generateLicense, verifyLicense };
|
||||
70
server/utils/remoteLicense.js
Normal file
70
server/utils/remoteLicense.js
Normal file
@ -0,0 +1,70 @@
|
||||
const mysql = require('mysql2/promise');
|
||||
require('dotenv').config();
|
||||
|
||||
// Configuration for the "Vendor/Central" License Database
|
||||
const remoteConfig = {
|
||||
host: process.env.LICENSE_DB_HOST,
|
||||
user: process.env.LICENSE_DB_USER,
|
||||
password: process.env.LICENSE_DB_PASSWORD,
|
||||
database: process.env.LICENSE_DB_NAME,
|
||||
port: process.env.LICENSE_DB_PORT || 3306,
|
||||
connectTimeout: 5000 // 5 seconds timeout
|
||||
};
|
||||
|
||||
function isRemoteConfigured() {
|
||||
return !!(process.env.LICENSE_DB_HOST && process.env.LICENSE_DB_USER && process.env.LICENSE_DB_NAME);
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to the Remote DB, checks if the key exists, and disconnects.
|
||||
* @param {string} key
|
||||
* @returns {Promise<boolean>} valid
|
||||
*/
|
||||
async function checkRemoteKey(key) {
|
||||
if (!isRemoteConfigured()) {
|
||||
console.warn('⚠️ Remote License DB not configured. Skipping remote check.');
|
||||
return true; // Use local check only if remote is not configured
|
||||
}
|
||||
|
||||
let conn;
|
||||
try {
|
||||
conn = await mysql.createConnection(remoteConfig);
|
||||
const [rows] = await conn.execute('SELECT 1 FROM issued_licenses WHERE license_key = ? LIMIT 1', [key]);
|
||||
return rows.length > 0;
|
||||
} catch (err) {
|
||||
console.error('❌ Remote License Check Failed:', err.message);
|
||||
return false; // Fail safe: if we can't verify remotely, assume invalid (or valid? User wanted "management", so fail is safer)
|
||||
} finally {
|
||||
if (conn) await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to Remote DB, inserts the generated key, and disconnects.
|
||||
* @param {object} licenseData { key, module, type }
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function registerRemoteKey({ key, module, type }) {
|
||||
if (!isRemoteConfigured()) {
|
||||
console.log('ℹ️ Remote License DB not configured. Key generated locally only.');
|
||||
return;
|
||||
}
|
||||
|
||||
let conn;
|
||||
try {
|
||||
conn = await mysql.createConnection(remoteConfig);
|
||||
const sql = `
|
||||
INSERT INTO issued_licenses (license_key, module_code, license_type, created_at)
|
||||
VALUES (?, ?, ?, NOW())
|
||||
`;
|
||||
await conn.execute(sql, [key, module, type]);
|
||||
console.log('✅ License Key registered to Central DB.');
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to register key to Central DB:', err.message);
|
||||
console.warn('⚠️ The key was generated but may not be activatable if the server enforces remote check.');
|
||||
} finally {
|
||||
if (conn) await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { checkRemoteKey, registerRemoteKey };
|
||||
65
src/app/App.tsx
Normal file
65
src/app/App.tsx
Normal file
@ -0,0 +1,65 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { MainLayout } from '../widgets/layout/MainLayout';
|
||||
import { LoginPage } from '../pages/auth/LoginPage';
|
||||
import { DashboardPage } from '../modules/asset/pages/DashboardPage';
|
||||
import { AssetListPage } from '../modules/asset/pages/AssetListPage';
|
||||
import { AssetRegisterPage } from '../modules/asset/pages/AssetRegisterPage';
|
||||
import { AssetSettingsPage } from '../modules/asset/pages/AssetSettingsPage';
|
||||
import { AssetDetailPage } from '../modules/asset/pages/AssetDetailPage';
|
||||
import { UserManagementPage } from '../platform/pages/UserManagementPage';
|
||||
import { 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';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<AuthProvider>
|
||||
<SystemProvider>
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
{/* Public Routes */}
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
|
||||
{/* Protected Routes */}
|
||||
<Route element={
|
||||
<AuthGuard>
|
||||
<MainLayout />
|
||||
</AuthGuard>
|
||||
}>
|
||||
<Route path="/" element={<Navigate to="/asset/dashboard" replace />} />
|
||||
<Route path="/asset/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/asset/register" element={<AssetRegisterPage />} />
|
||||
{/* Generic List Route */}
|
||||
<Route path="/asset/list" element={<AssetListPage />} />
|
||||
|
||||
{/* Specific Category Routes (Reuse List Page) */}
|
||||
<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 path="/admin/users" element={<UserManagementPage />} />
|
||||
<Route path="/admin/license" element={<LicensePage />} />
|
||||
<Route path="/production/dashboard" element={<ProductionPage />} />
|
||||
<Route path="/monitoring" element={<MonitoringPage />} />
|
||||
<Route path="/admin" element={<div>Admin (Coming Soon)</div>} />
|
||||
</Route>
|
||||
|
||||
{/* Fallback */}
|
||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</SystemProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
90
src/app/index.css
Normal file
90
src/app/index.css
Normal file
@ -0,0 +1,90 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
/* Modern Enterprise Theme (Slate & Sky) */
|
||||
/* 눈의 피로를 줄이는 부드러운 회색조 배경과 명확한 가시성의 포인트 컬러 */
|
||||
|
||||
/* Backgrounds */
|
||||
--color-bg-base: #F1F5F9;
|
||||
/* Slate 100: 눈이 편안한 연한 회색 배경 */
|
||||
--color-bg-surface: #ffffff;
|
||||
/* White: 카드/컨텐츠 영역 */
|
||||
--color-bg-sidebar: #1E293B;
|
||||
/* Slate 800: 안정감 있는 짙은 네이비 (사이드바) */
|
||||
|
||||
/* Text */
|
||||
--color-text-primary: #0F172A;
|
||||
/* Slate 900: 선명한 검정색 (본문) */
|
||||
--color-text-secondary: #64748B;
|
||||
/* Slate 500: 부드러운 회색 (보조 텍스트) */
|
||||
--color-text-inverse: #F8FAFC;
|
||||
/* Slate 50: 어두운 배경 위 밝은 텍스트 */
|
||||
|
||||
/* Brand/Accents */
|
||||
--color-brand-primary: #0EA5E9;
|
||||
/* Sky 500: 산뜻하고 눈에 잘 띄는 블루 (강조/버튼) */
|
||||
--color-brand-secondary: #0F172A;
|
||||
/* Slate 900: 보조 강조색 */
|
||||
--color-brand-accent: #E2E8F0;
|
||||
/* Slate 200: 테두리/구분선 */
|
||||
|
||||
--color-border: #E2E8F0;
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
|
||||
/* Transition */
|
||||
--transition-base: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: var(--color-bg-base);
|
||||
color: var(--color-text-primary);
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
background: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
/* Scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #cbd5e1;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
1
src/assets/react.svg
Normal file
1
src/assets/react.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
10
src/main.tsx
Normal file
10
src/main.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './app/index.css'
|
||||
import App from './app/App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
210
src/modules/asset/pages/AssetDetailPage.css
Normal file
210
src/modules/asset/pages/AssetDetailPage.css
Normal file
@ -0,0 +1,210 @@
|
||||
/* Page Layout */
|
||||
.detail-page .page-header {
|
||||
margin-bottom: 1rem;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
padding: 0.5rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
/* Tabs */
|
||||
.tabs-container {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
color: var(--color-brand-primary);
|
||||
background-color: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
color: var(--color-brand-primary);
|
||||
border-bottom-color: var(--color-brand-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Document Layout Structure */
|
||||
/* Document Layout Structure */
|
||||
.document-layout {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 1.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.doc-header-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.id-table {
|
||||
width: auto;
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.doc-main-row {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
height: 320px;
|
||||
/* Fixed height to align image and table */
|
||||
}
|
||||
|
||||
.doc-image-area {
|
||||
flex: 0 0 450px;
|
||||
/* Width for image area */
|
||||
background-color: #f8fafc;
|
||||
border: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.asset-detail-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
/* Changed from cover to contain */
|
||||
}
|
||||
|
||||
.doc-info-area {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Document Tables */
|
||||
.doc-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.doc-table th,
|
||||
.doc-table td {
|
||||
border: 1px solid #cbd5e1;
|
||||
padding: 0.75rem 1rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.doc-table th {
|
||||
font-weight: 600;
|
||||
color: #475569;
|
||||
background-color: #f8fafc;
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.doc-table td {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
height: 1px;
|
||||
background-color: var(--color-border);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Truncate content in listings */
|
||||
.truncate-cell {
|
||||
max-width: 400px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* Force full width for inputs in table */
|
||||
.doc-table input,
|
||||
.doc-table select {
|
||||
width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
/* Critical for table-fixed */
|
||||
box-sizing: border-box !important;
|
||||
max-width: 100% !important;
|
||||
height: 100% !important;
|
||||
/* Ensure it fills the cell height */
|
||||
margin: 0 !important;
|
||||
/* Remove any default margins */
|
||||
}
|
||||
|
||||
/* Ensure table layout is strict */
|
||||
.doc-table {
|
||||
table-layout: fixed !important;
|
||||
}
|
||||
|
||||
/* Maintenance Form */
|
||||
.maintenance-form {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Print Overrides */
|
||||
@media print {
|
||||
.page-container {
|
||||
padding: 0;
|
||||
background: white;
|
||||
}
|
||||
|
||||
.page-header,
|
||||
.tabs-container,
|
||||
.back-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.print-friendly {
|
||||
box-shadow: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.doc-main-row {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.doc-image-area {
|
||||
height: 300px;
|
||||
border: 1px solid #000;
|
||||
}
|
||||
}
|
||||
986
src/modules/asset/pages/AssetDetailPage.tsx
Normal file
986
src/modules/asset/pages/AssetDetailPage.tsx
Normal file
@ -0,0 +1,986 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Card } from '../../../shared/ui/Card';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { ArrowLeft, Save, Upload, X, FileText, Wrench, BookOpen, Trash2, ZoomIn, FileSpreadsheet, File, Download, Printer, Plus } from 'lucide-react';
|
||||
|
||||
import { getMaintenanceTypes } from './AssetSettingsPage';
|
||||
import './AssetDetailPage.css';
|
||||
import { assetApi } from '../../../shared/api/assetApi';
|
||||
import type { Asset, MaintenanceRecord, Manual } from '../../../shared/api/assetApi';
|
||||
import { SERVER_URL } from '../../../shared/api/client';
|
||||
import { createPortal } from 'react-dom';
|
||||
import DatePicker from 'react-datepicker';
|
||||
import "react-datepicker/dist/react-datepicker.css";
|
||||
import { ko } from 'date-fns/locale';
|
||||
|
||||
export function AssetDetailPage() {
|
||||
const { assetId } = useParams<{ assetId: string }>();
|
||||
// const navigate = useNavigate(); // Kept for future use
|
||||
const [asset, setAsset] = useState<Asset | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'basic' | 'history' | 'manual'>('basic');
|
||||
|
||||
useEffect(() => {
|
||||
if (assetId) {
|
||||
loadAsset(assetId);
|
||||
}
|
||||
}, [assetId]);
|
||||
|
||||
const loadAsset = async (id: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await assetApi.getById(id);
|
||||
setAsset(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load asset:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="p-8 text-center">불러오는 중...</div>;
|
||||
if (!asset) return <div className="p-8 text-center">자산 정보를 찾을 수 없습니다.</div>;
|
||||
|
||||
// Extend asset with mock data for missing DB fields (consumables, history)
|
||||
const fullAsset = {
|
||||
...asset,
|
||||
// If image exists, use it. If relative, prepend SERVER_URL. If not, use placeholder.
|
||||
image: asset.image
|
||||
? (asset.image.startsWith('http') ? asset.image : `${SERVER_URL}${asset.image} `)
|
||||
: 'https://via.placeholder.com/400x350?text=No+Image',
|
||||
consumables: [],
|
||||
history: []
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (activeTab) {
|
||||
case 'basic': return <BasicInfoTab asset={fullAsset} onRefresh={() => assetId && loadAsset(assetId)} />;
|
||||
case 'history': return <HistoryTab assetId={asset.id} />;
|
||||
case 'manual': return <ManualTab assetId={asset.id} />;
|
||||
default: return <BasicInfoTab asset={fullAsset} onRefresh={() => assetId && loadAsset(assetId)} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container detail-page">
|
||||
<div className="flex gap-1 mb-4 border-b border-slate-200 mt-4">
|
||||
<button
|
||||
className={`flex items-center gap-2 px-5 py-3 text-[0.95rem] font-medium transition-all border-b-2 ${activeTab === 'basic'
|
||||
? 'text-blue-600 border-blue-600 font-semibold'
|
||||
: 'text-slate-500 border-transparent hover:text-blue-500 hover:bg-blue-50/50'
|
||||
}`}
|
||||
onClick={() => setActiveTab('basic')}
|
||||
>
|
||||
<FileText size={16} />
|
||||
기본 정보 (관리대장)
|
||||
</button>
|
||||
<button
|
||||
className={`flex items-center gap-2 px-5 py-3 text-[0.95rem] font-medium transition-all border-b-2 ${activeTab === 'history'
|
||||
? 'text-blue-600 border-blue-600 font-semibold'
|
||||
: 'text-slate-500 border-transparent hover:text-blue-500 hover:bg-blue-50/50'
|
||||
}`}
|
||||
onClick={() => setActiveTab('history')}
|
||||
>
|
||||
<Wrench size={16} />
|
||||
정비 이력
|
||||
</button>
|
||||
<button
|
||||
className={`flex items-center gap-2 px-5 py-3 text-[0.95rem] font-medium transition-all border-b-2 ${activeTab === 'manual'
|
||||
? 'text-blue-600 border-blue-600 font-semibold'
|
||||
: 'text-slate-500 border-transparent hover:text-blue-500 hover:bg-blue-50/50'
|
||||
}`}
|
||||
onClick={() => setActiveTab('manual')}
|
||||
>
|
||||
<BookOpen size={16} />
|
||||
매뉴얼 / 지침서
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="detail-content">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BasicInfoTab({ asset, onRefresh }: { asset: Asset & { image?: string, consumables?: any[] }, onRefresh: () => void }) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editData, setEditData] = useState(asset);
|
||||
const [isZoomed, setIsZoomed] = useState(false);
|
||||
|
||||
const handleChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value } = e.target;
|
||||
setEditData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
await assetApi.update(asset.id, editData);
|
||||
setIsEditing(false);
|
||||
onRefresh(); // Refresh data from server
|
||||
} catch (error) {
|
||||
console.error("Failed to update asset:", error);
|
||||
alert("저장에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const res = await assetApi.uploadImage(file);
|
||||
// res.data.url is native path like /uploads/filename. We save this to DB.
|
||||
setEditData(prev => ({ ...prev, image: res.data.url }));
|
||||
} catch (error) {
|
||||
console.error("Failed to upload image:", error);
|
||||
alert("이미지 업로드에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteImage = () => {
|
||||
if (confirm("이미지를 삭제하시겠습니까?")) {
|
||||
setEditData(prev => ({ ...prev, image: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setEditData(asset);
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center mb-4 gap-1" style={{ justifyContent: 'flex-end' }}>
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button size="sm" variant="secondary" onClick={handleCancel} icon={<ArrowLeft size={16} />}>취소</Button>
|
||||
<Button size="sm" onClick={handleSave} icon={<Save size={16} />}>저장</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button size="sm" variant="secondary" onClick={() => setIsEditing(true)}>수정</Button>
|
||||
)}
|
||||
<Button variant="secondary" size="sm" icon={<Printer size={16} />}>출력</Button>
|
||||
</div>
|
||||
|
||||
<Card className="content-card print-friendly">
|
||||
|
||||
<div className="document-layout flex gap-6 items-start">
|
||||
{/* Left: Image Area */}
|
||||
<div
|
||||
className="doc-image-area flex-none bg-white border border-slate-300 rounded flex flex-col items-center justify-center overflow-hidden relative group cursor-pointer p-2"
|
||||
style={{ width: '400px', height: '350px' }}
|
||||
onClick={() => !isEditing && editData.image && setIsZoomed(true)}
|
||||
>
|
||||
{editData.image ? (
|
||||
<>
|
||||
<img
|
||||
src={editData.image.startsWith('http') ? editData.image : `${SERVER_URL}${editData.image} `}
|
||||
alt={asset.name}
|
||||
className="w-full h-full"
|
||||
style={{ objectFit: 'contain', maxWidth: '100%', maxHeight: '100%' }}
|
||||
/>
|
||||
{!isEditing && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/5 opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
<div className="bg-black/50 text-white p-2 rounded-full">
|
||||
<ZoomIn size={24} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-slate-400">이미지 없음</div>
|
||||
)}
|
||||
|
||||
{isEditing && (
|
||||
<div className="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-4" onClick={(e) => e.stopPropagation()}>
|
||||
<label className="cursor-pointer bg-white p-2 rounded-full hover:bg-slate-100 text-slate-700">
|
||||
<Upload size={20} />
|
||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} />
|
||||
</label>
|
||||
{editData.image && (
|
||||
<button onClick={handleDeleteImage} className="bg-white p-2 rounded-full hover:bg-red-50 text-red-500">
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Info Table */}
|
||||
<div className="doc-info-area flex-1">
|
||||
|
||||
<table className="doc-table w-full border-collapse border border-slate-300 table-fixed">
|
||||
<colgroup>
|
||||
<col className="w-[10%] bg-slate-50" />
|
||||
<col className="w-[23%]" />
|
||||
<col className="w-[10%] bg-slate-50" />
|
||||
<col className="w-[23%]" />
|
||||
<col className="w-[10%] bg-slate-50" />
|
||||
<col className="w-[24%]" />
|
||||
</colgroup>
|
||||
<tbody>
|
||||
<tr style={{ height: '70px' }}>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">관리번호</th>
|
||||
<td colSpan={3} className="border border-slate-300 p-2 text-lg bg-slate-50">
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium text-left cursor-default">
|
||||
{editData.id}
|
||||
</div>
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">구입가격</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="number"
|
||||
name="purchasePrice"
|
||||
value={editData.purchasePrice || ''}
|
||||
onChange={handleChange}
|
||||
placeholder="0"
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.purchasePrice ? `${Number(editData.purchasePrice).toLocaleString()} 원` : '-'}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ height: '70px' }}>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">설비명</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="name"
|
||||
value={editData.name}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.name}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">S/N</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="serialNumber"
|
||||
value={editData.serialNumber}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.serialNumber}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">입고일</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
type="date"
|
||||
name="purchaseDate"
|
||||
value={editData.purchaseDate}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none font-sans border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
style={{ fontFamily: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium font-sans cursor-default">
|
||||
{editData.purchaseDate}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ height: '70px' }}>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">모델명</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="model"
|
||||
value={editData.model}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.model}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">제작사</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="manufacturer"
|
||||
value={editData.manufacturer}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.manufacturer}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">교정주기</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="calibrationCycle"
|
||||
value={editData.calibrationCycle}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.calibrationCycle}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<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 ? (
|
||||
<input
|
||||
name="specs"
|
||||
value={editData.specs}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.specs}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
<tr style={{ height: '70px' }}>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">설치위치</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="location"
|
||||
value={editData.location}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.location}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">관리책임자</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<input
|
||||
name="manager"
|
||||
value={editData.manager}
|
||||
onChange={handleChange}
|
||||
className="block px-2 !text-lg !font-medium transition-colors rounded-none outline-none border border-slate-300 shadow-sm focus:outline-none focus:bg-slate-50"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-14 flex items-center px-2 text-lg font-medium cursor-default">
|
||||
{editData.manager}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50 font-semibold text-center">관리상태</th>
|
||||
<td className="border border-slate-300 p-2">
|
||||
{isEditing ? (
|
||||
<div className="relative w-full">
|
||||
<select
|
||||
name="status"
|
||||
value={editData.status}
|
||||
onChange={e => setEditData({ ...editData, status: e.target.value as any })}
|
||||
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="active">정상 가동</option>
|
||||
<option value="maintain">점검 중</option>
|
||||
<option value="broken">수리 필요</option>
|
||||
<option value="disposed">폐기 (말소)</option>
|
||||
</select>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full !h-14 flex items-center px-2 border-0 !border-none">
|
||||
<span className={`badge ${editData.status === 'active' ? 'badge-success' : editData.status === 'disposed' ? 'badge-neutral' : 'badge-warning'} !text - lg!font - medium px - 4 py - 2`}>
|
||||
{editData.status === 'active' ? '정상 가동' : editData.status === 'disposed' ? '폐기 (말소)' : editData.status === 'maintain' ? '점검 중' : '수리 필요'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="section-divider my-6 border-b border-slate-200"></div>
|
||||
|
||||
{/* Consumables Section */}
|
||||
<div className="consumables-section">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="section-title text-lg font-bold">관련 소모품 관리</h3>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />}>소모품 추가</Button>
|
||||
</div>
|
||||
<table className="doc-table w-full text-center border-collapse border border-slate-300">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50">품명</th>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50">규격</th>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50">현재고</th>
|
||||
<th className="border border-slate-300 p-2 bg-slate-50">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{asset.consumables?.map(item => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-slate-300 p-2">{item.name}</td>
|
||||
<td className="border border-slate-300 p-2">{item.spec}</td>
|
||||
<td className="border border-slate-300 p-2">{item.qty}개</td>
|
||||
<td className="border border-slate-300 p-2">
|
||||
<button className="text-slate-400 hover:text-red-500 text-sm underline">삭제</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Image Zoom Modal - Moved to Portal */}
|
||||
{isZoomed && editData.image && createPortal(
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backdropFilter: 'blur(4px)'
|
||||
}}
|
||||
onClick={() => setIsZoomed(false)}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: 'white',
|
||||
borderRadius: '0.5rem',
|
||||
boxShadow: '0 25px 50px -12px rgba(0, 0, 0, 0.25)',
|
||||
maxWidth: '56rem', // max-w-4xl
|
||||
width: '100%',
|
||||
maxHeight: '90vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
margin: '1rem'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '1rem',
|
||||
borderBottom: '1px solid #e2e8f0',
|
||||
backgroundColor: '#f8fafc'
|
||||
}}>
|
||||
<h3 style={{ fontWeight: 'bold', fontSize: '1.125rem', color: '#1e293b' }}>{asset.name} - 이미지 상세</h3>
|
||||
<button
|
||||
onClick={() => setIsZoomed(false)}
|
||||
style={{
|
||||
padding: '0.5rem',
|
||||
borderRadius: '9999px',
|
||||
color: '#64748b',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
onMouseOver={(e) => e.currentTarget.style.backgroundColor = '#e2e8f0'}
|
||||
onMouseOut={(e) => e.currentTarget.style.backgroundColor = 'transparent'}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Modal Content */}
|
||||
<div style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '1rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
backgroundColor: '#f1f5f9'
|
||||
}}>
|
||||
<img
|
||||
src={editData.image.startsWith('http') ? editData.image : `${SERVER_URL}${editData.image} `}
|
||||
alt={asset.name}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: '75vh',
|
||||
objectFit: 'contain',
|
||||
backgroundColor: 'white',
|
||||
boxShadow: '0 1px 3px 0 rgba(0, 0, 0, 0.1)',
|
||||
display: 'block'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
// Helper for badge colors
|
||||
// Helper for badge colors
|
||||
const getTypeBadgeColor = (type: string) => {
|
||||
const types = getMaintenanceTypes();
|
||||
const found = types.find(t => t.name === type);
|
||||
if (!found) return 'bg-slate-100 text-slate-700';
|
||||
|
||||
switch (found.color) {
|
||||
case 'success': return 'bg-green-100 text-green-700';
|
||||
case 'danger': return 'bg-red-100 text-red-700';
|
||||
case 'warning': return 'bg-orange-100 text-orange-700';
|
||||
case 'primary': return 'bg-blue-100 text-blue-700';
|
||||
default: return 'bg-slate-100 text-slate-700'; // neutral
|
||||
}
|
||||
};
|
||||
|
||||
function HistoryTab({ assetId }: { assetId: string }) {
|
||||
const [history, setHistory] = useState<MaintenanceRecord[]>([]);
|
||||
const [isWriting, setIsWriting] = useState(true); // Default to open
|
||||
const [formData, setFormData] = useState({
|
||||
maintenance_date: new Date().toISOString().split('T')[0],
|
||||
type: getMaintenanceTypes()[0]?.name || '정기점검',
|
||||
content: '',
|
||||
images: [] as string[]
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
loadHistory();
|
||||
}, [assetId]);
|
||||
|
||||
const loadHistory = async () => {
|
||||
try {
|
||||
const data = await assetApi.getMaintenanceHistory(assetId);
|
||||
setHistory(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load history:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
const res = await assetApi.uploadImage(file);
|
||||
setFormData(prev => ({ ...prev, images: [...(prev.images || []), res.data.url] }));
|
||||
// Reset input value to allow same file selection again
|
||||
e.target.value = '';
|
||||
} catch (error) {
|
||||
console.error("Failed to upload image:", error);
|
||||
alert("이미지 업로드에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteImage = (index: number) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
images: prev.images.filter((_, i) => i !== index)
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.content) {
|
||||
alert("정비 내용을 입력해주세요.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await assetApi.addMaintenance(assetId, {
|
||||
maintenance_date: formData.maintenance_date,
|
||||
type: formData.type,
|
||||
content: formData.content,
|
||||
images: formData.images
|
||||
});
|
||||
setIsWriting(false);
|
||||
setFormData({
|
||||
maintenance_date: new Date().toISOString().split('T')[0],
|
||||
type: '정기점검',
|
||||
content: '',
|
||||
images: []
|
||||
});
|
||||
loadHistory();
|
||||
} catch (error) {
|
||||
console.error("Failed to save maintenance record:", error);
|
||||
alert("저장에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm("정비 이력을 삭제하시겠습니까?")) {
|
||||
try {
|
||||
await assetApi.deleteMaintenance(id);
|
||||
loadHistory();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete record:", error);
|
||||
alert("삭제에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="content-card shadow-sm border border-slate-200">
|
||||
<div className="card-header flex justify-between items-center p-4 border-b border-slate-100 bg-white">
|
||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<span className="w-1 h-6 bg-blue-600 rounded-full inline-block"></span>
|
||||
정비 및 수리 이력
|
||||
</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
{isWriting && (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
className="flex items-center gap-2 px-3 py-1.5 rounded text-sm font-medium transition-colors bg-blue-600 text-white hover:bg-blue-700 shadow-sm"
|
||||
>
|
||||
<Save size={16} /> 저장하기
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setIsWriting(!isWriting)}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm font-medium transition-colors ${isWriting
|
||||
? 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm'
|
||||
}`}
|
||||
>
|
||||
{isWriting ? (
|
||||
<>
|
||||
<X size={16} /> 작성 취소
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload size={16} /> 정비등록
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isWriting && (
|
||||
<div className="maintenance-form bg-slate-50 p-6 border-b border-slate-200 rounded-lg mb-6">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-500 mb-1">정비일자</label>
|
||||
<DatePicker
|
||||
locale={ko}
|
||||
dateFormat="yyyy-MM-dd"
|
||||
className="w-full h-14 text-lg px-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
selected={formData.maintenance_date ? new Date(formData.maintenance_date) : null}
|
||||
onChange={(date: Date | null) => {
|
||||
if (date) {
|
||||
const yyyy = date.getFullYear();
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const dd = String(date.getDate()).padStart(2, '0');
|
||||
setFormData({ ...formData, maintenance_date: `${yyyy}-${mm}-${dd}` });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-500 mb-1">구분</label>
|
||||
<select
|
||||
className="w-full h-14 text-lg px-3 border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
|
||||
value={formData.type}
|
||||
onChange={e => setFormData({ ...formData, type: e.target.value })}
|
||||
>
|
||||
{getMaintenanceTypes().map(t => (
|
||||
<option key={t.id} value={t.name}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-500 mb-1">정비내용</label>
|
||||
<textarea
|
||||
className="w-full p-4 text-lg border border-slate-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white min-h-[300px] resize-y"
|
||||
placeholder="상세 정비 내용을 입력하세요..."
|
||||
value={formData.content}
|
||||
onChange={e => setFormData({ ...formData, content: e.target.value })}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-bold text-slate-500 mb-1">사진 첨부</label>
|
||||
<div className="flex flex-col gap-3">
|
||||
<label className="cursor-pointer group relative flex items-center justify-center px-4 py-2 border border-slate-300 rounded-lg bg-white hover:bg-slate-50 transition-all h-14 w-full md:w-auto self-start">
|
||||
<input type="file" accept="image/*" className="hidden" style={{ display: 'none' }} onChange={handleImageUpload} />
|
||||
<div className="flex items-center gap-2 text-slate-600 group-hover:text-slate-800">
|
||||
<Upload size={20} />
|
||||
<span className="text-base font-medium">사진 선택</span>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="flex flex-wrap gap-4 mt-2">
|
||||
{formData.images && formData.images.map((imgUrl, idx) => (
|
||||
<div key={idx} className="relative group w-fit">
|
||||
<div className="w-[150px] h-[150px] rounded-lg bg-white border border-slate-200 overflow-hidden shadow-sm flex items-center justify-center">
|
||||
<img
|
||||
src={imgUrl.startsWith('http') ? imgUrl : `${SERVER_URL}${imgUrl}`}
|
||||
alt={`preview-${idx}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleDeleteImage(idx)}
|
||||
className="absolute top-1 right-1 p-1.5 bg-white/90 hover:bg-red-50 rounded-full text-slate-500 hover:text-red-500 shadow-md transition-colors border border-slate-200"
|
||||
title="이미지 삭제"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="history-list">
|
||||
<table className="w-full text-left">
|
||||
<thead className="bg-slate-50 text-slate-500 font-bold border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-6 py-4 text-center w-40 text-base">정비일자</th>
|
||||
<th className="px-6 py-4 text-center w-32 text-base">구분</th>
|
||||
<th className="px-6 py-4 text-center text-base">정비내용</th>
|
||||
<th className="px-6 py-4 text-center w-24 text-base">첨부</th>
|
||||
<th className="px-6 py-4 text-center w-24 text-base">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{history.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-center py-16 text-slate-400">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<div className="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center text-slate-300">
|
||||
<Upload size={32} />
|
||||
</div>
|
||||
<p className="text-lg">등록된 정비 이력이 없습니다.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
history.map(item => (
|
||||
<tr key={item.id} className="hover:bg-slate-50 transition-colors group">
|
||||
<td className="px-6 py-5 text-center whitespace-nowrap text-slate-700 text-lg">
|
||||
{new Date(item.maintenance_date).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-6 py-5 text-center">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-semibold ${getTypeBadgeColor(item.type)}`}>
|
||||
{item.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-center text-slate-600 max-w-xs truncate">
|
||||
{item.content}
|
||||
</td>
|
||||
<td className="px-6 py-5 text-center">
|
||||
<div className="flex gap-1 justify-center flex-wrap max-w-[200px] mx-auto">
|
||||
{(item.images && item.images.length > 0) ? (
|
||||
item.images.map((img, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={img.startsWith('http') ? img : `${SERVER_URL}${img}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center w-8 h-8 rounded bg-slate-100 text-blue-600 hover:bg-blue-50 border border-slate-200 overflow-hidden"
|
||||
title="이미지 보기"
|
||||
>
|
||||
<img
|
||||
src={img.startsWith('http') ? img : `${SERVER_URL}${img}`}
|
||||
alt=""
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</a>
|
||||
))
|
||||
) : (
|
||||
item.image_url ? (
|
||||
<a
|
||||
href={item.image_url.startsWith('http') ? item.image_url : `${SERVER_URL}${item.image_url}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-blue-50 text-blue-600 hover:bg-blue-100 hover:text-blue-700 transition-colors"
|
||||
title="이미지 보기"
|
||||
>
|
||||
<ZoomIn size={20} />
|
||||
</a>
|
||||
) : <span className="text-slate-300">-</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-center">
|
||||
<button
|
||||
className="text-slate-400 hover:text-red-600 hover:bg-red-50 p-2 rounded-md transition-all opacity-0 group-hover:opacity-100 focus:opacity-100"
|
||||
onClick={() => handleDelete(item.id)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={20} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ManualTab({ assetId }: { assetId: string }) {
|
||||
const [manuals, setManuals] = useState<Manual[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
loadManuals();
|
||||
}, [assetId]);
|
||||
|
||||
const loadManuals = async () => {
|
||||
try {
|
||||
const data = await assetApi.getManuals(assetId);
|
||||
setManuals(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to load manuals:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
try {
|
||||
// Reusing uploadImage since it returns {url} and works for files
|
||||
const res = await assetApi.uploadImage(file);
|
||||
await assetApi.addManual(assetId, {
|
||||
file_name: file.name,
|
||||
file_url: res.data.url
|
||||
});
|
||||
loadManuals();
|
||||
e.target.value = ''; // Reset input
|
||||
} catch (error) {
|
||||
console.error("Failed to upload manual:", error);
|
||||
alert("파일 업로드에 실패했습니다.");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (confirm("매뉴얼을 삭제하시겠습니까?")) {
|
||||
try {
|
||||
await assetApi.deleteManual(id);
|
||||
loadManuals();
|
||||
} catch (error) {
|
||||
console.error("Failed to delete manual:", error);
|
||||
alert("삭제에 실패했습니다.");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const getFileIcon = (filename: string) => {
|
||||
const ext = filename.split('.').pop()?.toLowerCase();
|
||||
if (ext === 'pdf') return <FileText size={24} className="text-red-500" />;
|
||||
if (['xls', 'xlsx', 'csv'].includes(ext || '')) return <FileSpreadsheet size={24} className="text-green-600" />;
|
||||
return <File size={24} className="text-slate-400" />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="content-card shadow-sm border border-slate-200">
|
||||
<div className="card-header flex justify-between items-center p-4 border-b border-slate-100 bg-white">
|
||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<span className="w-1 h-6 bg-blue-600 rounded-full inline-block"></span>
|
||||
매뉴얼 및 지침서
|
||||
</h2>
|
||||
<div>
|
||||
<label className="cursor-pointer flex items-center gap-2 px-3 py-1.5 rounded text-sm font-medium transition-colors bg-blue-600 text-white hover:bg-blue-700 shadow-sm">
|
||||
<Upload size={16} />
|
||||
<span>파일 업로드</span>
|
||||
<input type="file" className="hidden" onChange={handleFileUpload} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{manuals.length === 0 ? (
|
||||
<div className="empty-state py-12 flex flex-col items-center gap-3 text-slate-400">
|
||||
<div className="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center">
|
||||
<BookOpen size={32} className="text-slate-300" />
|
||||
</div>
|
||||
<p>등록된 매뉴얼이 없습니다.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{manuals.map(manual => (
|
||||
<div key={manual.id} className="flex items-center justify-between p-4 bg-slate-50 border border-slate-200 rounded-lg hover:border-blue-300 transition-colors group">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-2 bg-white rounded border border-slate-100 shadow-sm">
|
||||
{getFileIcon(manual.file_name)}
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<p className="font-medium text-slate-700 group-hover:text-blue-600 transition-colors cursor-pointer"
|
||||
onClick={() => window.open(manual.file_url.startsWith('http') ? manual.file_url : `${SERVER_URL}${manual.file_url}`, '_blank')}
|
||||
>
|
||||
{manual.file_name}
|
||||
</p>
|
||||
<span className="text-xs text-slate-400">{new Date(manual.created_at || '').toLocaleDateString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<a
|
||||
href={manual.file_url.startsWith('http') ? manual.file_url : `${SERVER_URL}${manual.file_url}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
download
|
||||
className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-full transition-colors"
|
||||
title="다운로드/미리보기"
|
||||
>
|
||||
<Download size={18} />
|
||||
</a>
|
||||
<button
|
||||
onClick={() => handleDelete(manual.id)}
|
||||
className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-full transition-colors"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
330
src/modules/asset/pages/AssetListPage.css
Normal file
330
src/modules/asset/pages/AssetListPage.css
Normal file
@ -0,0 +1,330 @@
|
||||
/* Page Layout */
|
||||
.page-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-end;
|
||||
text-align: left;
|
||||
/* Explicitly set text align */
|
||||
}
|
||||
|
||||
.page-title-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* Toolbar */
|
||||
.table-toolbar {
|
||||
padding: 1.25rem 2rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
position: relative;
|
||||
/* For z-index context */
|
||||
}
|
||||
|
||||
.search-box {
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
/* For popup positioning */
|
||||
}
|
||||
|
||||
/* Filter Popup */
|
||||
.filter-popup {
|
||||
position: absolute;
|
||||
top: 110%;
|
||||
right: 0;
|
||||
width: 280px;
|
||||
background-color: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: var(--shadow-lg);
|
||||
z-index: 50;
|
||||
padding: 1rem;
|
||||
animation: fadeIn 0.15s ease-out;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.filter-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
accent-color: var(--color-brand-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-divider {
|
||||
height: 1px;
|
||||
background-color: #f1f5f9;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
|
||||
.filter-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.text-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #64748b;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.text-btn:hover {
|
||||
color: var(--color-brand-primary);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table-container {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.data-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.data-table th {
|
||||
background-color: #f8fafc;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.data-table td {
|
||||
padding: 0.875rem 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
vertical-align: middle;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.hover-row:hover {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.font-mono {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.text-slate-600 {
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.text-slate-900 {
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.text-slate-500 {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Status Badges */
|
||||
.data-table td.status-cell {
|
||||
text-align: center !important;
|
||||
}
|
||||
|
||||
.data-table td.status-cell>div {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.badge-neutral {
|
||||
background-color: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination-bar {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pagination-controls {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #64748b;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.page-btn:hover:not(:disabled) {
|
||||
background-color: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.page-btn:disabled {
|
||||
color: #cbd5e1;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-number {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
color: #64748b;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.page-number:hover {
|
||||
background-color: #f1f5f9;
|
||||
}
|
||||
|
||||
.page-number.active {
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Table Utilities */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hover-row:hover td {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
|
||||
.hover-row td button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
459
src/modules/asset/pages/AssetListPage.tsx
Normal file
459
src/modules/asset/pages/AssetListPage.tsx
Normal file
@ -0,0 +1,459 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { Card } from '../../../shared/ui/Card';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { Input } from '../../../shared/ui/Input';
|
||||
import { Search, Plus, Filter, Download, ChevronsLeft, ChevronsRight, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import './AssetListPage.css';
|
||||
|
||||
import { getCategories } from './AssetSettingsPage';
|
||||
|
||||
import { assetApi, type Asset } from '../../../shared/api/assetApi';
|
||||
import { SERVER_URL } from '../../../shared/api/client';
|
||||
import ExcelJS from 'exceljs';
|
||||
import { saveAs } from 'file-saver';
|
||||
|
||||
// Mock Data Removed - Now using API
|
||||
|
||||
|
||||
export function AssetListPage() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const itemsPerPage = 8;
|
||||
|
||||
// Fetch Assets
|
||||
useEffect(() => {
|
||||
loadAssets();
|
||||
}, []);
|
||||
|
||||
const loadAssets = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await assetApi.getAll();
|
||||
setAssets(data);
|
||||
} catch (error: any) {
|
||||
console.error("Failed to fetch assets:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 1. Determine Category based on URL & Dynamic Settings
|
||||
const getCategoryFromPath = () => {
|
||||
const categories = getCategories();
|
||||
|
||||
if (location.pathname.includes('facilities')) return categories.find(c => c.menuLink === 'facilities')?.name || '설비';
|
||||
if (location.pathname.includes('tools')) return categories.find(c => c.menuLink === 'tools')?.name || '공구';
|
||||
if (location.pathname.includes('instruments')) return categories.find(c => c.menuLink === 'instruments')?.name || '계측기';
|
||||
if (location.pathname.includes('vehicles')) return categories.find(c => c.menuLink === 'vehicles')?.name || '차량/운반';
|
||||
if (location.pathname.includes('general')) return categories.find(c => c.menuLink === 'general')?.name || '일반 자산';
|
||||
if (location.pathname.includes('consumables')) return categories.find(c => c.menuLink === 'consumables')?.name || '소모품';
|
||||
|
||||
return null; // 'All'
|
||||
};
|
||||
|
||||
const currentCategory = getCategoryFromPath();
|
||||
|
||||
// 2. Determine Page Title
|
||||
const getPageTitle = () => {
|
||||
if (!currentCategory) return '전체 자산 조회';
|
||||
return `${currentCategory} 현황`;
|
||||
};
|
||||
|
||||
const [isFilterOpen, setIsFilterOpen] = useState(false);
|
||||
const [activeFilters, setActiveFilters] = useState({
|
||||
status: [] as string[],
|
||||
location: [] as string[]
|
||||
});
|
||||
|
||||
const toggleFilter = (type: 'status' | 'location', value: string) => {
|
||||
setActiveFilters(prev => {
|
||||
const current = prev[type];
|
||||
const updated = current.includes(value)
|
||||
? current.filter(item => item !== value)
|
||||
: [...current, value];
|
||||
return { ...prev, [type]: updated };
|
||||
});
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setActiveFilters({ status: [], location: [] });
|
||||
};
|
||||
|
||||
// 3. Main Filter Logic
|
||||
const filteredAssets = assets.filter(asset => {
|
||||
// Category Filter
|
||||
if (currentCategory) {
|
||||
// Strict match for category name
|
||||
// Note: If using multiple categories per item, use includes. For now, strict match.
|
||||
// If asset.category contains the currentCategory string (e.g. '자산' in '일반 자산'), it might pass loosely.
|
||||
// But we want strict filtering for specific menus.
|
||||
|
||||
// If the asset category is exactly the current category, keep it.
|
||||
// Or if the asset category includes the main part (legacy logic).
|
||||
// Let's make it strict if possible, or robust for '설비' vs '설비 자산'.
|
||||
|
||||
if (asset.category !== currentCategory) {
|
||||
// Fallback for partial matches if needed, but let's try strict first based on user request.
|
||||
if (!asset.category.includes(currentCategory)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Search Term Filter
|
||||
if (searchTerm) {
|
||||
const lowerTerm = searchTerm.toLowerCase();
|
||||
if (!asset.name.toLowerCase().includes(lowerTerm) &&
|
||||
!asset.id.toLowerCase().includes(lowerTerm) &&
|
||||
!asset.location.toLowerCase().includes(lowerTerm)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Status Filter
|
||||
if (activeFilters.status.length > 0 && !activeFilters.status.includes(asset.status)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Location Filter
|
||||
if (activeFilters.location.length > 0 && !activeFilters.location.includes(asset.location)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// 4. Pagination
|
||||
const totalPages = Math.ceil(filteredAssets.length / itemsPerPage);
|
||||
const paginatedAssets = filteredAssets.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return <span className="badge badge-success">정상 가동</span>;
|
||||
case 'maintain': return <span className="badge badge-warning">점검 중</span>;
|
||||
case 'broken': return <span className="badge badge-danger">수리 필요</span>;
|
||||
default: return <span className="badge badge-neutral">미상</span>;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusText = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return '정상 가동';
|
||||
case 'maintain': return '점검 중';
|
||||
case 'broken': return '수리 필요';
|
||||
case 'disposed': return '폐기 (말소)';
|
||||
default: return '미상';
|
||||
}
|
||||
};
|
||||
|
||||
const handleExcelDownload = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const worksheet = workbook.addWorksheet('자산 목록');
|
||||
|
||||
// Columns
|
||||
worksheet.columns = [
|
||||
{ header: '이미지', key: 'image', width: 15 },
|
||||
{ header: '관리번호', key: 'id', width: 20 },
|
||||
{ header: '자산명', key: 'name', width: 30 },
|
||||
{ header: '카테고리', key: 'category', width: 15 },
|
||||
{ header: '모델명', key: 'model', width: 20 },
|
||||
{ header: 'S/N', key: 'serialNumber', width: 20 },
|
||||
{ header: '제작사', key: 'manufacturer', width: 20 },
|
||||
{ header: '설치 위치', key: 'location', width: 20 },
|
||||
{ header: '상태', key: 'status', width: 15 },
|
||||
{ header: '관리자', key: 'manager', width: 15 },
|
||||
{ header: '도입일', key: 'purchaseDate', width: 15 }
|
||||
];
|
||||
|
||||
// Add rows first (Pass 1) to avoid empty row creation issues
|
||||
const rowsWithImages: { row: ExcelJS.Row, image: string | undefined, id: string }[] = [];
|
||||
|
||||
for (const asset of filteredAssets) {
|
||||
const row = worksheet.addRow({
|
||||
id: asset.id,
|
||||
name: asset.name,
|
||||
category: asset.category,
|
||||
model: asset.model,
|
||||
serialNumber: asset.serialNumber,
|
||||
manufacturer: asset.manufacturer,
|
||||
location: asset.location,
|
||||
status: getStatusText(asset.status),
|
||||
manager: asset.manager,
|
||||
purchaseDate: asset.purchaseDate
|
||||
});
|
||||
|
||||
// Set row height
|
||||
row.height = 60;
|
||||
|
||||
if (asset.image) {
|
||||
rowsWithImages.push({ row, image: asset.image, id: asset.id });
|
||||
}
|
||||
}
|
||||
|
||||
// Embed Images (Pass 2)
|
||||
for (const { row, image, id } of rowsWithImages) {
|
||||
if (!image) continue;
|
||||
try {
|
||||
const imageUrl = image.startsWith('http') ? image : `${SERVER_URL}${image}`;
|
||||
const response = await fetch(imageUrl);
|
||||
const buffer = await response.arrayBuffer();
|
||||
const imageId = workbook.addImage({
|
||||
buffer: buffer,
|
||||
extension: 'png',
|
||||
});
|
||||
|
||||
worksheet.addImage(imageId, {
|
||||
tl: { col: 0, row: row.number - 1 } as any,
|
||||
br: { col: 1, row: row.number } as any,
|
||||
editAs: 'oneCell'
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn('Failed to embed image for asset:', id, err);
|
||||
}
|
||||
}
|
||||
|
||||
// Headers Styling
|
||||
worksheet.getRow(1).font = { bold: true };
|
||||
worksheet.getRow(1).alignment = { vertical: 'middle', horizontal: 'center' };
|
||||
|
||||
// Generate File
|
||||
const buffer = await workbook.xlsx.writeBuffer();
|
||||
const blob = new Blob([buffer], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
|
||||
const today = new Date().toISOString().split('T')[0].replace(/-/g, '');
|
||||
saveAs(blob, `자산목록_${today}.xlsx`);
|
||||
|
||||
} catch (error) {
|
||||
console.error("Excel generation failed:", error);
|
||||
alert("엑셀 다운로드 중 오류가 발생했습니다.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header" style={{ justifyContent: 'flex-start', textAlign: 'left' }}>
|
||||
<div>
|
||||
<h1 className="page-title-text">{getPageTitle()}</h1>
|
||||
<p className="page-subtitle">
|
||||
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card className="content-card">
|
||||
{/* Toolbar */}
|
||||
<div className="table-toolbar relative">
|
||||
<div className="search-box">
|
||||
<Input
|
||||
placeholder="자산명, 관리번호, 위치 검색..."
|
||||
icon={<Search size={18} />}
|
||||
value={searchTerm}
|
||||
onChange={(e) => { setSearchTerm(e.target.value); setCurrentPage(1); }}
|
||||
/>
|
||||
</div>
|
||||
<div className="filter-actions relative">
|
||||
<Button
|
||||
variant={isFilterOpen ? 'primary' : 'secondary'}
|
||||
icon={<Filter size={18} />}
|
||||
onClick={() => setIsFilterOpen(!isFilterOpen)}
|
||||
>
|
||||
필터
|
||||
</Button>
|
||||
<Button variant="secondary" icon={<Download size={16} />} onClick={handleExcelDownload}>엑셀 다운로드</Button>
|
||||
<Button onClick={() => navigate('/asset/register')} icon={<Plus size={16} />}>자산 등록</Button>
|
||||
|
||||
{/* Filter Popup */}
|
||||
{isFilterOpen && (
|
||||
<div className="filter-popup">
|
||||
<div className="filter-section">
|
||||
<h4 className="filter-title">상태</h4>
|
||||
<div className="filter-options">
|
||||
{[
|
||||
{ label: '정상 가동', value: 'active' },
|
||||
{ label: '점검 중', value: 'maintain' },
|
||||
{ label: '수리 필요', value: 'broken' },
|
||||
{ label: '폐기 (말소)', value: 'disposed' }
|
||||
].map(opt => (
|
||||
<label key={opt.value} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeFilters.status.includes(opt.value)}
|
||||
onChange={() => toggleFilter('status', opt.value)}
|
||||
/>
|
||||
{opt.label}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-divider" />
|
||||
<div className="filter-section">
|
||||
<h4 className="filter-title">위치</h4>
|
||||
<div className="filter-options">
|
||||
{[
|
||||
'제1공장 A라인',
|
||||
'제1공장 B라인',
|
||||
'제2공장 창고',
|
||||
'본사 사무실'
|
||||
].map(loc => (
|
||||
<label key={loc} className="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={activeFilters.location.includes(loc)}
|
||||
onChange={() => toggleFilter('location', loc)}
|
||||
/>
|
||||
{loc}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="filter-footer">
|
||||
<button className="text-btn text-sm" onClick={clearFilters}>초기화</button>
|
||||
<Button size="sm" onClick={() => setIsFilterOpen(false)}>닫기</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="table-container">
|
||||
<table className="data-table">
|
||||
<thead>
|
||||
<tr className="text-center">
|
||||
<th className="text-center" style={{ width: '50px' }}><input type="checkbox" /></th>
|
||||
<th className="text-center">관리번호</th>
|
||||
<th className="text-center">자산명</th>
|
||||
<th className="text-center">카테고리</th>
|
||||
<th className="text-center">모델명</th>
|
||||
<th className="text-center">설치 위치</th>
|
||||
<th className="text-center">상태</th>
|
||||
<th className="text-center">관리자</th>
|
||||
<th className="text-center">도입일</th>
|
||||
<th className="text-center w-24">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="text-center py-8 text-slate-500">
|
||||
데이터를 불러오는 중입니다...
|
||||
</td>
|
||||
</tr>
|
||||
) : paginatedAssets.length > 0 ? (
|
||||
paginatedAssets.map((asset) => (
|
||||
<tr key={asset.id} className="hover-row group text-center">
|
||||
<td><input type="checkbox" /></td>
|
||||
<td className="text-slate-600">
|
||||
<span
|
||||
className="hover:text-blue-600 hover:underline cursor-pointer group-hover:text-blue-600 transition-colors"
|
||||
onClick={() => navigate(`/asset/detail/${asset.id}`)}
|
||||
title="상세 정보 보기"
|
||||
>
|
||||
{asset.id}
|
||||
</span>
|
||||
</td>
|
||||
<td className="font-medium text-slate-900">
|
||||
<span
|
||||
className="hover:text-blue-600 hover:underline cursor-pointer group-hover:text-blue-600 transition-colors"
|
||||
onClick={() => navigate(`/asset/detail/${asset.id}`)}
|
||||
title="상세 정보 보기"
|
||||
>
|
||||
{asset.name}
|
||||
</span>
|
||||
</td>
|
||||
<td><span className="category-tag">{asset.category}</span></td>
|
||||
<td className="text-slate-500">{asset.model}</td>
|
||||
<td>{asset.location}</td>
|
||||
<td className="status-cell">
|
||||
<div>
|
||||
{getStatusBadge(asset.status)}
|
||||
</div>
|
||||
</td>
|
||||
<td>{asset.manager}</td>
|
||||
<td className="text-slate-500">{asset.purchaseDate}</td>
|
||||
<td>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 text-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/asset/detail/${asset.id}`);
|
||||
}}
|
||||
>
|
||||
관리
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={9} className="empty-state">
|
||||
데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
<div className="pagination-bar">
|
||||
<div className="pagination-info">
|
||||
총 <span className="font-bold">{filteredAssets.length}</span>개 중 <span className="font-bold">{filteredAssets.length > 0 ? (currentPage - 1) * itemsPerPage + 1 : 0} - {Math.min(currentPage * itemsPerPage, filteredAssets.length)}</span>
|
||||
</div>
|
||||
<div className="pagination-controls">
|
||||
<button
|
||||
className="page-btn"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage(1)}
|
||||
>
|
||||
<ChevronsLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="page-btn"
|
||||
disabled={currentPage === 1}
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
|
||||
<button
|
||||
key={page}
|
||||
className={`page-number ${currentPage === page ? 'active' : ''}`}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
|
||||
<button
|
||||
className="page-btn"
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="page-btn"
|
||||
disabled={currentPage === totalPages || totalPages === 0}
|
||||
onClick={() => setCurrentPage(totalPages)}
|
||||
>
|
||||
<ChevronsRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
331
src/modules/asset/pages/AssetRegisterPage.tsx
Normal file
331
src/modules/asset/pages/AssetRegisterPage.tsx
Normal file
@ -0,0 +1,331 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card } from '../../../shared/ui/Card';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { Input } from '../../../shared/ui/Input';
|
||||
import { Select } from '../../../shared/ui/Select';
|
||||
import { ArrowLeft, Save, Upload } from 'lucide-react';
|
||||
import { getCategories, getLocations, getIDRule } from './AssetSettingsPage';
|
||||
import { assetApi, type Asset } from '../../../shared/api/assetApi';
|
||||
|
||||
export function AssetRegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Load Settings Data
|
||||
const categories = getCategories();
|
||||
const locations = getLocations();
|
||||
const idRule = getIDRule();
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
id: '', // Asset ID (Auto-generated)
|
||||
name: '',
|
||||
categoryId: '', // Use ID for selection
|
||||
model: '',
|
||||
serialNo: '',
|
||||
locationId: '', // Use ID for selection
|
||||
manager: '',
|
||||
status: 'active',
|
||||
purchaseDate: new Date().toISOString().split('T')[0], // Default to today
|
||||
purchasePrice: '',
|
||||
image: null as File | null,
|
||||
imagePreview: '' as string,
|
||||
manufacturer: ''
|
||||
});
|
||||
|
||||
// Auto-generate Asset ID
|
||||
useEffect(() => {
|
||||
// If category is required by rule but not selected, can't generate fully
|
||||
const hasCategoryRule = idRule.some(r => r.type === 'category');
|
||||
if (hasCategoryRule && !formData.categoryId) {
|
||||
setFormData(prev => ({ ...prev, id: '' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const category = categories.find(c => c.id === formData.categoryId);
|
||||
const year = formData.purchaseDate ? new Date(formData.purchaseDate).getFullYear().toString() : new Date().getFullYear().toString();
|
||||
|
||||
// Build ID string based on Rule
|
||||
const generatedId = idRule.map(part => {
|
||||
if (part.type === 'company') return part.value; // e.g. HK
|
||||
if (part.type === 'custom') return part.value;
|
||||
if (part.type === 'separator') return part.value;
|
||||
if (part.type === 'year') return year;
|
||||
if (part.type === 'category') return category ? category.code : 'UNKNOWN';
|
||||
if (part.type === 'sequence') return part.value; // In real app, we fetch next seq. here we just show the format pattern e.g. 001
|
||||
return '';
|
||||
}).join('');
|
||||
|
||||
// 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]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
image: file,
|
||||
imagePreview: reader.result as string
|
||||
}));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!formData.categoryId || !formData.name || !formData.locationId) {
|
||||
alert('필수 항목(카테고리, 자산명, 설치위치)을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Map IDs to Names/Codes for Backend
|
||||
const selectedCategory = categories.find(c => c.id === formData.categoryId);
|
||||
const selectedLocation = locations.find(l => l.id === formData.locationId);
|
||||
|
||||
// Upload Image if exists
|
||||
let imageUrl = '';
|
||||
if (formData.image) {
|
||||
const uploadRes = await assetApi.uploadImage(formData.image);
|
||||
imageUrl = uploadRes.data.url;
|
||||
}
|
||||
|
||||
const payload: Partial<Asset> = {
|
||||
id: formData.id,
|
||||
name: formData.name,
|
||||
category: selectedCategory ? selectedCategory.name : '미지정', // Backend expects name
|
||||
model: formData.model,
|
||||
serialNumber: formData.serialNo,
|
||||
location: selectedLocation ? selectedLocation.name : '미지정',
|
||||
manager: formData.manager,
|
||||
status: formData.status as Asset['status'],
|
||||
purchaseDate: formData.purchaseDate,
|
||||
purchasePrice: formData.purchasePrice ? Number(formData.purchasePrice) : 0,
|
||||
manufacturer: formData.manufacturer,
|
||||
image: imageUrl
|
||||
};
|
||||
|
||||
await assetApi.create(payload);
|
||||
|
||||
alert(`자산이 성공적으로 등록되었습니다.\n자산번호: ${formData.id}`);
|
||||
navigate('/asset/list');
|
||||
} catch (error) {
|
||||
console.error('Failed to register asset:', error);
|
||||
alert('자산 등록에 실패했습니다. 서버 상태를 확인해주세요.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<Card className="w-full h-full shadow-sm border border-slate-200">
|
||||
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="col-span-full border-b border-slate-100 pb-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-slate-800">기본 정보</h3>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="카테고리 *"
|
||||
name="categoryId"
|
||||
value={formData.categoryId}
|
||||
onChange={handleChange}
|
||||
options={categories.map(c => ({ label: c.name, value: c.id }))}
|
||||
placeholder="카테고리 선택"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="자산 관리 번호 (자동 생성)"
|
||||
name="id"
|
||||
value={formData.id}
|
||||
disabled
|
||||
placeholder="카테고리 선택 시 자동 생성됨"
|
||||
className="bg-slate-50 font-mono text-slate-600"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="자산명 *"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="예: CNC 머시닝 센터"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="제작사"
|
||||
name="manufacturer"
|
||||
value={formData.manufacturer}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="모델명"
|
||||
name="model"
|
||||
value={formData.model}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="시리얼 번호 / 규격"
|
||||
name="serialNo"
|
||||
value={formData.serialNo}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
{/* Image Upload Field */}
|
||||
<div className="ui-field-container col-span-full">
|
||||
<label className="ui-label">자산 이미지</label>
|
||||
<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' }}>
|
||||
{/* Preview Area - Fixed size container */}
|
||||
<div
|
||||
className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
|
||||
style={{
|
||||
width: '400px',
|
||||
height: '350px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
|
||||
|
||||
{/* Management 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>
|
||||
|
||||
<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>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
295
src/modules/asset/pages/AssetSettingsPage.css
Normal file
295
src/modules/asset/pages/AssetSettingsPage.css
Normal file
@ -0,0 +1,295 @@
|
||||
/* Page Header adjustments for Settings */
|
||||
.page-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0 !important;
|
||||
/* Override default bottom padding */
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.header-top {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Settings Tabs */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: -1px;
|
||||
/* Overlap border */
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.settings-tab:hover {
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
color: var(--color-brand-primary);
|
||||
font-weight: 700;
|
||||
border-bottom-color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
/* Common Layout */
|
||||
.input-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
/* Align inputs and button bottom */
|
||||
margin-bottom: 1.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Ensure inputs in the row take full width of their container */
|
||||
.input-row .ui-field-container {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.row-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 2px;
|
||||
/* Slight adjustment to align with input border */
|
||||
}
|
||||
|
||||
/* Table Styles */
|
||||
.table-wrapper {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.settings-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.settings-table th {
|
||||
background-color: #f8fafc;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.settings-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: var(--color-text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.settings-table tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.settings-table tr.editing-row {
|
||||
background-color: #f0f9ff;
|
||||
}
|
||||
|
||||
.code-badge {
|
||||
background-color: #e0f2fe;
|
||||
color: #0369a1;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.cell-actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.icon-btn {
|
||||
color: #94a3b8;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.icon-btn:hover {
|
||||
background-color: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.icon-btn.delete:hover {
|
||||
background-color: #fee2e2;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Rule Builder Items */
|
||||
.preview-box {
|
||||
background-color: #f1f5f9;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.preview-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.preview-code {
|
||||
font-family: monospace;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.rule-builder {
|
||||
min-height: 80px;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.rule-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
min-height: 60px;
|
||||
padding: 0.5rem;
|
||||
border: 2px dashed #e2e8f0;
|
||||
border-radius: var(--radius-md);
|
||||
background-color: #fcfcfc;
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background-color: white;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.25rem 0.25rem 0.75rem;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.rule-chip {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: #334155;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.rule-chip.type-company {
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.rule-chip.type-category {
|
||||
color: #c026d3;
|
||||
}
|
||||
|
||||
.rule-chip.type-year {
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.rule-chip.type-sequence {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.rule-chip.type-separator {
|
||||
color: #64748b;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.rule-actions {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.rule-actions button {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
color: #94a3b8;
|
||||
transition: all 0.2s;
|
||||
background: none;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.rule-actions button:hover:not(:disabled) {
|
||||
background-color: #f1f5f9;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.rule-actions button.delete:hover {
|
||||
background-color: #fee2e2;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.rule-actions button:disabled {
|
||||
opacity: 0.3;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Rule Tools */
|
||||
.tools-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.tools-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.custom-tool {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
padding-left: 0.5rem;
|
||||
border-left: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.custom-text-input {
|
||||
height: 32px;
|
||||
/* Match button height (sm size) */
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 0.875rem;
|
||||
width: 150px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.custom-text-input:focus {
|
||||
border-color: var(--color-brand-primary);
|
||||
}
|
||||
933
src/modules/asset/pages/AssetSettingsPage.tsx
Normal file
933
src/modules/asset/pages/AssetSettingsPage.tsx
Normal file
@ -0,0 +1,933 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Card } from '../../../shared/ui/Card';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { Input } from '../../../shared/ui/Input';
|
||||
import { Plus, Trash2, Edit2, X, Check, GripVertical } from 'lucide-react';
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import './AssetSettingsPage.css';
|
||||
|
||||
// --- DnD Components ---
|
||||
const SortableItemContext = React.createContext<any>(null);
|
||||
|
||||
function SortableRow({
|
||||
id,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
id: string;
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
zIndex: isDragging ? 2 : 1,
|
||||
position: 'relative' as const,
|
||||
};
|
||||
|
||||
return (
|
||||
<tr ref={setNodeRef} style={style} className={className}>
|
||||
<SortableItemContext.Provider value={{ attributes, listeners }}>
|
||||
{children}
|
||||
</SortableItemContext.Provider>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
function DragHandle() {
|
||||
const { attributes, listeners } = React.useContext(SortableItemContext);
|
||||
return (
|
||||
<button className="cursor-grab active:cursor-grabbing p-1 text-slate-400 hover:text-slate-600" {...attributes} {...listeners}>
|
||||
<GripVertical size={16} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SortableRuleItem({ id, children, className }: { id: string; children: React.ReactNode; className?: string }) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
display: 'flex',
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={className} {...attributes} {...listeners}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------
|
||||
|
||||
// Types
|
||||
interface AssetCategory {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
menuLink?: string;
|
||||
}
|
||||
|
||||
interface AssetLocation {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
interface AssetStatus {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
type IDRuleComponentType = 'company' | 'category' | 'year' | 'sequence' | 'separator' | 'custom';
|
||||
|
||||
interface IDRuleComponent {
|
||||
id: string;
|
||||
type: IDRuleComponentType;
|
||||
value: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Initial Mock Data
|
||||
let GLOBAL_CATEGORIES: AssetCategory[] = [
|
||||
{ id: '1', name: '설비', code: 'FAC', menuLink: 'facilities' },
|
||||
{ id: '2', name: '공구', code: 'TOL', menuLink: 'tools' },
|
||||
{ id: '3', name: '계측기', code: 'INS' },
|
||||
{ id: '4', name: '차량/운반', code: 'VEH' },
|
||||
{ id: '5', name: '일반 자산', code: 'GEN', menuLink: 'general' },
|
||||
{ id: '6', name: '소모품', code: 'CSM', menuLink: 'consumables' },
|
||||
];
|
||||
|
||||
let GLOBAL_LOCATIONS: AssetLocation[] = [
|
||||
{ id: '1', name: '제1공장 A라인' },
|
||||
{ id: '2', name: '제1공장 B라인' },
|
||||
{ id: '3', name: '제2공장 창고' },
|
||||
{ id: '4', name: '본사 사무실' },
|
||||
];
|
||||
|
||||
let GLOBAL_STATUSES: AssetStatus[] = [
|
||||
{ id: '1', name: '정상 가동', code: 'active', color: 'success' },
|
||||
{ id: '2', name: '점검 중', code: 'maintain', color: 'warning' },
|
||||
{ id: '3', name: '수리 필요', code: 'broken', color: 'danger' },
|
||||
{ id: '4', name: '폐기', code: 'disposed', color: 'neutral' },
|
||||
];
|
||||
|
||||
let GLOBAL_ID_RULE: IDRuleComponent[] = [
|
||||
{ id: 'r1', type: 'company', value: 'HK', label: '회사약어' },
|
||||
{ id: 'r2', type: 'separator', value: '-', label: '구분자' },
|
||||
{ id: 'r3', type: 'category', value: '', label: '카테고리' },
|
||||
{ id: 'r4', type: 'separator', value: '-', label: '구분자' },
|
||||
{ id: 'r5', type: 'year', value: 'YYYY', label: '년도' },
|
||||
{ id: 'r6', type: 'separator', value: '-', label: '구분자' },
|
||||
{ id: 'r7', type: 'sequence', value: '001', label: '일련번호(3자리)' },
|
||||
];
|
||||
|
||||
export interface AssetMaintenanceType {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
let GLOBAL_MAINTENANCE_TYPES: AssetMaintenanceType[] = [
|
||||
{ id: '1', name: '정기점검', color: 'success' },
|
||||
{ id: '2', name: '수리', color: 'danger' },
|
||||
{ id: '3', name: '부품교체', color: 'warning' },
|
||||
{ id: '4', name: '기타', color: 'neutral' },
|
||||
];
|
||||
|
||||
// EXPORTS REQUIRED BY OTHER PAGES
|
||||
export const getCategories = () => GLOBAL_CATEGORIES;
|
||||
export const getLocations = () => GLOBAL_LOCATIONS;
|
||||
export const getStatuses = () => GLOBAL_STATUSES;
|
||||
export const getIDRule = () => GLOBAL_ID_RULE;
|
||||
export const getMaintenanceTypes = () => GLOBAL_MAINTENANCE_TYPES;
|
||||
|
||||
export function AssetSettingsPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const activeTab = searchParams.get('tab') || 'basic';
|
||||
|
||||
// State
|
||||
const [categories, setCategories] = useState<AssetCategory[]>(GLOBAL_CATEGORIES);
|
||||
const [locations, setLocations] = useState<AssetLocation[]>(GLOBAL_LOCATIONS);
|
||||
const [statuses, setStatuses] = useState<AssetStatus[]>(GLOBAL_STATUSES);
|
||||
const [maintenanceTypes, setMaintenanceTypes] = useState<AssetMaintenanceType[]>(GLOBAL_MAINTENANCE_TYPES);
|
||||
const [idRule, setIdRule] = useState<IDRuleComponent[]>(GLOBAL_ID_RULE);
|
||||
|
||||
// Form inputs
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [newCategoryCode, setNewCategoryCode] = useState('');
|
||||
const [newCategoryMenuLink, setNewCategoryMenuLink] = useState('');
|
||||
const [editingCatId, setEditingCatId] = useState<string | null>(null);
|
||||
|
||||
const [newLocationName, setNewLocationName] = useState('');
|
||||
const [editingLocId, setEditingLocId] = useState<string | null>(null);
|
||||
|
||||
const [newStatusName, setNewStatusName] = useState('');
|
||||
const [newStatusCode, setNewStatusCode] = useState('');
|
||||
const [newStatusColor, setNewStatusColor] = useState('neutral');
|
||||
const [editingStatusId, setEditingStatusId] = useState<string | null>(null);
|
||||
|
||||
const [newMaintName, setNewMaintName] = useState('');
|
||||
const [newMaintColor, setNewMaintColor] = useState('success');
|
||||
const [editingMaintId, setEditingMaintId] = useState<string | null>(null);
|
||||
|
||||
const [customRuleText, setCustomRuleText] = useState('');
|
||||
const [companyCodeInput, setCompanyCodeInput] = useState('HK');
|
||||
|
||||
// DnD Sensors
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) return;
|
||||
|
||||
if (activeTab === 'category') {
|
||||
setCategories((items) => {
|
||||
const oldIndex = items.findIndex((i) => i.id === active.id);
|
||||
const newIndex = items.findIndex((i) => i.id === over.id);
|
||||
const updated = arrayMove(items, oldIndex, newIndex);
|
||||
GLOBAL_CATEGORIES = updated;
|
||||
return updated;
|
||||
});
|
||||
} else if (activeTab === 'location') {
|
||||
setLocations((items) => {
|
||||
const oldIndex = items.findIndex((i) => i.id === active.id);
|
||||
const newIndex = items.findIndex((i) => i.id === over.id);
|
||||
const updated = arrayMove(items, oldIndex, newIndex);
|
||||
GLOBAL_LOCATIONS = updated;
|
||||
return updated;
|
||||
});
|
||||
} else if (activeTab === 'status') {
|
||||
setStatuses((items) => {
|
||||
const oldIndex = items.findIndex((i) => i.id === active.id);
|
||||
const newIndex = items.findIndex((i) => i.id === over.id);
|
||||
const updated = arrayMove(items, oldIndex, newIndex);
|
||||
GLOBAL_STATUSES = updated;
|
||||
return updated;
|
||||
});
|
||||
} else if (activeTab === 'maintenance') {
|
||||
setMaintenanceTypes((items) => {
|
||||
const oldIndex = items.findIndex((i) => i.id === active.id);
|
||||
const newIndex = items.findIndex((i) => i.id === over.id);
|
||||
const updated = arrayMove(items, oldIndex, newIndex);
|
||||
GLOBAL_MAINTENANCE_TYPES = updated;
|
||||
return updated;
|
||||
});
|
||||
} else if (activeTab === 'basic') {
|
||||
setIdRule((items) => {
|
||||
const oldIndex = items.findIndex((i) => i.id === active.id);
|
||||
const newIndex = items.findIndex((i) => i.id === over.id);
|
||||
const updated = arrayMove(items, oldIndex, newIndex);
|
||||
GLOBAL_ID_RULE = updated;
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Category Logic ---
|
||||
const addOrUpdateCategory = () => {
|
||||
if (!newCategoryName || !newCategoryCode) return alert('모두 입력해주세요.');
|
||||
|
||||
if (editingCatId) {
|
||||
// Update
|
||||
const updated = categories.map(c => c.id === editingCatId ? { ...c, name: newCategoryName, code: newCategoryCode, menuLink: newCategoryMenuLink } : c);
|
||||
setCategories(updated);
|
||||
GLOBAL_CATEGORIES = updated;
|
||||
setEditingCatId(null);
|
||||
} else {
|
||||
// Add
|
||||
const newEntry = { id: Date.now().toString(), name: newCategoryName, code: newCategoryCode, menuLink: newCategoryMenuLink };
|
||||
const updated = [...categories, newEntry];
|
||||
setCategories(updated);
|
||||
GLOBAL_CATEGORIES = updated;
|
||||
}
|
||||
setNewCategoryName('');
|
||||
setNewCategoryCode('');
|
||||
setNewCategoryMenuLink('');
|
||||
};
|
||||
|
||||
const startEditCategory = (id: string, name: string, code: string, menuLink?: string) => {
|
||||
setNewCategoryName(name);
|
||||
setNewCategoryCode(code);
|
||||
setNewCategoryMenuLink(menuLink || '');
|
||||
setEditingCatId(id);
|
||||
};
|
||||
|
||||
const cancelEditCategory = () => {
|
||||
setNewCategoryName('');
|
||||
setNewCategoryCode('');
|
||||
setNewCategoryMenuLink('');
|
||||
setEditingCatId(null);
|
||||
};
|
||||
|
||||
const deleteCategory = (id: string) => {
|
||||
if (confirm('삭제하시겠습니까?')) {
|
||||
const updated = categories.filter(c => c.id !== id);
|
||||
setCategories(updated);
|
||||
GLOBAL_CATEGORIES = updated;
|
||||
if (editingCatId === id) cancelEditCategory();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Location Logic ---
|
||||
const addOrUpdateLocation = () => {
|
||||
if (!newLocationName) return alert('장소명을 입력해주세요.');
|
||||
|
||||
if (editingLocId) {
|
||||
const updated = locations.map(l => l.id === editingLocId ? { ...l, name: newLocationName } : l);
|
||||
setLocations(updated);
|
||||
GLOBAL_LOCATIONS = updated;
|
||||
setEditingLocId(null);
|
||||
} else {
|
||||
const newEntry = { id: Date.now().toString(), name: newLocationName };
|
||||
const updated = [...locations, newEntry];
|
||||
setLocations(updated);
|
||||
GLOBAL_LOCATIONS = updated;
|
||||
}
|
||||
setNewLocationName('');
|
||||
};
|
||||
|
||||
const startEditLocation = (id: string, name: string) => {
|
||||
setNewLocationName(name);
|
||||
setEditingLocId(id);
|
||||
};
|
||||
|
||||
const cancelEditLocation = () => {
|
||||
setNewLocationName('');
|
||||
setEditingLocId(null);
|
||||
};
|
||||
|
||||
const deleteLocation = (id: string) => {
|
||||
if (confirm('삭제하시겠습니까?')) {
|
||||
const updated = locations.filter(l => l.id !== id);
|
||||
setLocations(updated);
|
||||
GLOBAL_LOCATIONS = updated;
|
||||
if (editingLocId === id) cancelEditLocation();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Status Functions ---
|
||||
const startEditStatus = (id: string, name: string, code: string, color: string) => {
|
||||
setEditingStatusId(id);
|
||||
setNewStatusName(name);
|
||||
setNewStatusCode(code);
|
||||
setNewStatusColor(color);
|
||||
};
|
||||
|
||||
const cancelEditStatus = () => {
|
||||
setEditingStatusId(null);
|
||||
setNewStatusName('');
|
||||
setNewStatusCode('');
|
||||
setNewStatusColor('neutral');
|
||||
};
|
||||
|
||||
const addOrUpdateStatus = () => {
|
||||
if (!newStatusName.trim() || !newStatusCode.trim()) return alert('상태명과 코드를 입력해주세요.');
|
||||
|
||||
if (editingStatusId) {
|
||||
// Update
|
||||
const updated = statuses.map(item =>
|
||||
item.id === editingStatusId
|
||||
? { ...item, name: newStatusName, code: newStatusCode, color: newStatusColor }
|
||||
: item
|
||||
);
|
||||
setStatuses(updated);
|
||||
GLOBAL_STATUSES = updated;
|
||||
cancelEditStatus();
|
||||
} else {
|
||||
// Add
|
||||
const newId = String(Date.now());
|
||||
const newItem: AssetStatus = { id: newId, name: newStatusName, code: newStatusCode, color: newStatusColor };
|
||||
const updated = [...statuses, newItem];
|
||||
setStatuses(updated);
|
||||
GLOBAL_STATUSES = updated;
|
||||
setNewStatusName('');
|
||||
setNewStatusCode('');
|
||||
setNewStatusColor('neutral');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteStatus = (id: string) => {
|
||||
if (window.confirm('정말 삭제하시겠습니까?')) {
|
||||
const updated = statuses.filter(item => item.id !== id);
|
||||
setStatuses(updated);
|
||||
GLOBAL_STATUSES = updated;
|
||||
if (editingStatusId === id) cancelEditStatus();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- Maintenance Type Logic ---
|
||||
const startEditMaint = (id: string, name: string, color: string) => {
|
||||
setEditingMaintId(id);
|
||||
setNewMaintName(name);
|
||||
setNewMaintColor(color);
|
||||
};
|
||||
|
||||
const cancelEditMaint = () => {
|
||||
setEditingMaintId(null);
|
||||
setNewMaintName('');
|
||||
setNewMaintColor('neutral');
|
||||
};
|
||||
|
||||
const addOrUpdateMaint = () => {
|
||||
if (!newMaintName.trim()) return alert('구분명을 입력해주세요.');
|
||||
|
||||
if (editingMaintId) {
|
||||
const updated = maintenanceTypes.map(item =>
|
||||
item.id === editingMaintId
|
||||
? { ...item, name: newMaintName, color: newMaintColor }
|
||||
: item
|
||||
);
|
||||
setMaintenanceTypes(updated);
|
||||
GLOBAL_MAINTENANCE_TYPES = updated;
|
||||
cancelEditMaint();
|
||||
} else {
|
||||
const newId = String(Date.now());
|
||||
const newItem: AssetMaintenanceType = { id: newId, name: newMaintName, color: newMaintColor };
|
||||
const updated = [...maintenanceTypes, newItem];
|
||||
setMaintenanceTypes(updated);
|
||||
GLOBAL_MAINTENANCE_TYPES = updated;
|
||||
setNewMaintName('');
|
||||
setNewMaintColor('neutral');
|
||||
}
|
||||
};
|
||||
|
||||
const deleteMaint = (id: string) => {
|
||||
if (window.confirm('정말 삭제하시겠습니까?')) {
|
||||
const updated = maintenanceTypes.filter(item => item.id !== id);
|
||||
setMaintenanceTypes(updated);
|
||||
GLOBAL_MAINTENANCE_TYPES = updated;
|
||||
if (editingMaintId === id) cancelEditMaint();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// --- ID Rule Logic ---
|
||||
const addRuleComponent = (type: IDRuleComponentType, defaultValue: string, label: string) => {
|
||||
const newComp: IDRuleComponent = {
|
||||
id: Date.now().toString(),
|
||||
type,
|
||||
value: type === 'custom' ? customRuleText : defaultValue,
|
||||
label
|
||||
};
|
||||
|
||||
if (type === 'custom' && !customRuleText) return alert('사용자 정의 텍스트를 입력하세요.');
|
||||
|
||||
const updated = [...idRule, newComp];
|
||||
setIdRule(updated);
|
||||
GLOBAL_ID_RULE = updated;
|
||||
setCustomRuleText('');
|
||||
};
|
||||
|
||||
const removeRuleComponent = (id: string) => {
|
||||
const updated = idRule.filter(comp => comp.id !== id);
|
||||
setIdRule(updated);
|
||||
GLOBAL_ID_RULE = updated;
|
||||
};
|
||||
|
||||
|
||||
const getPreviewId = () => {
|
||||
const mockData = {
|
||||
company: 'HK',
|
||||
category: 'FAC',
|
||||
year: new Date().getFullYear().toString(),
|
||||
sequence: '001',
|
||||
separator: '-'
|
||||
};
|
||||
|
||||
return idRule.map(part => {
|
||||
if (part.type === 'custom') return part.value;
|
||||
if (part.type === 'company') return part.value;
|
||||
if (part.type === 'category') return mockData.category;
|
||||
if (part.type === 'year') return mockData.year;
|
||||
if (part.type === 'sequence') return part.value;
|
||||
if (part.type === 'separator') return part.value;
|
||||
return '';
|
||||
}).join('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="settings-content mt-4">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{activeTab === 'basic' && (
|
||||
<Card className="settings-card max-w-5xl">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">자산 관리번호 생성 규칙</h2>
|
||||
<p className="card-desc">자산 등록 시 자동 생성될 관리번호의 포맷을 조합합니다.</p>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{/* Preview Box */}
|
||||
<div className="preview-box">
|
||||
<span className="preview-label">생성 예시:</span>
|
||||
<span className="preview-code">{getPreviewId()}</span>
|
||||
</div>
|
||||
|
||||
{/* Configurator */}
|
||||
<div className="rule-builder">
|
||||
<div className="rule-list">
|
||||
{idRule.length === 0 && <div className="text-slate-400 p-4">규칙 요소가 없습니다. 아래에서 추가하세요.</div>}
|
||||
<SortableContext items={idRule} strategy={horizontalListSortingStrategy}>
|
||||
{idRule.map((comp) => (
|
||||
<SortableRuleItem key={comp.id} id={comp.id} className="rule-item cursor-grab active:cursor-grabbing">
|
||||
<span className={`rule-chip type-${comp.type}`}>
|
||||
{comp.label}
|
||||
{(comp.type === 'company' || comp.type === 'custom' || comp.type === 'separator') && ` (${comp.value})`}
|
||||
</span>
|
||||
<div className="rule-actions">
|
||||
{/* Up/Down buttons removed in favor of DnD */}
|
||||
<button className="delete" onPointerDown={(e) => e.stopPropagation()} onClick={() => removeRuleComponent(comp.id)}><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</SortableRuleItem>
|
||||
))}
|
||||
</SortableContext>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Toolset */}
|
||||
<div className="rule-tools">
|
||||
<h4 className="tools-title">규칙 요소 추가</h4>
|
||||
<div className="tools-grid">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: 'auto' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="약어 (예: HK)"
|
||||
value={companyCodeInput}
|
||||
onChange={e => setCompanyCodeInput(e.target.value.toUpperCase())}
|
||||
className="custom-text-input"
|
||||
maxLength={5}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('company', companyCodeInput, `회사약어 (${companyCodeInput})`)}>회사약어 ({companyCodeInput})</Button>
|
||||
</div>
|
||||
<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('sequence', '001', '일련번호')}>일련번호</Button>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('separator', '-', '구분자 (-)')}>기호 (-)</Button>
|
||||
<div className="custom-tool">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="사용자 정의 텍스트"
|
||||
value={customRuleText}
|
||||
onChange={e => setCustomRuleText(e.target.value)}
|
||||
className="custom-text-input"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('custom', '', '사용자 정의')}>추가</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'category' && (
|
||||
<Card className="settings-card max-w-4xl">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">카테고리 관리</h2>
|
||||
<p className="card-desc">자산 유형을 분류하는 카테고리를 관리합니다. (약어는 자산번호 생성 시 사용됩니다)</p>
|
||||
</div>
|
||||
|
||||
<div className="card-body">
|
||||
<div style={{ display: 'flex', alignItems: 'flex-end', gap: '16px', padding: '16px', backgroundColor: '#f8fafc', borderRadius: '0.5rem', marginBottom: '1rem', border: '1px solid #e2e8f0', width: 'fit-content' }}>
|
||||
<div style={{ width: '300px', marginBottom: 0 }}>
|
||||
<label className="ui-label">카테고리 명</label>
|
||||
<div className="ui-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="ui-input"
|
||||
placeholder="예: 설비, 공구"
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ width: '120px', marginBottom: 0 }}>
|
||||
<label className="ui-label">약어</label>
|
||||
<div className="ui-input-wrapper">
|
||||
<input
|
||||
type="text"
|
||||
className="ui-input"
|
||||
placeholder="CODE"
|
||||
value={newCategoryCode}
|
||||
onChange={(e) => setNewCategoryCode(e.target.value.toUpperCase())}
|
||||
maxLength={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ui-field-container" style={{ width: '200px', marginBottom: 0 }}>
|
||||
<label className="ui-label">메뉴 연결</label>
|
||||
<div className="ui-input-wrapper">
|
||||
<select
|
||||
className="ui-input"
|
||||
value={newCategoryMenuLink}
|
||||
onChange={(e) => setNewCategoryMenuLink(e.target.value)}
|
||||
style={{ height: '50px', width: '100%' }}
|
||||
>
|
||||
<option value="">(연결 안 함)</option>
|
||||
<option value="facilities">설비 자산 탭</option>
|
||||
<option value="tools">공구 관리 탭</option>
|
||||
<option value="instruments">계측기 관리 탭</option>
|
||||
<option value="vehicles">차량/운반 탭</option>
|
||||
<option value="general">일반 자산 탭</option>
|
||||
<option value="consumables">소모품 관리 탭</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-none">
|
||||
<div className="flex gap-2">
|
||||
<Button style={{ height: '50px' }} onClick={addOrUpdateCategory} icon={editingCatId ? <Check size={16} /> : <Plus size={16} />}>
|
||||
{editingCatId ? '수정' : '추가'}
|
||||
</Button>
|
||||
{editingCatId && (
|
||||
<Button variant="secondary" style={{ height: '50px' }} onClick={cancelEditCategory} icon={<X size={16} />}>취소</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrapper">
|
||||
<table className="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '50px' }}></th>
|
||||
<th>카테고리 명</th>
|
||||
<th style={{ width: '120px' }}>약어</th>
|
||||
<th style={{ width: '150px' }}>메뉴 연결</th>
|
||||
<th style={{ width: '100px', textAlign: 'center' }}>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<SortableContext items={categories} strategy={verticalListSortingStrategy}>
|
||||
<tbody>
|
||||
{categories.map((cat) => (
|
||||
<SortableRow key={cat.id} id={cat.id} className={editingCatId === cat.id ? 'editing-row' : ''}>
|
||||
<td><DragHandle /></td>
|
||||
<td>{cat.name}</td>
|
||||
<td><span className="code-badge">{cat.code}</span></td>
|
||||
<td>
|
||||
{cat.menuLink ? (
|
||||
<span className="text-xs bg-slate-100 text-slate-600 px-2 py-1 rounded">
|
||||
{cat.menuLink}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-slate-300">-</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-actions">
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => startEditCategory(cat.id, cat.name, cat.code, cat.menuLink)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn delete"
|
||||
onClick={() => deleteCategory(cat.id)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</SortableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'location' && (
|
||||
<Card className="settings-card max-w-3xl">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">설치 위치 / 보관 장소</h2>
|
||||
<p className="card-desc">자산이 위치할 수 있는 장소를 관리합니다.</p>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="input-row">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
label="장소 명"
|
||||
placeholder="예: 제1공장 A라인, 본사 창고"
|
||||
value={newLocationName}
|
||||
onChange={(e) => setNewLocationName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<Button onClick={addOrUpdateLocation} icon={editingLocId ? <Check size={16} /> : <Plus size={16} />}>
|
||||
{editingLocId ? '수정 완료' : '추가'}
|
||||
</Button>
|
||||
{editingLocId && (
|
||||
<Button variant="secondary" onClick={cancelEditLocation} icon={<X size={16} />}>취소</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrapper">
|
||||
<table className="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '50px' }}></th>
|
||||
<th>장소 명</th>
|
||||
<th style={{ width: '100px', textAlign: 'center' }}>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<SortableContext items={locations} strategy={verticalListSortingStrategy}>
|
||||
<tbody>
|
||||
{locations.map((loc) => (
|
||||
<SortableRow key={loc.id} id={loc.id} className={editingLocId === loc.id ? 'editing-row' : ''}>
|
||||
<td><DragHandle /></td>
|
||||
<td>{loc.name}</td>
|
||||
<td>
|
||||
<div className="cell-actions">
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => startEditLocation(loc.id, loc.name)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn delete"
|
||||
onClick={() => deleteLocation(loc.id)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</SortableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'status' && (
|
||||
<Card className="settings-card max-w-5xl">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">자산 상태 관리</h2>
|
||||
<p className="card-desc">자산의 상태(운용, 파손, 수리 등)를 정의하고 관리합니다.</p>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="input-row" style={{ display: 'flex', alignItems: 'flex-end', gap: '1rem' }}>
|
||||
<div style={{ flex: 2 }}>
|
||||
<Input
|
||||
label="상태"
|
||||
placeholder="예: 정상 가동"
|
||||
value={newStatusName}
|
||||
onChange={(e) => setNewStatusName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1.5 }}>
|
||||
<Input
|
||||
label="상태 코드"
|
||||
placeholder="예: active"
|
||||
value={newStatusCode}
|
||||
onChange={(e) => setNewStatusCode(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1.5 }}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">라벨 색상</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-500"
|
||||
value={newStatusColor}
|
||||
onChange={(e) => setNewStatusColor(e.target.value)}
|
||||
>
|
||||
<option value="success">초록색 (Success)</option>
|
||||
<option value="warning">주황색 (Warning)</option>
|
||||
<option value="danger">빨간색 (Danger)</option>
|
||||
<option value="primary">파란색 (Primary)</option>
|
||||
<option value="neutral">회색 (Neutral)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<Button onClick={addOrUpdateStatus} icon={editingStatusId ? <Check size={16} /> : <Plus size={16} />}>
|
||||
{editingStatusId ? '수정 완료' : '추가'}
|
||||
</Button>
|
||||
{editingStatusId && (
|
||||
<Button variant="secondary" onClick={cancelEditStatus} icon={<X size={16} />}>취소</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrapper">
|
||||
<table className="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '50px' }}></th>
|
||||
<th>상태</th>
|
||||
<th>코드</th>
|
||||
<th>라벨 미리보기</th>
|
||||
<th style={{ width: '100px', textAlign: 'center' }}>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<SortableContext items={statuses} strategy={verticalListSortingStrategy}>
|
||||
<tbody>
|
||||
{statuses.map((item) => (
|
||||
<SortableRow key={item.id} id={item.id} className={editingStatusId === item.id ? 'editing-row' : ''}>
|
||||
<td><DragHandle /></td>
|
||||
<td>{item.name}</td>
|
||||
<td><code className="text-xs bg-slate-100 px-2 py-1 rounded">{item.code}</code></td>
|
||||
<td>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-${item.color}-100 text-${item.color}-700`}>
|
||||
{item.name}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-actions">
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => startEditStatus(item.id, item.name, item.code, item.color)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn delete"
|
||||
onClick={() => deleteStatus(item.id)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</SortableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'maintenance' && (
|
||||
<Card className="settings-card max-w-4xl">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">유지보수 구분 관리</h2>
|
||||
<p className="card-desc">유지보수 이력 등록 시 사용할 작업 구분을 관리합니다.</p>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="input-row" style={{ display: 'flex', alignItems: 'flex-end', gap: '1rem' }}>
|
||||
<div style={{ flex: 2 }}>
|
||||
<Input
|
||||
label="구분명"
|
||||
placeholder="예: 정기점검, 수리"
|
||||
value={newMaintName}
|
||||
onChange={(e) => setNewMaintName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ flex: 1.5 }}>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">라벨 색상</label>
|
||||
<select
|
||||
className="w-full px-3 py-2 bg-white border border-slate-300 rounded-md focus:outline-none focus:ring-2 focus:ring-slate-500"
|
||||
value={newMaintColor}
|
||||
onChange={(e) => setNewMaintColor(e.target.value)}
|
||||
>
|
||||
<option value="success">초록색 (Success)</option>
|
||||
<option value="warning">주황색 (Warning)</option>
|
||||
<option value="danger">빨간색 (Danger)</option>
|
||||
<option value="primary">파란색 (Primary)</option>
|
||||
<option value="neutral">회색 (Neutral)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="row-actions">
|
||||
<Button onClick={addOrUpdateMaint} icon={editingMaintId ? <Check size={16} /> : <Plus size={16} />}>
|
||||
{editingMaintId ? '수정 완료' : '추가'}
|
||||
</Button>
|
||||
{editingMaintId && (
|
||||
<Button variant="secondary" onClick={cancelEditMaint} icon={<X size={16} />}>취소</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="table-wrapper">
|
||||
<table className="settings-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: '50px' }}></th>
|
||||
<th>구분명</th>
|
||||
<th>라벨 미리보기</th>
|
||||
<th style={{ width: '100px', textAlign: 'center' }}>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<SortableContext items={maintenanceTypes} strategy={verticalListSortingStrategy}>
|
||||
<tbody>
|
||||
{maintenanceTypes.map((item) => (
|
||||
<SortableRow key={item.id} id={item.id} className={editingMaintId === item.id ? 'editing-row' : ''}>
|
||||
<td><DragHandle /></td>
|
||||
<td>{item.name}</td>
|
||||
<td>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-${item.color}-100 text-${item.color}-700`}>
|
||||
{item.name}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="cell-actions">
|
||||
<button
|
||||
className="icon-btn"
|
||||
onClick={() => startEditMaint(item.id, item.name, item.color)}
|
||||
title="수정"
|
||||
>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button
|
||||
className="icon-btn delete"
|
||||
onClick={() => deleteMaint(item.id)}
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</SortableRow>
|
||||
))}
|
||||
</tbody>
|
||||
</SortableContext>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</DndContext>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
284
src/modules/asset/pages/DashboardPage.css
Normal file
284
src/modules/asset/pages/DashboardPage.css
Normal file
@ -0,0 +1,284 @@
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
box-shadow: var(--shadow-sm);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-start;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: var(--radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.stat-trend {
|
||||
font-size: 0.75rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.25rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat-trend.positive {
|
||||
color: #10b981;
|
||||
/* Green */
|
||||
}
|
||||
|
||||
.stat-trend.negative {
|
||||
color: #ef4444;
|
||||
/* Red */
|
||||
}
|
||||
|
||||
/* Utility Colors for Icons */
|
||||
.bg-blue-50 {
|
||||
background-color: #eff6ff;
|
||||
}
|
||||
|
||||
.text-blue-600 {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.bg-red-50 {
|
||||
background-color: #fef2f2;
|
||||
}
|
||||
|
||||
.text-red-600 {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.bg-green-50 {
|
||||
background-color: #f0fdf4;
|
||||
}
|
||||
|
||||
.text-green-600 {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.bg-purple-50 {
|
||||
background-color: #faf5ff;
|
||||
}
|
||||
|
||||
.text-purple-600 {
|
||||
color: #9333ea;
|
||||
}
|
||||
|
||||
/* Dashboard Main Grid */
|
||||
.dashboard-main-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
/* Activity List */
|
||||
.activity-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
}
|
||||
|
||||
.activity-item:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.activity-icon {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background-color: #f1f5f9;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.activity-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.activity-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.activity-time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 999px;
|
||||
font-weight: 500;
|
||||
background-color: #f1f5f9;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.status-badge.success {
|
||||
background-color: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.status-badge.warning {
|
||||
background-color: #ffedd5;
|
||||
color: #9a3412;
|
||||
}
|
||||
|
||||
.status-badge.danger {
|
||||
background-color: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
/* Tailwind Utilities (Mock) */
|
||||
.bg-white {
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.rounded-lg {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.border {
|
||||
border: 1px solid;
|
||||
}
|
||||
|
||||
.border-slate-200 {
|
||||
border-color: #e2e8f0;
|
||||
}
|
||||
|
||||
.border-slate-100 {
|
||||
border-color: #f1f5f9;
|
||||
}
|
||||
|
||||
.shadow-sm {
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.p-6 {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.px-6 {
|
||||
padding-left: 1.5rem;
|
||||
padding-right: 1.5rem;
|
||||
}
|
||||
|
||||
.py-4 {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.border-b {
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-slate-800 {
|
||||
color: #1e293b;
|
||||
}
|
||||
|
||||
.text-slate-500 {
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.text-red-600 {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.text-orange-500 {
|
||||
color: #f97316;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.text-sm {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.p-3 {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.bg-slate-50 {
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
121
src/modules/asset/pages/DashboardPage.tsx
Normal file
121
src/modules/asset/pages/DashboardPage.tsx
Normal file
@ -0,0 +1,121 @@
|
||||
import { Card } from '../../../shared/ui/Card';
|
||||
import { Package, AlertTriangle, CheckCircle, TrendingUp, DollarSign } from 'lucide-react';
|
||||
import './DashboardPage.css';
|
||||
|
||||
export function DashboardPage() {
|
||||
return (
|
||||
<div className="dashboard-container">
|
||||
{/* Stats Row */}
|
||||
<div className="stats-grid">
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon bg-blue-50 text-blue-600">
|
||||
<Package size={24} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-label">총 자산</span>
|
||||
<span className="stat-value">1,248</span>
|
||||
<span className="stat-trend positive">
|
||||
<TrendingUp size={14} /> +12%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon bg-red-50 text-red-600">
|
||||
<AlertTriangle size={24} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-label">정비 필요</span>
|
||||
<span className="stat-value">5</span>
|
||||
<span className="stat-trend negative">
|
||||
긴급 조치 요망
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon bg-green-50 text-green-600">
|
||||
<CheckCircle size={24} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-label">가동률</span>
|
||||
<span className="stat-value">98.5%</span>
|
||||
<span className="stat-trend positive">
|
||||
<TrendingUp size={14} /> +0.5%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stat-card">
|
||||
<div className="stat-icon bg-purple-50 text-purple-600">
|
||||
<DollarSign size={24} />
|
||||
</div>
|
||||
<div className="stat-info">
|
||||
<span className="stat-label">이번 달 구매</span>
|
||||
<span className="stat-value">₩ 12.5M</span>
|
||||
<span className="stat-trend">
|
||||
3건 예정
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Grid */}
|
||||
<div className="dashboard-main-grid">
|
||||
<Card title="최근 정비 이력" className="recent-activity">
|
||||
<ul className="activity-list">
|
||||
{[1, 2, 3, 4, 5].map((i) => (
|
||||
<li key={i} className="activity-item">
|
||||
<div className="activity-icon">
|
||||
<WrenchIcon />
|
||||
</div>
|
||||
<div className="activity-details">
|
||||
<p className="activity-text">
|
||||
<span className="font-medium">CNC 머신 #0{i}</span> 정기 점검 완료
|
||||
</p>
|
||||
<p className="activity-time">2시간 전</p>
|
||||
</div>
|
||||
<span className="status-badge success">완료</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title="소모품 부족 알림" className="stock-alert">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="bg-slate-50 text-slate-500">
|
||||
<tr>
|
||||
<th className="p-3">품목명</th>
|
||||
<th className="p-3">현재고</th>
|
||||
<th className="p-3">상태</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="p-3 border-b border-slate-100">절삭유 A-Type</td>
|
||||
<td className="p-3 border-b border-slate-100 font-bold text-red-600">2 Drum</td>
|
||||
<td className="p-3 border-b border-slate-100"><span className="status-badge danger">주문필요</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3 border-b border-slate-100">베어링 6204</td>
|
||||
<td className="p-3 border-b border-slate-100 font-bold text-orange-500">5 EA</td>
|
||||
<td className="p-3 border-b border-slate-100"><span className="status-badge warning">부족</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="p-3 border-b border-slate-100">장갑 (L)</td>
|
||||
<td className="p-3 border-b border-slate-100">12 Pack</td>
|
||||
<td className="p-3 border-b border-slate-100"><span className="status-badge">정상</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WrenchIcon() {
|
||||
return (
|
||||
<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="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /></svg>
|
||||
)
|
||||
}
|
||||
71
src/modules/cctv/components/JSMpegPlayer.tsx
Normal file
71
src/modules/cctv/components/JSMpegPlayer.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
|
||||
|
||||
interface JSMpegPlayerProps {
|
||||
url: string;
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function JSMpegPlayer({ url, width, height, className }: JSMpegPlayerProps) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const playerRef = useRef<any>(null);
|
||||
const [retryCount, setRetryCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasRef.current || !url) return;
|
||||
|
||||
let player: any = null;
|
||||
let reconnectTimer: any = null;
|
||||
|
||||
const initPlayer = () => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const JSMpeg = window.JSMpeg;
|
||||
if (!JSMpeg) {
|
||||
console.error('JSMpeg library not loaded correctly');
|
||||
return;
|
||||
}
|
||||
|
||||
// JSMpeg v2 uses JSMpeg.Player and accepts string URL
|
||||
player = new JSMpeg.Player(url, {
|
||||
canvas: canvasRef.current,
|
||||
autoplay: true,
|
||||
audio: false,
|
||||
loop: false,
|
||||
onSourceCompleted: () => {
|
||||
console.log('Source completed/Disconnected, reconnecting in 1s...');
|
||||
// Trigger usage of reconnect
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
reconnectTimer = setTimeout(() => {
|
||||
setRetryCount(prev => prev + 1); // Trigger effect re-run
|
||||
}, 1000);
|
||||
}
|
||||
});
|
||||
playerRef.current = player;
|
||||
} catch (e) {
|
||||
console.error('Failed to init JSMpeg player', e);
|
||||
}
|
||||
};
|
||||
|
||||
initPlayer();
|
||||
|
||||
return () => {
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
if (player) {
|
||||
try {
|
||||
player.destroy();
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [url, retryCount]); // Re-run when url or retryCount changes
|
||||
|
||||
return (
|
||||
<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" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
462
src/modules/cctv/pages/MonitoringPage.tsx
Normal file
462
src/modules/cctv/pages/MonitoringPage.tsx
Normal file
@ -0,0 +1,462 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { apiClient } from '../../../shared/api/client';
|
||||
import { JSMpegPlayer } from '../components/JSMpegPlayer';
|
||||
import { Plus, Settings, Trash2, X, Video } from 'lucide-react';
|
||||
import { useAuth } from '../../../shared/auth/AuthContext';
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, useSortable, rectSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
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';
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
// Wrap Camera Card with Sortable
|
||||
function SortableCamera({ camera, children, disabled }: { camera: Camera, children: React.ReactNode, disabled: boolean }) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id: camera.id, disabled });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className="bg-white rounded-xl shadow-lg overflow-hidden border border-slate-200">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MonitoringPage() {
|
||||
const { user } = useAuth();
|
||||
const [cameras, setCameras] = useState<Camera[]>([]);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [editingCamera, setEditingCamera] = useState<Camera | null>(null);
|
||||
const [formData, setFormData] = useState<Partial<Camera>>({
|
||||
name: '',
|
||||
ip_address: '',
|
||||
port: 554,
|
||||
username: '',
|
||||
password: '',
|
||||
stream_path: '/stream1',
|
||||
transport_mode: 'tcp',
|
||||
rtsp_encoding: false,
|
||||
quality: 'low'
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [streamVersions, setStreamVersions] = useState<{ [key: number]: number }>({});
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCameras();
|
||||
}, []);
|
||||
|
||||
const fetchCameras = async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/cameras');
|
||||
setCameras(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch cameras', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setLoading(true);
|
||||
try {
|
||||
if (editingCamera) {
|
||||
await apiClient.put(`/cameras/${editingCamera.id}`, formData);
|
||||
// Force stream refresh for this camera
|
||||
setStreamVersions(prev => ({ ...prev, [editingCamera.id]: Date.now() }));
|
||||
} else {
|
||||
await apiClient.post('/cameras', formData);
|
||||
}
|
||||
setShowForm(false);
|
||||
setEditingCamera(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
ip_address: '',
|
||||
port: 554,
|
||||
username: '',
|
||||
password: '',
|
||||
stream_path: '/stream1',
|
||||
transport_mode: 'tcp',
|
||||
rtsp_encoding: false,
|
||||
quality: 'low'
|
||||
});
|
||||
fetchCameras();
|
||||
} catch (err) {
|
||||
console.error('Failed to save camera', err);
|
||||
const errMsg = (err as any).response?.data?.error || (err as any).message || '카메라 저장 실패';
|
||||
alert(`오류 발생: ${errMsg}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (camera: Camera) => {
|
||||
setEditingCamera(camera);
|
||||
setFormData(camera);
|
||||
setShowForm(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!window.confirm('정말 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/cameras/${id}`);
|
||||
fetchCameras();
|
||||
} catch (err) {
|
||||
console.error('Failed to delete camera', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
setCameras((items) => {
|
||||
const oldIndex = items.findIndex((item) => item.id === active.id);
|
||||
const newIndex = items.findIndex((item) => item.id === over.id);
|
||||
const newItems = arrayMove(items, oldIndex, newIndex);
|
||||
|
||||
// Save new order
|
||||
apiClient.put('/cameras/reorder', { cameras: newItems.map(c => c.id) })
|
||||
.catch(e => console.error('Failed to save order', e));
|
||||
|
||||
return newItems;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const getStreamUrl = (cameraId: number) => {
|
||||
// Use current host (proxied or direct)
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const version = streamVersions[cameraId] || 0;
|
||||
const url = `${protocol}//${window.location.host}/api/stream?cameraId=${cameraId}&v=${version}`;
|
||||
console.log(`[CCTV] Connecting to: ${url}`);
|
||||
return url;
|
||||
};
|
||||
|
||||
const handlePing = async (id: number) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/cameras/${id}/ping`);
|
||||
|
||||
// Critical check: If the server returns HTML (catch-all), it's old code
|
||||
if (typeof res.data === 'string' && res.data.includes('<html')) {
|
||||
alert('❌ Server Sync Error!\nThe production server is still running old code (received HTML instead of JSON).\nPlease re-upload the "server" folder and restart PM2.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.data.success) {
|
||||
alert(`✅ Ping Success!\nServer can reach Camera (${res.data.ip}).`);
|
||||
} else {
|
||||
alert(`❌ Ping Failed!\nServer cannot reach Camera (${res.data.ip || 'Unknown IP'}).\nCheck NAS network/firewall.`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.response?.status === 404) {
|
||||
alert('❌ API Not Found (404)!\nThe server does not have the "ping" feature. Please update and restart the backend.');
|
||||
} else {
|
||||
alert('Ping API Error. Please check server logs.');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const checkServerVersion = async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/health');
|
||||
console.log('[System] Server Version:', res.data);
|
||||
} catch (e) {
|
||||
console.warn('[System] Could not fetch server health - might be old version.');
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCameras();
|
||||
checkServerVersion();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Video className="text-blue-600" />
|
||||
CCTV
|
||||
</h1>
|
||||
{user?.role === 'admin' && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingCamera(null);
|
||||
setFormData({
|
||||
name: '',
|
||||
ip_address: '',
|
||||
port: 554,
|
||||
username: '',
|
||||
password: '',
|
||||
stream_path: '/stream1'
|
||||
});
|
||||
setShowForm(true);
|
||||
}}
|
||||
className="bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center gap-2 hover:bg-blue-700 transition"
|
||||
>
|
||||
<Plus size={18} />
|
||||
카메라 추가
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Camera Grid with DnD */}
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={cameras.map(c => c.id)}
|
||||
strategy={rectSortingStrategy}
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-6">
|
||||
{cameras.map(camera => (
|
||||
<SortableCamera key={camera.id} camera={camera} disabled={user?.role !== 'admin'}>
|
||||
<div className="relative aspect-video bg-black group">
|
||||
<JSMpegPlayer
|
||||
key={`${camera.id}-${streamVersions[camera.id] || 0}`}
|
||||
url={getStreamUrl(camera.id)}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
{/* Overlay Controls */}
|
||||
{user?.role === 'admin' && (
|
||||
<div className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity flex gap-2" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
onClick={() => handlePing(camera.id)}
|
||||
className="bg-green-600/80 text-white p-2 rounded-full hover:bg-green-700"
|
||||
title="연결 테스트 (Ping)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m21 2-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3L15.5 7.5z" /></svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleEdit(camera)}
|
||||
className="bg-black/50 text-white p-2 rounded-full hover:bg-black/70"
|
||||
title="설정"
|
||||
>
|
||||
<Settings size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(camera.id)}
|
||||
className="bg-red-500/80 text-white p-2 rounded-full hover:bg-red-600"
|
||||
title="삭제"
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4">
|
||||
<h3 className="text-white font-medium flex items-center gap-2">
|
||||
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
|
||||
{camera.name}
|
||||
</h3>
|
||||
{/* IP/Port Hidden as requested */}
|
||||
</div>
|
||||
</div>
|
||||
</SortableCamera>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{cameras.length === 0 && (
|
||||
<div className="text-center py-20 text-slate-500">
|
||||
<Video size={48} className="mx-auto mb-4 opacity-50" />
|
||||
<p>등록된 카메라가 없습니다. 카메라를 추가해주세요.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Modal */}
|
||||
{showForm && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-lg p-6 animate-in fade-in zoom-in duration-200">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h2 className="text-xl font-bold">
|
||||
{editingCamera ? '카메라 설정 수정' : '새 카메라 추가'}
|
||||
</h2>
|
||||
<button onClick={() => setShowForm(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">카메라 이름</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
required
|
||||
className="w-full border border-slate-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
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 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
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 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
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 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
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 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
/>
|
||||
</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 focus:ring-2 focus:ring-blue-500 outline-none"
|
||||
placeholder="/stream1"
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
TAPO C200 예시: /stream1 (고화질) 또는 /stream2 (저화질)
|
||||
</p>
|
||||
</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>
|
||||
<p className="text-xs text-slate-400 mt-2">
|
||||
* 연결이 불안정하면 TCP로 설정하세요. 비밀번호에 특수문자가 있어 연결 실패 시 인코딩을 켜보세요.
|
||||
</p>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
127
src/pages/auth/LoginPage.css
Normal file
127
src/pages/auth/LoginPage.css
Normal file
@ -0,0 +1,127 @@
|
||||
.login-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: var(--color-bg-base);
|
||||
background: linear-gradient(135deg, var(--color-bg-sidebar) 0%, var(--color-brand-primary) 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 3rem;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.brand-logo {
|
||||
display: inline-flex;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg-sidebar);
|
||||
color: var(--color-text-inverse);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.login-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 2.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
background-color: var(--color-bg-surface);
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(82, 109, 130, 0.1);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
margin-top: 1rem;
|
||||
padding: 0.875rem;
|
||||
background-color: var(--color-bg-sidebar);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.025em;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.login-btn:hover {
|
||||
background-color: #1e293b;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
88
src/pages/auth/LoginPage.tsx
Normal file
88
src/pages/auth/LoginPage.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../shared/auth/AuthContext';
|
||||
import { Box, Lock, User } from 'lucide-react';
|
||||
import './LoginPage.css';
|
||||
|
||||
export function LoginPage() {
|
||||
const [id, setId] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const { login } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const from = location.state?.from?.pathname || '/asset/dashboard';
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const success = await login(id, password);
|
||||
if (success) {
|
||||
navigate(from, { replace: true });
|
||||
} else {
|
||||
setError('아이디 또는 비밀번호를 확인해주세요.');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('로그인 중 오류가 발생했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-container">
|
||||
<div className="login-card">
|
||||
<div className="login-header">
|
||||
<div className="brand-logo">
|
||||
<Box size={32} />
|
||||
</div>
|
||||
<h1>SmartAsset</h1>
|
||||
<p>통합 자산관리 시스템</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="login-form">
|
||||
<div className="form-group">
|
||||
<label htmlFor="id">아이디</label>
|
||||
<div className="input-wrapper">
|
||||
<User size={18} className="input-icon" />
|
||||
<input
|
||||
id="id"
|
||||
type="text"
|
||||
value={id}
|
||||
onChange={(e) => setId(e.target.value)}
|
||||
placeholder="아이디를 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<label htmlFor="password">비밀번호</label>
|
||||
<div className="input-wrapper">
|
||||
<Lock size={18} className="input-icon" />
|
||||
<input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="error-message">{error}</div>}
|
||||
|
||||
<button type="submit" className="login-btn">
|
||||
로그인
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="login-footer">
|
||||
<p>© 2026 SmartAsset System. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
src/platform/pages/UserManagementPage.css
Normal file
27
src/platform/pages/UserManagementPage.css
Normal file
@ -0,0 +1,27 @@
|
||||
/* UserManagementPage.css */
|
||||
.page-container {
|
||||
padding: 24px;
|
||||
background-color: #f8fafc;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.text-red-500 {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Modal slide-in animation */
|
||||
@keyframes slideIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-in {
|
||||
animation: slideIn 0.3s ease-out forwards;
|
||||
}
|
||||
293
src/platform/pages/UserManagementPage.tsx
Normal file
293
src/platform/pages/UserManagementPage.tsx
Normal file
@ -0,0 +1,293 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card } from '../../shared/ui/Card';
|
||||
import { Button } from '../../shared/ui/Button';
|
||||
import { Input } from '../../shared/ui/Input';
|
||||
import { apiClient } from '../../shared/api/client';
|
||||
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon } from 'lucide-react';
|
||||
import type { User } from '../../shared/auth/AuthContext';
|
||||
import './UserManagementPage.css'; // Will create CSS separately or inline styles initially. Let's assume global css or create specific.
|
||||
|
||||
interface UserFormData {
|
||||
id: string;
|
||||
password?: string;
|
||||
name: string;
|
||||
department: string;
|
||||
position: string;
|
||||
phone: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
|
||||
export function UserManagementPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Modal State
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [formData, setFormData] = useState<UserFormData>({
|
||||
id: '',
|
||||
password: '',
|
||||
name: '',
|
||||
department: '',
|
||||
position: '',
|
||||
phone: '',
|
||||
role: 'user'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get('/users');
|
||||
setUsers(res.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users', error);
|
||||
alert('사용자 목록을 불러오지 못했습니다.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenAdd = () => {
|
||||
setFormData({
|
||||
id: '',
|
||||
password: '',
|
||||
name: '',
|
||||
department: '',
|
||||
position: '',
|
||||
phone: '',
|
||||
role: 'user'
|
||||
});
|
||||
setIsEditing(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (user: User) => {
|
||||
setFormData({
|
||||
id: user.id,
|
||||
password: '', // Password empty by default on edit
|
||||
name: user.name,
|
||||
department: user.department || '',
|
||||
position: user.position || '',
|
||||
phone: user.phone || '',
|
||||
role: user.role
|
||||
});
|
||||
setIsEditing(true);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!confirm('정말 이 사용자를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/users/${id}`);
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user', error);
|
||||
alert('삭제 실패');
|
||||
}
|
||||
};
|
||||
|
||||
const formatPhoneNumber = (value: string) => {
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
if (cleaned.length <= 3) {
|
||||
return cleaned;
|
||||
} else if (cleaned.length <= 7) {
|
||||
return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
|
||||
} else {
|
||||
return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
// Update
|
||||
const payload: any = { ...formData };
|
||||
if (!payload.password) delete payload.password; // Don't send empty password
|
||||
|
||||
await apiClient.put(`/users/${formData.id}`, payload);
|
||||
alert('수정되었습니다.');
|
||||
} else {
|
||||
// Create
|
||||
if (!formData.password) return alert('비밀번호를 입력하세요.');
|
||||
await apiClient.post('/users', formData);
|
||||
alert('등록되었습니다.');
|
||||
}
|
||||
setIsModalOpen(false);
|
||||
fetchUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Submit failed', error);
|
||||
const errorMsg = error.response?.data?.error || error.message || '저장 중 오류가 발생했습니다.';
|
||||
alert(`오류: ${errorMsg}`);
|
||||
}
|
||||
};
|
||||
|
||||
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">시스템 관리 - 사용자 관리</h1>
|
||||
<p className="text-slate-500 mt-1">시스템 접속 권한 및 사용자 정보를 관리합니다.</p>
|
||||
</div>
|
||||
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}>사용자 등록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left text-slate-600">
|
||||
<thead className="text-xs text-slate-700 uppercase bg-slate-50 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">연락처</th>
|
||||
<th className="px-6 py-4">마지막 접속</th>
|
||||
<th className="px-6 py-4 text-center">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-slate-900">{user.id}</div>
|
||||
<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'
|
||||
}`}>
|
||||
{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>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && !loading && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-slate-400">등록된 사용자가 없습니다.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<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-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">
|
||||
<h2 className="text-lg font-bold text-slate-800">
|
||||
{isEditing ? '사용자 정보 수정' : '새 사용자 등록'}
|
||||
</h2>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">아이디 <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
value={formData.id}
|
||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||
disabled={isEditing} // ID cannot be changed on edit
|
||||
placeholder="로그인 아이디"
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder={isEditing ? "(변경시에만 입력)" : "비밀번호"}
|
||||
required={!isEditing}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">이름 <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">권한</label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-500 focus:border-transparent"
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'user' | 'admin' })}
|
||||
>
|
||||
<option value="user">일반 사용자</option>
|
||||
<option value="admin">시스템 관리자</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">부서</label>
|
||||
<Input
|
||||
value={formData.department}
|
||||
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">직위</label>
|
||||
<Input
|
||||
value={formData.position}
|
||||
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">핸드폰 번호</label>
|
||||
<Input
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
|
||||
placeholder="예: 010-1234-5678"
|
||||
maxLength={13}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button type="submit" icon={<Check size={16} />}>{isEditing ? '저장' : '등록'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
12
src/production/pages/ProductionPage.tsx
Normal file
12
src/production/pages/ProductionPage.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
|
||||
|
||||
export function ProductionPage() {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<h1 className="text-2xl font-bold mb-4">생산 관리</h1>
|
||||
<div className="bg-white rounded-lg shadow p-6">
|
||||
<p>생산 관리 대시보드입니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
src/shared/api/assetApi.ts
Normal file
150
src/shared/api/assetApi.ts
Normal file
@ -0,0 +1,150 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
// Frontend Interface (CamelCase)
|
||||
export interface Asset {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
model: string; // DB: model_name
|
||||
serialNumber: string; // DB: serial_number
|
||||
manufacturer: string;
|
||||
location: string;
|
||||
purchaseDate: string; // DB: purchase_date
|
||||
manager: string;
|
||||
status: 'active' | 'maintain' | 'broken' | 'disposed';
|
||||
purchasePrice?: number; // DB: purchase_price
|
||||
image?: string; // DB: image_url
|
||||
calibrationCycle?: string; // DB: calibration_cycle
|
||||
specs?: string;
|
||||
}
|
||||
|
||||
// DB Interface (SnakeCase) - for internal mapping
|
||||
interface DBAsset {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
model_name: string;
|
||||
serial_number: string;
|
||||
manufacturer: string;
|
||||
location: string;
|
||||
purchase_date: string;
|
||||
manager: string;
|
||||
status: 'active' | 'maintain' | 'broken' | 'disposed';
|
||||
purchase_price?: number;
|
||||
image_url?: string;
|
||||
calibration_cycle: string;
|
||||
specs: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface MaintenanceRecord {
|
||||
id: number;
|
||||
asset_id: string;
|
||||
maintenance_date: string;
|
||||
type: string;
|
||||
content: string;
|
||||
image_url?: string; // Legacy
|
||||
images?: string[]; // New: Multiple images
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
// Manual Interface
|
||||
export interface Manual {
|
||||
id: number;
|
||||
asset_id: string;
|
||||
file_name: string;
|
||||
file_url: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export const assetApi = {
|
||||
getAll: async (): Promise<Asset[]> => {
|
||||
const response = await apiClient.get<DBAsset[]>('/assets');
|
||||
return response.data.map(mapDBToAsset);
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Asset> => {
|
||||
const response = await apiClient.get<DBAsset>(`/assets/${id}`);
|
||||
return mapDBToAsset(response.data);
|
||||
},
|
||||
|
||||
create: (data: Partial<Asset>) => {
|
||||
const dbData = mapAssetToDB(data);
|
||||
return apiClient.post<{ message: string, id: string }>('/assets', dbData);
|
||||
},
|
||||
|
||||
update: (id: string, data: Partial<Asset>) => {
|
||||
const dbData = mapAssetToDB(data);
|
||||
return apiClient.put(`/assets/${id}`, dbData);
|
||||
},
|
||||
|
||||
uploadImage: (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
return apiClient.post<{ url: string }>('/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
},
|
||||
|
||||
// Maintenance History
|
||||
getMaintenanceHistory: async (assetId: string): Promise<MaintenanceRecord[]> => {
|
||||
const response = await apiClient.get<any[]>(`/assets/${assetId}/maintenance`);
|
||||
return response.data.map(record => ({
|
||||
...record,
|
||||
// Ensure images is parsed as array if it comes as string from DB
|
||||
images: typeof record.images === 'string' ? JSON.parse(record.images) : (record.images || [])
|
||||
}));
|
||||
},
|
||||
|
||||
addMaintenance: async (assetId: string, data: Partial<MaintenanceRecord>) => {
|
||||
return apiClient.post(`/assets/${assetId}/maintenance`, data);
|
||||
},
|
||||
|
||||
deleteMaintenance: async (id: number) => {
|
||||
return apiClient.delete(`/maintenance/${id}`);
|
||||
},
|
||||
|
||||
// Manuals / Instructions
|
||||
getManuals: async (assetId: string): Promise<Manual[]> => {
|
||||
const response = await apiClient.get<Manual[]>(`/assets/${assetId}/manuals`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
addManual: async (assetId: string, data: { file_name: string; file_url: string }) => {
|
||||
return apiClient.post(`/assets/${assetId}/manuals`, data);
|
||||
},
|
||||
|
||||
deleteManual: async (id: number) => {
|
||||
return apiClient.delete(`/manuals/${id}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper Mappers
|
||||
const mapDBToAsset = (db: DBAsset): Asset => ({
|
||||
id: db.id,
|
||||
name: db.name,
|
||||
category: db.category,
|
||||
model: db.model_name || '',
|
||||
serialNumber: db.serial_number || '',
|
||||
manufacturer: db.manufacturer || '',
|
||||
location: db.location || '',
|
||||
purchaseDate: db.purchase_date ? new Date(db.purchase_date).toISOString().split('T')[0] : '', // Format YYYY-MM-DD
|
||||
manager: db.manager || '',
|
||||
status: db.status,
|
||||
purchasePrice: db.purchase_price,
|
||||
image: db.image_url,
|
||||
calibrationCycle: db.calibration_cycle || '',
|
||||
specs: db.specs || ''
|
||||
});
|
||||
|
||||
const mapAssetToDB = (asset: Partial<Asset>): Partial<DBAsset> => {
|
||||
const db: any = { ...asset };
|
||||
if (asset.model !== undefined) { db.model_name = asset.model; delete db.model; }
|
||||
if (asset.serialNumber !== undefined) { db.serial_number = asset.serialNumber; delete db.serialNumber; }
|
||||
if (asset.purchaseDate !== undefined) { db.purchase_date = asset.purchaseDate; delete db.purchaseDate; }
|
||||
if (asset.purchasePrice !== undefined) { db.purchase_price = asset.purchasePrice; delete db.purchasePrice; }
|
||||
if (asset.image !== undefined) { db.image_url = asset.image; delete db.image; }
|
||||
if (asset.calibrationCycle !== undefined) { db.calibration_cycle = asset.calibrationCycle; delete db.calibrationCycle; }
|
||||
return db;
|
||||
};
|
||||
27
src/shared/api/client.ts
Normal file
27
src/shared/api/client.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import axios from 'axios';
|
||||
|
||||
// Backend URL
|
||||
// Empty string for relative path (works for both Dev via Proxy and Prod)
|
||||
export const SERVER_URL = '';
|
||||
export const BASE_URL = '/api';
|
||||
|
||||
export const apiClient = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
withCredentials: true // Enable sending/receiving cookies
|
||||
});
|
||||
|
||||
let csrfToken: string | null = null;
|
||||
|
||||
export const setCsrfToken = (token: string | null) => {
|
||||
csrfToken = token;
|
||||
};
|
||||
|
||||
apiClient.interceptors.request.use((config) => {
|
||||
if (csrfToken && config.method && config.method.toUpperCase() !== 'GET' && config.method.toUpperCase() !== 'HEAD') {
|
||||
config.headers['X-CSRF-Token'] = csrfToken;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
89
src/shared/auth/AuthContext.tsx
Normal file
89
src/shared/auth/AuthContext.tsx
Normal file
@ -0,0 +1,89 @@
|
||||
import { createContext, useContext, useState, type ReactNode, useEffect } from 'react';
|
||||
import { apiClient, setCsrfToken } from '../api/client';
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'admin' | 'user';
|
||||
department?: string;
|
||||
position?: string;
|
||||
phone?: string;
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
login: (id: string, password: string) => Promise<boolean>;
|
||||
logout: () => void;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | null>(null);
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Check for existing session on mount
|
||||
useEffect(() => {
|
||||
const checkSession = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/check');
|
||||
if (response.data.isAuthenticated) {
|
||||
setUser(response.data.user);
|
||||
if (response.data.csrfToken) {
|
||||
setCsrfToken(response.data.csrfToken);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Session check failed:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
const login = async (id: string, password: string) => {
|
||||
try {
|
||||
const response = await apiClient.post('/login', { id, password });
|
||||
if (response.data.success) {
|
||||
setUser(response.data.user);
|
||||
if (response.data.csrfToken) {
|
||||
setCsrfToken(response.data.csrfToken);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await apiClient.post('/logout');
|
||||
} catch (error) {
|
||||
console.error('Logout failed:', error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
setCsrfToken(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ user, login, logout, isAuthenticated: !!user, isLoading }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
20
src/shared/auth/AuthGuard.tsx
Normal file
20
src/shared/auth/AuthGuard.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from './AuthContext';
|
||||
|
||||
export function AuthGuard({ children }: { children: ReactNode }) {
|
||||
const { isAuthenticated, isLoading } = useAuth();
|
||||
const location = useLocation();
|
||||
|
||||
if (isLoading) {
|
||||
// Simple loading state, could be a spinner
|
||||
return <div className="flex items-center justify-center min-h-screen">Loading...</div>;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
// Redirect to the login page, but save the current location they were trying to go to
|
||||
return <Navigate to="/login" state={{ from: location }} replace />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
63
src/shared/context/SystemContext.tsx
Normal file
63
src/shared/context/SystemContext.tsx
Normal file
@ -0,0 +1,63 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { apiClient } from '../api/client';
|
||||
import { useAuth } from '../auth/AuthContext';
|
||||
|
||||
export interface ModuleState {
|
||||
active: boolean;
|
||||
type: 'dev' | 'sub' | 'demo' | null;
|
||||
expiry: string | null;
|
||||
}
|
||||
|
||||
interface SystemContextType {
|
||||
modules: Record<string, ModuleState>;
|
||||
refreshModules: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const SystemContext = createContext<SystemContextType | null>(null);
|
||||
|
||||
export function SystemProvider({ children }: { children: ReactNode }) {
|
||||
const [modules, setModules] = useState<Record<string, ModuleState>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { isAuthenticated } = useAuth(); // Only load if authenticated
|
||||
|
||||
const refreshModules = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/system/modules');
|
||||
if (response.data.modules) {
|
||||
setModules(response.data.modules);
|
||||
} 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);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load system modules:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
refreshModules();
|
||||
} else {
|
||||
setModules({});
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
return (
|
||||
<SystemContext.Provider value={{ modules, refreshModules, isLoading }}>
|
||||
{children}
|
||||
</SystemContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useSystem() {
|
||||
const context = useContext(SystemContext);
|
||||
if (!context) {
|
||||
throw new Error('useSystem must be used within a SystemProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
31
src/shared/ui/Button.tsx
Normal file
31
src/shared/ui/Button.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { type ButtonHTMLAttributes, type ReactNode } from 'react';
|
||||
import './Components.css';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
children: ReactNode;
|
||||
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function Button({
|
||||
children,
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
icon,
|
||||
className = '',
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const variantClass = `ui-btn-${variant}`;
|
||||
const sizeClass = `ui-btn-${size}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`ui-btn ${variantClass} ${sizeClass} ${className}`}
|
||||
{...props}
|
||||
>
|
||||
{icon && <span className="mr-2" style={{ marginRight: '0.5rem', display: 'flex' }}>{icon}</span>}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
24
src/shared/ui/Card.tsx
Normal file
24
src/shared/ui/Card.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface CardProps {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ children, className = '', title, action }: CardProps) {
|
||||
return (
|
||||
<div className={`bg-white rounded-lg border border-slate-200 shadow-sm ${className}`}>
|
||||
{(title || action) && (
|
||||
<div className="px-6 py-4 border-b border-slate-100 flex justify-between items-center">
|
||||
{title && <h3 className="font-semibold text-slate-800">{title}</h3>}
|
||||
{action && <div>{action}</div>}
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
173
src/shared/ui/Components.css
Normal file
173
src/shared/ui/Components.css
Normal file
@ -0,0 +1,173 @@
|
||||
/* Shared UI Components Styles */
|
||||
|
||||
/* Base reset for consistency */
|
||||
.ui-base {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* --- Button --- */
|
||||
.ui-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
border: 1px solid transparent;
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ui-btn:focus-visible {
|
||||
box-shadow: 0 0 0 2px var(--color-brand-accent), 0 0 0 4px var(--color-brand-primary);
|
||||
}
|
||||
|
||||
/* Variants */
|
||||
.ui-btn-primary {
|
||||
background-color: var(--color-bg-sidebar);
|
||||
/* Slate 800 */
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ui-btn-primary:hover {
|
||||
background-color: #0f172a;
|
||||
/* Slate 900 */
|
||||
}
|
||||
|
||||
.ui-btn-secondary {
|
||||
background-color: white;
|
||||
color: var(--color-text-primary);
|
||||
border-color: var(--color-border);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.ui-btn-secondary:hover {
|
||||
background-color: #f8fafc;
|
||||
/* Slate 50 */
|
||||
}
|
||||
|
||||
.ui-btn-danger {
|
||||
background-color: #dc2626;
|
||||
/* Red 600 */
|
||||
color: white;
|
||||
}
|
||||
|
||||
.ui-btn-danger:hover {
|
||||
background-color: #b91c1c;
|
||||
/* Red 700 */
|
||||
}
|
||||
|
||||
.ui-btn-ghost {
|
||||
background-color: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ui-btn-ghost:hover {
|
||||
background-color: #f1f5f9;
|
||||
/* Slate 100 */
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
/* Sizes */
|
||||
.ui-btn-sm {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.ui-btn-md {
|
||||
padding: 0.625rem 1.25rem;
|
||||
font-size: 0.95rem;
|
||||
/* readable base size */
|
||||
}
|
||||
|
||||
.ui-btn-lg {
|
||||
padding: 0.875rem 1.75rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
|
||||
/* --- Input & Select Container --- */
|
||||
.ui-field-container {
|
||||
width: 100%;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ui-label {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
/* Base size */
|
||||
font-weight: 600;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ui-input-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ui-icon-wrapper {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
left: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Common form element styles */
|
||||
.ui-input,
|
||||
.ui-select {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: white;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-primary);
|
||||
font-size: 1rem;
|
||||
/* Clear readable text */
|
||||
line-height: 1.5;
|
||||
transition: var(--transition-base);
|
||||
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.ui-input:focus,
|
||||
.ui-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.15);
|
||||
/* Ring effect */
|
||||
}
|
||||
|
||||
.ui-input:disabled,
|
||||
.ui-select:disabled {
|
||||
background-color: #f8fafc;
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-input.has-icon {
|
||||
padding-left: 2.75rem;
|
||||
/* Space for icon */
|
||||
}
|
||||
|
||||
.ui-input.error,
|
||||
.ui-select.error {
|
||||
border-color: #ef4444;
|
||||
}
|
||||
|
||||
.ui-input.error:focus,
|
||||
.ui-select.error:focus {
|
||||
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.15);
|
||||
}
|
||||
|
||||
.ui-error-msg {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
37
src/shared/ui/Input.tsx
Normal file
37
src/shared/ui/Input.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import { type InputHTMLAttributes, forwardRef, type ReactNode } from 'react';
|
||||
import './Components.css';
|
||||
|
||||
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export const Input = forwardRef<HTMLInputElement, InputProps>(
|
||||
({ label, error, icon, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<div className={`ui-field-container ${className}`}>
|
||||
{label && (
|
||||
<label className="ui-label">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="ui-input-wrapper">
|
||||
{icon && (
|
||||
<div className="ui-icon-wrapper">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
ref={ref}
|
||||
className={`ui-input ${icon ? 'has-icon' : ''} ${error ? 'error' : ''}`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="ui-error-msg">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Input.displayName = 'Input';
|
||||
40
src/shared/ui/Select.tsx
Normal file
40
src/shared/ui/Select.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import { type SelectHTMLAttributes, forwardRef } from 'react';
|
||||
import './Components.css';
|
||||
|
||||
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||
label?: string;
|
||||
error?: string;
|
||||
options: { label: string; value: string }[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
|
||||
({ label, error, options, placeholder, className = '', ...props }, ref) => {
|
||||
return (
|
||||
<div className={`ui-field-container ${className}`}>
|
||||
{label && (
|
||||
<label className="ui-label">
|
||||
{label}
|
||||
</label>
|
||||
)}
|
||||
<div className="ui-input-wrapper">
|
||||
<select
|
||||
ref={ref}
|
||||
className={`ui-select ${error ? 'error' : ''}`}
|
||||
{...props}
|
||||
>
|
||||
{placeholder && <option value="">{placeholder}</option>}
|
||||
{options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{error && <p className="ui-error-msg">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Select.displayName = 'Select';
|
||||
314
src/system/pages/LicensePage.tsx
Normal file
314
src/system/pages/LicensePage.tsx
Normal file
@ -0,0 +1,314 @@
|
||||
import { useState } from 'react';
|
||||
import { useSystem } from '../../shared/context/SystemContext';
|
||||
import { apiClient } from '../../shared/api/client';
|
||||
import { Check, X, Key, Shield, AlertTriangle, Terminal } from 'lucide-react';
|
||||
|
||||
export function LicensePage() {
|
||||
const { modules, refreshModules } = useSystem();
|
||||
const [selectedModule, setSelectedModule] = useState<string | null>(null);
|
||||
const [licenseKey, setLicenseKey] = useState('');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const moduleInfo: Record<string, { title: string, desc: string }> = {
|
||||
'asset': { title: '자산 관리 모듈', desc: '자산 등록, 조회, 수정 및 유지보수 이력 관리' },
|
||||
'production': { title: '생산 관리 모듈', desc: '생산 계획, 실적 및 공정 관리' },
|
||||
'monitoring': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' }
|
||||
};
|
||||
|
||||
// Subscriber Configuration State
|
||||
const [serverSubscriberId, setServerSubscriberId] = useState('');
|
||||
const [inputSubscriberId, setInputSubscriberId] = useState('');
|
||||
const [isConfigSaving, setIsConfigSaving] = useState(false);
|
||||
|
||||
// Initial Load for Subscriber ID
|
||||
useState(() => {
|
||||
fetchSubscriberId();
|
||||
});
|
||||
|
||||
async function fetchSubscriberId() {
|
||||
try {
|
||||
const res = await apiClient.get('/system/config');
|
||||
setServerSubscriberId(res.data.subscriber_id || '');
|
||||
setInputSubscriberId(res.data.subscriber_id || '');
|
||||
} catch (err) {
|
||||
console.error('Failed to load subscriber config', err);
|
||||
}
|
||||
}
|
||||
|
||||
const handleSaveConfig = async () => {
|
||||
if (!inputSubscriberId.trim()) return alert('구독자 ID를 입력해주세요.');
|
||||
setIsConfigSaving(true);
|
||||
try {
|
||||
await apiClient.post('/system/config', { subscriber_id: inputSubscriberId });
|
||||
setServerSubscriberId(inputSubscriberId);
|
||||
alert('구독자 ID가 저장되었습니다.');
|
||||
} catch (err) {
|
||||
alert('설정 저장 실패');
|
||||
} finally {
|
||||
setIsConfigSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleActivate = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!selectedModule || !licenseKey.trim()) return;
|
||||
|
||||
if (!serverSubscriberId) {
|
||||
setError('서버 구독자 ID가 설정되지 않았습니다. 먼저 설정을 완료해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Include user context if needed, but module activation is system-wide
|
||||
await apiClient.post(`/system/modules/${selectedModule}/activate`, { licenseKey });
|
||||
await refreshModules();
|
||||
setSelectedModule(null);
|
||||
setLicenseKey('');
|
||||
alert('모듈이 성공적으로 활성화되었습니다.');
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || '활성화 실패');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeactivate = async (code: string) => {
|
||||
if (!confirm('정말 이 모듈을 비활성화 하시겠습니까? 사용자 메뉴에서 즉시 사라집니다.')) return;
|
||||
try {
|
||||
await apiClient.post(`/system/modules/${code}/deactivate`);
|
||||
await refreshModules();
|
||||
} catch (err) {
|
||||
alert('비활성화 실패');
|
||||
}
|
||||
};
|
||||
|
||||
// ... Generator logic omitted for brevity as it's dev-only ...
|
||||
|
||||
// History State
|
||||
const [historyModule, setHistoryModule] = useState<string | null>(null);
|
||||
const [historyData, setHistoryData] = useState<any[]>([]);
|
||||
|
||||
const fetchHistory = async (code: string) => {
|
||||
try {
|
||||
const res = await apiClient.get(`/system/modules/${code}/history`);
|
||||
setHistoryData(res.data);
|
||||
setHistoryModule(code);
|
||||
} catch (err) {
|
||||
alert('이력을 불러오는데 실패했습니다.');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-xl font-bold flex items-center gap-2">
|
||||
<Shield className="text-blue-600" />
|
||||
라이선스 및 모듈 관리
|
||||
</h2>
|
||||
|
||||
{/* Server Configuration Section */}
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-lg p-6 mb-8">
|
||||
<h3 className="text-lg font-bold mb-4 flex items-center gap-2">
|
||||
<Terminal size={20} className="text-slate-600" />
|
||||
서버 환경 설정
|
||||
</h3>
|
||||
<div className="flex items-end gap-4 max-w-xl">
|
||||
<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)}
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-1">
|
||||
* 라이선스 키에 포함된 ID와 일치해야 활성화됩니다.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleSaveConfig}
|
||||
disabled={isConfigSaving}
|
||||
className="bg-slate-800 text-white px-4 py-2 rounded hover:bg-slate-900 transition disabled:opacity-50"
|
||||
>
|
||||
{isConfigSaving ? '저장 중...' : '설정 저장'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Object.entries(moduleInfo).map(([code, info]) => {
|
||||
// Check typing for modules[code]
|
||||
const state = modules[code] as any || { active: false, type: null, expiry: null };
|
||||
const isActive = state.active;
|
||||
|
||||
return (
|
||||
<div key={code} className={`bg-white p-6 rounded-lg shadow border-l-4 ${isActive ? 'border-green-500' : 'border-gray-300'}`}>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<h3 className="font-bold text-lg">{info.title}</h3>
|
||||
{isActive ? (
|
||||
<span className="bg-green-100 text-green-800 text-xs px-2 py-1 rounded flex items-center gap-1">
|
||||
<Check size={12} /> 활성
|
||||
</span>
|
||||
) : (
|
||||
<span className="bg-gray-100 text-gray-800 text-xs px-2 py-1 rounded flex items-center gap-1">
|
||||
<X size={12} /> 비활성
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-gray-600 text-sm mb-4 h-10">{info.desc}</p>
|
||||
|
||||
{isActive && (
|
||||
<div className="mb-4 text-sm bg-gray-50 p-2 rounded">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500">유형:</span>
|
||||
<span className="font-medium uppercase">{state.type === 'dev' ? '개발자용' : state.type === 'sub' ? '구독자용' : '데모용'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-gray-500">구독자:</span>
|
||||
<span className="font-medium">{state.subscriber_id || 'N/A'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mt-1">
|
||||
<span className="text-gray-500">만료일:</span>
|
||||
<span className="font-medium">
|
||||
{state.expiry ? new Date(state.expiry).toLocaleDateString() : '무제한'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{!isActive ? (
|
||||
<button
|
||||
onClick={() => setSelectedModule(code)}
|
||||
className="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700 transition flex items-center justify-center gap-2"
|
||||
>
|
||||
<Key size={16} /> 활성화 하기
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => fetchHistory(code)}
|
||||
className="flex-1 bg-gray-100 text-gray-700 border border-gray-200 py-2 rounded hover:bg-gray-200 transition text-sm"
|
||||
>
|
||||
변경 이력
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeactivate(code)}
|
||||
className="flex-1 bg-red-50 text-red-600 border border-red-200 py-2 rounded hover:bg-red-100 transition text-sm"
|
||||
>
|
||||
비활성화
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* License Activation Modal */}
|
||||
{selectedModule && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h3 className="text-lg font-bold mb-4">라이선스 키 입력</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">
|
||||
발급받은 라이선스 키를 입력하여 <strong>{moduleInfo[selectedModule]?.title}</strong>을(를) 활성화하세요.
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleActivate}>
|
||||
<textarea
|
||||
className="w-full border rounded p-2 text-sm font-mono h-24 mb-4 resize-none"
|
||||
placeholder="여기에 키를 붙여넣으세요..."
|
||||
value={licenseKey}
|
||||
onChange={(e) => {
|
||||
setLicenseKey(e.target.value);
|
||||
if (error) setError(null);
|
||||
}}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 text-red-600 text-sm flex items-center gap-2 bg-red-50 p-2 rounded">
|
||||
<AlertTriangle size={16} /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSelectedModule(null); setError(null); setLicenseKey(''); }}
|
||||
className="px-4 py-2 text-gray-600 hover:bg-gray-100 rounded"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting ? '확인 중...' : '활성화'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History Modal */}
|
||||
{historyModule && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-2xl">
|
||||
<h3 className="text-lg font-bold mb-4">라이선스 변경 이력 ({moduleInfo[historyModule]?.title})</h3>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left border-collapse">
|
||||
<thead className="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
<th className="p-2 border-b">교체 일시</th>
|
||||
<th className="p-2 border-b">구독자</th>
|
||||
<th className="p-2 border-b">유형</th>
|
||||
<th className="p-2 border-b">라이선스 키 (일부)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{historyData.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={4} className="p-4 text-center text-gray-500">이력이 없습니다.</td>
|
||||
</tr>
|
||||
) : (
|
||||
historyData.map((h: any) => (
|
||||
<tr key={h.id} className="border-b">
|
||||
<td className="p-2">{new Date(h.activated_at).toLocaleString()}</td>
|
||||
<td className="p-2">{h.subscriber_id || 'N/A'}</td>
|
||||
<td className="p-2 uppercase">{h.license_type || 'N/A'}</td>
|
||||
<td className="p-2 font-mono text-gray-500 text-xs">
|
||||
{h.license_key ? h.license_key.substring(0, 20) + '...' : '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end mt-4">
|
||||
<button
|
||||
onClick={() => setHistoryModule(null)}
|
||||
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
239
src/widgets/layout/MainLayout.css
Normal file
239
src/widgets/layout/MainLayout.css
Normal file
@ -0,0 +1,239 @@
|
||||
/* Layout Container */
|
||||
.layout-container {
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: var(--color-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Sidebar */
|
||||
.sidebar {
|
||||
width: 260px;
|
||||
background-color: var(--color-bg-sidebar);
|
||||
color: var(--color-text-inverse);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.05);
|
||||
flex-shrink: 0;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
height: 64px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.brand-text {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 1.5rem 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* Module Group Styles */
|
||||
.module-group {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.module-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 0.75rem;
|
||||
color: #94a3b8;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: var(--transition-base);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.module-header:hover {
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.module-header.active {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.module-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.module-items {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding-left: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.6rem 0.75rem 0.6rem 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
color: #94a3b8;
|
||||
transition: var(--transition-base);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
background-color: var(--color-brand-primary);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
padding: 1rem;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.user-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--color-brand-primary);
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 700;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.user-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.user-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-role {
|
||||
font-size: 0.75rem;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.logout-btn {
|
||||
color: #94a3b8;
|
||||
padding: 0.5rem;
|
||||
border-radius: var(--radius-sm);
|
||||
transition: var(--transition-base);
|
||||
}
|
||||
|
||||
.logout-btn:hover {
|
||||
color: #ef4444;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
|
||||
/* Main Content */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: var(--color-bg-base);
|
||||
}
|
||||
|
||||
/* Top Header - White Theme */
|
||||
.top-header {
|
||||
height: 64px;
|
||||
background-color: var(--color-bg-surface);
|
||||
/* White Header */
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
/* Tabs at bottom */
|
||||
padding: 0 2rem;
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
border-bottom: 3px solid transparent;
|
||||
transition: all 0.2s;
|
||||
font-size: 0.95rem;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
color: var(--color-brand-primary);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: var(--color-brand-primary);
|
||||
font-weight: 700;
|
||||
border-bottom-color: var(--color-brand-primary);
|
||||
/* Blue underline */
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* Content Area */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
226
src/widgets/layout/MainLayout.tsx
Normal file
226
src/widgets/layout/MainLayout.tsx
Normal file
@ -0,0 +1,226 @@
|
||||
import { useState } from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../shared/auth/AuthContext';
|
||||
import { useSystem } from '../../shared/context/SystemContext';
|
||||
import { LayoutDashboard, Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Factory, Video, Shield } from 'lucide-react';
|
||||
import './MainLayout.css';
|
||||
|
||||
export function MainLayout() {
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
const { modules } = useSystem();
|
||||
const [expandedModules, setExpandedModules] = useState<string[]>(['smart_asset']);
|
||||
|
||||
const toggleModule = (moduleId: string) => {
|
||||
setExpandedModules(prev =>
|
||||
prev.includes(moduleId)
|
||||
? prev.filter(id => id !== moduleId)
|
||||
: [...prev, moduleId]
|
||||
);
|
||||
};
|
||||
|
||||
// Helper to check if a module is active (default/fallback handling can be done here or in context)
|
||||
const isModuleActive = (code: string) => modules[code]?.active;
|
||||
|
||||
return (
|
||||
<div className="layout-container">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<div className="brand">
|
||||
<Box className="brand-icon" size={24} />
|
||||
<span className="brand-text">Platform</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{/* Module: System Management */}
|
||||
{user?.role === 'admin' && (
|
||||
<div className="module-group">
|
||||
<button
|
||||
className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`}
|
||||
onClick={() => toggleModule('sys_mgmt')}
|
||||
>
|
||||
<div className="module-title">
|
||||
<Settings size={18} />
|
||||
<span>시스템 관리</span>
|
||||
</div>
|
||||
{expandedModules.includes('sys_mgmt') ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
|
||||
{expandedModules.includes('sys_mgmt') && (
|
||||
<div className="module-items">
|
||||
<Link to="/admin/users" className={`nav-item ${location.pathname.includes('/admin/users') ? 'active' : ''}`}>
|
||||
<UserIcon size={18} />
|
||||
<span>사용자 관리</span>
|
||||
</Link>
|
||||
<Link to="/admin/license" className={`nav-item ${location.pathname.includes('/admin/license') ? 'active' : ''}`}>
|
||||
<Shield size={18} />
|
||||
<span>모듈/라이선스 관리</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module: Asset Management (Renamed from Smart Asset) */}
|
||||
{isModuleActive('asset') && (
|
||||
<div className="module-group">
|
||||
<button
|
||||
className={`module-header ${expandedModules.includes('smart_asset') ? 'active' : ''}`}
|
||||
onClick={() => toggleModule('smart_asset')}
|
||||
>
|
||||
<div className="module-title">
|
||||
<Layers size={18} />
|
||||
<span>자산 관리</span>
|
||||
</div>
|
||||
{expandedModules.includes('smart_asset') ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
|
||||
{expandedModules.includes('smart_asset') && (
|
||||
<div className="module-items">
|
||||
<Link to="/asset/dashboard" className={`nav-item ${location.pathname.includes('dashboard') ? 'active' : ''}`}>
|
||||
<LayoutDashboard size={18} />
|
||||
<span>대시보드</span>
|
||||
</Link>
|
||||
<Link
|
||||
to="/asset/list"
|
||||
className={`nav-item ${['/asset/list', '/asset/facilities', '/asset/tools', '/asset/general', '/asset/consumables'].some(path => location.pathname.includes(path)) ? 'active' : ''}`}
|
||||
>
|
||||
<Box size={18} />
|
||||
<span>자산 현황</span>
|
||||
</Link>
|
||||
<Link to="/asset/settings" className={`nav-item ${location.pathname.includes('settings') ? 'active' : ''}`}>
|
||||
<Settings size={18} />
|
||||
<span>설정</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module: Production Management */}
|
||||
{isModuleActive('production') && (
|
||||
<div className="module-group">
|
||||
<button
|
||||
className={`module-header ${expandedModules.includes('production') ? 'active' : ''}`}
|
||||
onClick={() => toggleModule('production')}
|
||||
>
|
||||
<div className="module-title">
|
||||
<Factory size={18} />
|
||||
<span>생산 관리</span>
|
||||
</div>
|
||||
{expandedModules.includes('production') ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
|
||||
{expandedModules.includes('production') && (
|
||||
<div className="module-items">
|
||||
<Link to="/production/dashboard" className={`nav-item ${location.pathname.includes('/production/dashboard') ? 'active' : ''}`}>
|
||||
<LayoutDashboard size={18} />
|
||||
<span>대시보드</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Module: Monitoring */}
|
||||
{isModuleActive('monitoring') && (
|
||||
<div className="module-group">
|
||||
<Link to="/monitoring" className={`module-header ${location.pathname.includes('/monitoring') ? 'active' : ''}`}>
|
||||
<div className="module-title">
|
||||
<Video size={18} />
|
||||
<span>CCTV</span>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info">
|
||||
<div className="user-avatar bg-slate-700 text-white flex items-center justify-center rounded-full w-8 h-8 text-xs font-bold">
|
||||
{user?.name?.slice(0, 2) || 'USER'}
|
||||
</div>
|
||||
<div className="user-details">
|
||||
<span className="user-name">{user?.name}</span>
|
||||
<span className="user-role text-xs text-slate-400 capitalize">{user?.role}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button className="logout-btn" onClick={logout} title="로그아웃">
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="main-content">
|
||||
<header className="top-header">
|
||||
{/* Dynamic Tabs based on current route */}
|
||||
<div className="header-tabs">
|
||||
{/* Settings Tabs */}
|
||||
{location.pathname.includes('/asset/settings') && (
|
||||
<>
|
||||
<Link
|
||||
to="/asset/settings?tab=basic"
|
||||
className={`tab-item ${(!location.search.includes('tab=') || location.search.includes('tab=basic')) ? 'active' : ''}`}
|
||||
>
|
||||
기본 설정
|
||||
</Link>
|
||||
<Link
|
||||
to="/asset/settings?tab=category"
|
||||
className={`tab-item ${location.search.includes('tab=category') ? 'active' : ''}`}
|
||||
>
|
||||
카테고리 관리
|
||||
</Link>
|
||||
<Link
|
||||
to="/asset/settings?tab=location"
|
||||
className={`tab-item ${location.search.includes('tab=location') ? 'active' : ''}`}
|
||||
>
|
||||
설치 위치
|
||||
</Link>
|
||||
<Link
|
||||
to="/asset/settings?tab=status"
|
||||
className={`tab-item ${location.search.includes('tab=status') ? 'active' : ''}`}
|
||||
>
|
||||
자산 상태
|
||||
</Link>
|
||||
<Link
|
||||
to="/asset/settings?tab=maintenance"
|
||||
className={`tab-item ${location.search.includes('tab=maintenance') ? 'active' : ''}`}
|
||||
>
|
||||
정비 구분
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Asset Management Tabs (Visible for facilities, tools, general, consumables) */}
|
||||
{(location.pathname.startsWith('/asset/list') ||
|
||||
location.pathname.startsWith('/asset/facilities') ||
|
||||
location.pathname.startsWith('/asset/tools') ||
|
||||
location.pathname.startsWith('/asset/general') ||
|
||||
location.pathname.startsWith('/asset/consumables') ||
|
||||
location.pathname.startsWith('/asset/instruments') ||
|
||||
location.pathname.startsWith('/asset/vehicles') ||
|
||||
location.pathname.startsWith('/asset/register') ||
|
||||
location.pathname.startsWith('/asset/detail')) && (
|
||||
<>
|
||||
<Link to="/asset/list" className={`tab-item ${location.pathname === '/asset/list' ? 'active' : ''}`}>전체 조회</Link>
|
||||
<Link to="/asset/facilities" className={`tab-item ${location.pathname.includes('/facilities') ? 'active' : ''}`}>설비 자산</Link>
|
||||
<Link to="/asset/tools" className={`tab-item ${location.pathname.includes('/tools') ? 'active' : ''}`}>공구 관리</Link>
|
||||
<Link to="/asset/instruments" className={`tab-item ${location.pathname.includes('/instruments') ? 'active' : ''}`}>계측기 관리</Link>
|
||||
<Link to="/asset/vehicles" className={`tab-item ${location.pathname.includes('/vehicles') ? 'active' : ''}`}>차량/운반</Link>
|
||||
<Link to="/asset/general" className={`tab-item ${location.pathname.includes('/general') ? 'active' : ''}`}>일반 자산</Link>
|
||||
<Link to="/asset/consumables" className={`tab-item ${location.pathname.includes('/consumables') ? 'active' : ''}`}>소모품 관리</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="header-actions">
|
||||
{/* Future: Notifications, Search */}
|
||||
</div>
|
||||
</header>
|
||||
<div className="content-area">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
tailwind.config.js
Normal file
11
tailwind.config.js
Normal file
@ -0,0 +1,11 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
28
tools/generate_keys.cjs
Normal file
28
tools/generate_keys.cjs
Normal file
@ -0,0 +1,28 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const keysDir = path.join(__dirname, 'keys');
|
||||
|
||||
if (!fs.existsSync(keysDir)) {
|
||||
fs.mkdirSync(keysDir);
|
||||
}
|
||||
|
||||
const { privateKey, publicKey } = crypto.generateKeyPairSync('rsa', {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {
|
||||
type: 'spki',
|
||||
format: 'pem'
|
||||
},
|
||||
privateKeyEncoding: {
|
||||
type: 'pkcs8',
|
||||
format: 'pem'
|
||||
}
|
||||
});
|
||||
|
||||
fs.writeFileSync(path.join(keysDir, 'private_key.pem'), privateKey);
|
||||
fs.writeFileSync(path.join(keysDir, 'public_key.pem'), publicKey);
|
||||
|
||||
console.log('Keys generated successfully in tools/keys/');
|
||||
console.log(' - private_key.pem (KEEP SECRET! For generating licenses)');
|
||||
console.log(' - public_key.pem (Deploy this to server for verification)');
|
||||
28
tools/keys/private_key.pem
Normal file
28
tools/keys/private_key.pem
Normal file
@ -0,0 +1,28 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQC3UEpkVxAba7D8
|
||||
leg5cplNJUjh7jPMmkJMnFj7Csm5PNNUDTPMDPh69FT+2b2mezwFcgOgQ6aRqa6b
|
||||
XPeVutARjovttxsywPHJJxFrSTFlG6s2Ly6mUrGnV7QKYcdi74YQBjqjREfmKTUE
|
||||
/BBLIq5nKMaD4CYh5FrG1zprnbCFyW0VBNltCMCYIPthhQqPUGkG/SGcKN0Uou3o
|
||||
Gu1eLZjZCh6s1oGw4YsLqmMjnzV/j8Orgh1Rm+ezU2rs1QN5PhzEJ57L4ZJ9kgv2
|
||||
8MmkeebZrBhXbDpe7w7QXOdjCnM93CWkhH15P+3gZrQn8LdtPELhkREe2jHxWujD
|
||||
ZYIXb8MHAgMBAAECggEABYnlpYUMekDDKTe24BjoLJVBqzMH/n1OsYNQZYRBNqKW
|
||||
Rk8k8+5dieYTtRJm2mNMQD+VNwkh87IrNkLMHop1cYJbgfsPnpn09D1gYKULEbza
|
||||
Ir6MNP+4D9lSYyMPLQnxFuFt30XXlt0ecSBBtlgG6dNNbVd6lAtJ1bh4e9XrKwsK
|
||||
d+Ot1qKK9k30TrxjQzgO746MH9Jco4oHNnEws380ORX35f3T7jEKzD0MzWpI2q2b
|
||||
YXOg67Lh63WkezBnUhoZZv3dZa1wRVuvMM0NFOdXKEjuEywU6ASWh5UO6WDVQGTI
|
||||
SFMqAvL4NA4Dc1XkaQKhkDQKKRo3yyhNkEFjrJEdgQKBgQD5UiPLBl9g3HdstMHK
|
||||
T7+7UqyFrJSm1/Eu2353wIxlSDLr9zjIONGv9s/5SQJW/GlPyAY5qTvrmjhbRnKT
|
||||
Bi1ccfSqqXol8rTCFH2GGVXwNo3Iy8ptD2cTjjPEq36juuEt3oEEAxSKiVjfW5ts
|
||||
rdPPXKt43vEmVlmeLyTaxMTBxwKBgQC8OXfyaqzvZ8SgtCvXczyytC2082BKh2hr
|
||||
ThZfskpXMS7V//Pf2GG7CcqZQmXVW0C1R7ItXMm21OtFUwxmVkE5CrBrVm8mluAG
|
||||
ZCVW56vjVHv1N+jvTKQ5SuT56vERrPAOvDRDY1CjkoiMZCRFXgidaPL0zF/L6ZjU
|
||||
TgVNllj0wQKBgQDDJ9OSoOtZo1jrw1WJqgD3fRBEFkHJk3BbcD4/OH3s7aXGZJ6S
|
||||
wz8HUNecVtS5CBza8URGMD0R/4ark6otgYFSQnT0fXJ6b8+jt2xF4mENhXZYPYS2
|
||||
936EpSPKhz66pJaMVAWDAXI7uqTROSCg4jPQtcYW99OlYaQGmPptL+afkwKBgQCa
|
||||
+w7CcgeW0HBcij2XFvGhiy4fUk200C2wPQm7HgiMJpkT574cUASYhwVkkAFdXde3
|
||||
7CLPqxkEC+j0md8Z3Gez2hNNLkwzEAPB/2+nUPZ9JjEyxihr8UU/T2WeSk7YaPb4
|
||||
iwrVec8KADuirUoYO8cIJUP3QNiYA+2s0dkX+3WfgQKBgCQeSywwqEp/C3+Jt5J0
|
||||
y13uEXzpKJBXNAyKXQz93qC9bi1k/qfgCmmmHNpPaCkDCgM/HVtw2dV/MY1ZwQSG
|
||||
MAgymPMhWpTwlszvZHQRsRoUKjAwQjmlCVjIEnLPQ26AJwq9iq4ALq4l0pbLn2vx
|
||||
729gKSsz8qSJqVuT9Gkivohg
|
||||
-----END PRIVATE KEY-----
|
||||
9
tools/keys/public_key.pem
Normal file
9
tools/keys/public_key.pem
Normal file
@ -0,0 +1,9 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAt1BKZFcQG2uw/JXoOXKZ
|
||||
TSVI4e4zzJpCTJxY+wrJuTzTVA0zzAz4evRU/tm9pns8BXIDoEOmkamum1z3lbrQ
|
||||
EY6L7bcbMsDxyScRa0kxZRurNi8uplKxp1e0CmHHYu+GEAY6o0RH5ik1BPwQSyKu
|
||||
ZyjGg+AmIeRaxtc6a52whcltFQTZbQjAmCD7YYUKj1BpBv0hnCjdFKLt6BrtXi2Y
|
||||
2QoerNaBsOGLC6pjI581f4/Dq4IdUZvns1Nq7NUDeT4cxCeey+GSfZIL9vDJpHnm
|
||||
2awYV2w6Xu8O0FznYwpzPdwlpIR9eT/t4Ga0J/C3bTxC4ZERHtox8Vrow2WCF2/D
|
||||
BwIDAQAB
|
||||
-----END PUBLIC KEY-----
|
||||
475
tools/license_manager.cjs
Normal file
475
tools/license_manager.cjs
Normal file
@ -0,0 +1,475 @@
|
||||
/**
|
||||
* SmartAsset License Management CLI
|
||||
*
|
||||
* Usage: node tools/license_manager.cjs <command> [args]
|
||||
*
|
||||
* Commands:
|
||||
* list : Show all system modules and their status from DB
|
||||
* generate <module> <type> : Generate a new signed license key (Offline)
|
||||
* decode <key> : Decode and inspect a license key (Offline)
|
||||
* activate <key> : Activate a module using a key (Updates DB)
|
||||
* delete <module> : Deactivate/Remove a module (Updates DB)
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
require('dotenv').config({ path: path.resolve(__dirname, '../server/.env') });
|
||||
const { generateLicense, verifyLicense } = require('../server/utils/licenseManager');
|
||||
|
||||
// Try to load mysql2 from server dependencies
|
||||
let mysql;
|
||||
try {
|
||||
mysql = require(path.resolve(__dirname, '../server/node_modules/mysql2/promise'));
|
||||
} catch (e) {
|
||||
try {
|
||||
mysql = require('mysql2/promise');
|
||||
} catch (e2) {
|
||||
console.error('Error: mysql2 module not found. Please install dependencies in server directory.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// DB Connection Config
|
||||
const dbConfig = {
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
user: process.env.DB_USER || 'root',
|
||||
password: process.env.DB_PASSWORD,
|
||||
database: process.env.DB_NAME || 'smartasset_db',
|
||||
port: process.env.DB_PORT || 3306
|
||||
};
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
// Basic argument parsing
|
||||
function parseArgs(args) {
|
||||
const params = { positional: [] };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
if (args[i].startsWith('--')) {
|
||||
const key = args[i].substring(2);
|
||||
// Check if next is value or flag
|
||||
if (i + 1 < args.length && !args[i + 1].startsWith('--')) {
|
||||
params[key] = args[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
params[key] = true;
|
||||
}
|
||||
} else {
|
||||
params.positional.push(args[i]);
|
||||
}
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
const params = parseArgs(args);
|
||||
const command = params.positional[0];
|
||||
|
||||
if (!command) {
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
switch (command) {
|
||||
case 'list':
|
||||
await listModules();
|
||||
break;
|
||||
case 'generate':
|
||||
// Pass positional args: command is [0], module is [1], type is [2]
|
||||
await generateCmd(params.positional[1], params.positional[2]);
|
||||
break;
|
||||
case 'decode':
|
||||
await decodeCmd(params.positional[1]);
|
||||
break;
|
||||
case 'activate':
|
||||
await activateCmd(params.positional[1]);
|
||||
break;
|
||||
case 'delete':
|
||||
await deleteCmd(params.positional[1]);
|
||||
break;
|
||||
case 'show':
|
||||
await showCmd(params.positional[1]);
|
||||
break;
|
||||
case 'help':
|
||||
printUsage();
|
||||
break;
|
||||
default:
|
||||
console.error(`Unknown command: ${command}`);
|
||||
printUsage();
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error:', err.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
function printUsage() {
|
||||
console.log(`
|
||||
SmartAsset License Manager
|
||||
Usage: node tools/license_manager.cjs <command> [args]
|
||||
|
||||
Commands:
|
||||
list Show active modules in Database
|
||||
generate <module> <type> Generate a new license key
|
||||
Modules: asset, production, monitoring
|
||||
Types: dev, sub, demo
|
||||
Flags: --subscriber <id> (REQUIRED)
|
||||
[--activate]
|
||||
[--activation-window <days>]
|
||||
[--expiry <YYYY-MM-DD>]
|
||||
decode <key> Decrypt and show key details
|
||||
activate <key> Activate module in DB using key
|
||||
delete <module> Deactivate module in DB
|
||||
`);
|
||||
}
|
||||
|
||||
// --- Commands ---
|
||||
|
||||
async function listModules() {
|
||||
const conn = await mysql.createConnection(dbConfig);
|
||||
try {
|
||||
console.log('\n=== Active System Modules ===');
|
||||
const [rows] = await conn.execute('SELECT * FROM system_modules');
|
||||
console.log('---------------------------------------------------------------------------------------');
|
||||
console.log('| Code | Active | Type | Expiry | Subscriber |');
|
||||
console.log('---------------------------------------------------------------------------------------');
|
||||
|
||||
const defaults = ['asset', 'production', 'monitoring'];
|
||||
const data = {};
|
||||
rows.forEach(r => data[r.code] = r);
|
||||
|
||||
defaults.forEach(code => {
|
||||
const mod = data[code];
|
||||
const active = mod && mod.is_active ? 'YES' : 'NO ';
|
||||
const type = mod ? (mod.license_type || 'N/A') : 'N/A';
|
||||
const expiry = mod && mod.expiry_date ? new Date(mod.expiry_date).toLocaleDateString() : 'Permanent/None';
|
||||
const sub = mod && mod.subscriber_id ? mod.subscriber_id : 'N/A';
|
||||
console.log(`| ${code.padEnd(12)} | ${active.padEnd(6)} | ${type.padEnd(4)} | ${expiry.padEnd(20)} | ${sub.padEnd(18)} |`);
|
||||
});
|
||||
console.log('---------------------------------------------------------------------------------------\n');
|
||||
|
||||
console.log('=== Recent Issued Keys (Last 10) ===');
|
||||
const [issues] = await conn.execute('SELECT * FROM issued_licenses ORDER BY id DESC LIMIT 10');
|
||||
if (issues.length === 0) {
|
||||
console.log('(No issued keys found)');
|
||||
} else {
|
||||
console.log('-------------------------------------------------------------------------------------------------------');
|
||||
console.log('| ID | Date | Module | Type | Subscriber | Status | Deadline |');
|
||||
console.log('-------------------------------------------------------------------------------------------------------');
|
||||
issues.forEach(r => {
|
||||
const date = new Date(r.created_at).toLocaleDateString();
|
||||
const deadline = r.activation_deadline ? new Date(r.activation_deadline).toLocaleDateString() : 'None';
|
||||
// Check if status needs update based on deadline? (Display logic only)
|
||||
let status = r.status || 'ISSUED';
|
||||
if (status === 'ISSUED' && r.activation_deadline && new Date() > new Date(r.activation_deadline)) {
|
||||
status = 'EXPIRED';
|
||||
}
|
||||
|
||||
console.log(`| ${r.id.toString().padEnd(4)} | ${date.padEnd(10)} | ${r.module_code.padEnd(12)} | ${r.license_type.padEnd(4)} | ${(r.subscriber_id || 'N/A').padEnd(10)} | ${status.padEnd(10)} | ${deadline.padEnd(20)} |`);
|
||||
});
|
||||
console.log('-------------------------------------------------------------------------------------------------------\n');
|
||||
console.log('Tip: Use "show <id>" to view the full license key.\n');
|
||||
}
|
||||
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function showCmd(identifier) {
|
||||
if (!identifier) {
|
||||
console.error('Usage: show <id> OR show <module_code>');
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = await mysql.createConnection(dbConfig);
|
||||
try {
|
||||
// Check if identifier is a known module code
|
||||
const validModules = ['asset', 'production', 'monitoring'];
|
||||
const isModule = validModules.includes(identifier) || validModules.includes(identifier.toLowerCase());
|
||||
|
||||
if (isModule) {
|
||||
// Show Active Module License
|
||||
const code = identifier.toLowerCase() === 'cctv' ? 'monitoring' : identifier.toLowerCase();
|
||||
const [rows] = await conn.execute('SELECT * FROM system_modules WHERE code = ?', [code]);
|
||||
|
||||
if (rows.length === 0 || !rows[0].is_active) {
|
||||
console.log(`\nModule '${code}' is NOT active or has no license.\n`);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
console.log(`\n=== Active License: ${r.name} (${r.code}) ===`);
|
||||
console.log(`Subscriber : ${r.subscriber_id || 'N/A'}`);
|
||||
console.log(`Type : ${r.license_type}`);
|
||||
console.log(`Expiry : ${r.expiry_date ? new Date(r.expiry_date).toLocaleDateString() : 'Permanent'}`);
|
||||
console.log('--------------------------------------------------');
|
||||
console.log('KEY:');
|
||||
console.log(r.license_key);
|
||||
console.log('--------------------------------------------------\n');
|
||||
|
||||
} else {
|
||||
// Assume ID for Issued License
|
||||
const id = parseInt(identifier, 10);
|
||||
if (isNaN(id)) {
|
||||
console.error(`Error: '${identifier}' is not a valid ID or Module Code.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const [rows] = await conn.execute('SELECT * FROM issued_licenses WHERE id = ?', [id]);
|
||||
if (rows.length === 0) {
|
||||
console.error(`Error: License with ID ${id} not found.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const r = rows[0];
|
||||
console.log('\n=== Issued License Details (ID: ' + r.id + ') ===');
|
||||
console.log(`Module : ${r.module_code}`);
|
||||
console.log(`Type : ${r.license_type}`);
|
||||
console.log(`Subscriber : ${r.subscriber_id || 'N/A'}`);
|
||||
console.log(`Status : ${r.status}`);
|
||||
console.log(`Created : ${new Date(r.created_at).toLocaleString()}`);
|
||||
console.log(`Deadline : ${r.activation_deadline ? new Date(r.activation_deadline).toLocaleString() : 'None'}`);
|
||||
console.log('--------------------------------------------------');
|
||||
console.log('KEY:');
|
||||
console.log(r.license_key);
|
||||
console.log('--------------------------------------------------\n');
|
||||
}
|
||||
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function generateCmd(moduleCode, type) {
|
||||
const subscriberId = params.subscriber;
|
||||
const shouldActivate = params.activate;
|
||||
const activationWindow = params['activation-window'];
|
||||
const expiryDate = params['expiry']; // e.g. "2026-12-31"
|
||||
|
||||
if (!moduleCode || !type || !subscriberId) {
|
||||
console.log('Usage: generate <module> <type> --subscriber <id> [--activate] [--activation-window <days>] [--expiry <YYYY-MM-DD>]');
|
||||
return;
|
||||
}
|
||||
|
||||
// ... Validation (Same as before) ...
|
||||
const validModules = ['asset', 'production', 'monitoring'];
|
||||
const validTypes = ['dev', 'sub', 'demo'];
|
||||
const modMap = { 'cctv': 'monitoring' };
|
||||
const finalCode = modMap[moduleCode] || moduleCode;
|
||||
|
||||
if (!validModules.includes(finalCode)) {
|
||||
console.error(`Invalid module. Allowed: ${validModules.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
if (!validTypes.includes(type)) {
|
||||
console.error(`Invalid type. Allowed: ${validTypes.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (expiryDate && isNaN(Date.parse(expiryDate))) {
|
||||
console.error('Invalid expiry date format. Use YYYY-MM-DD.');
|
||||
return;
|
||||
}
|
||||
|
||||
const days = activationWindow ? parseInt(activationWindow, 10) : null;
|
||||
|
||||
// Load Private Key
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const privateKeyPath = path.join(__dirname, 'keys', 'private_key.pem');
|
||||
|
||||
let privateKey;
|
||||
try {
|
||||
privateKey = fs.readFileSync(privateKeyPath, 'utf8');
|
||||
} catch (e) {
|
||||
console.error('Error: Could not load private_key.pem from tools/keys/. Run generate_keys.cjs first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const key = generateLicense(finalCode, type, subscriberId, days, expiryDate, privateKey);
|
||||
|
||||
// Save to issued_licenses
|
||||
const conn = await mysql.createConnection(dbConfig);
|
||||
try {
|
||||
const deadline = days ? new Date(Date.now() + (days * 24 * 60 * 60 * 1000)) : null;
|
||||
await conn.execute(
|
||||
`INSERT INTO issued_licenses (module_code, license_key, license_type, subscriber_id, status, activation_deadline)
|
||||
VALUES (?, ?, ?, ?, 'ISSUED', ?)`,
|
||||
[finalCode, key, type, subscriberId, deadline]
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('Warning: Failed to log issued license to DB:', e.message);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
|
||||
console.log('\nGENERATED LICENSE KEY:');
|
||||
if (days) {
|
||||
console.log(`(NOTE: This key MUST be activated within ${days} days)`);
|
||||
}
|
||||
if (expiryDate) {
|
||||
console.log(`(NOTE: This key will expire on ${expiryDate})`);
|
||||
}
|
||||
console.log('--------------------------------------------------');
|
||||
console.log(key);
|
||||
console.log('--------------------------------------------------\n');
|
||||
|
||||
if (shouldActivate) {
|
||||
console.log('Auto-activating...');
|
||||
await activateCmd(key);
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeCmd(key) {
|
||||
if (!key) {
|
||||
console.error('Usage: decode <key_string>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Public Key
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const publicKeyPath = path.join(__dirname, 'keys', 'public_key.pem');
|
||||
|
||||
let publicKey;
|
||||
try {
|
||||
publicKey = fs.readFileSync(publicKeyPath, 'utf8');
|
||||
} catch (e) {
|
||||
console.error('Error: Could not load public_key.pem from tools/keys/. Run generate_keys.cjs first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = verifyLicense(key, publicKey);
|
||||
console.log('\nLICENSE DECODE RESULT:');
|
||||
console.log('--------------------------------------------------');
|
||||
console.log(`Valid Signature : ${result.isValid ? 'YES' : 'NO'}`);
|
||||
if (!result.isValid) {
|
||||
console.log(`Reason : ${result.reason}`);
|
||||
}
|
||||
|
||||
if (result.module) {
|
||||
// Even if invalid (expired), we might want to show details if signature was ok?
|
||||
// verifyLicense returns payload details if valid signature but expired?
|
||||
// Let's check verifyLicense implementation.
|
||||
// It returns { isValid: false, reason: 'Expired', ... } if expired.
|
||||
// So we can show details.
|
||||
|
||||
console.log(`Module : ${result.module}`);
|
||||
console.log(`Type : ${result.type}`);
|
||||
console.log(`Subscriber : ${result.subscriberId}`);
|
||||
console.log(`Expiry Date : ${result.expiryDate ? new Date(result.expiryDate).toLocaleDateString() : 'Permanent'}`);
|
||||
}
|
||||
console.log('--------------------------------------------------\n');
|
||||
}
|
||||
|
||||
async function activateCmd(key) {
|
||||
if (!key) {
|
||||
console.error('Usage: activate <key_string>');
|
||||
return;
|
||||
}
|
||||
|
||||
// Load Public Key for Verification
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const publicKeyPath = path.join(__dirname, 'keys', 'public_key.pem');
|
||||
|
||||
let publicKey;
|
||||
try {
|
||||
publicKey = fs.readFileSync(publicKeyPath, 'utf8');
|
||||
} catch (e) {
|
||||
console.error('Error: Could not load public_key.pem from tools/keys/. Run generate_keys.cjs first.');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = verifyLicense(key, publicKey);
|
||||
if (!result.isValid) {
|
||||
console.error(`Invalid License: ${result.reason}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = await mysql.createConnection(dbConfig);
|
||||
try {
|
||||
// ... Subscriber Check (Same) ...
|
||||
const [settings] = await conn.execute("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
||||
const serverSubscriberId = settings.length > 0 ? settings[0].setting_value : null;
|
||||
|
||||
if (!serverSubscriberId) {
|
||||
console.error('\nERROR: Server Subscriber ID is not set in DB. Please configure it first.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.subscriberId !== serverSubscriberId) {
|
||||
console.error(`\nERROR: License Subscriber Mismatch.`);
|
||||
console.error(`License is for: '${result.subscriberId}'`);
|
||||
console.error(`Server is set to: '${serverSubscriberId}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. Archive Current License
|
||||
const [current] = await conn.execute('SELECT * FROM system_modules WHERE code = ?', [result.module]);
|
||||
if (current.length > 0 && current[0].is_active && current[0].license_key) {
|
||||
const old = current[0];
|
||||
const historySql = `
|
||||
INSERT INTO license_history (module_code, license_key, license_type, subscriber_id, activated_at)
|
||||
VALUES (?, ?, ?, ?, NOW())
|
||||
`;
|
||||
await conn.execute(historySql, [old.code, old.license_key, old.license_type, old.subscriber_id]);
|
||||
|
||||
// Also mark old key as replaced/archived in issued_licenses if exists?
|
||||
// Optional, but good for consistency.
|
||||
await conn.execute("UPDATE issued_licenses SET status = 'REPLACED' WHERE license_key = ?", [old.license_key]);
|
||||
}
|
||||
|
||||
// 4. Activate New License
|
||||
const names = { 'asset': '자산 관리', 'production': '생산 관리', 'monitoring': 'CCTV' };
|
||||
const sql = `
|
||||
INSERT INTO system_modules (code, name, is_active, license_key, license_type, expiry_date, subscriber_id)
|
||||
VALUES (?, ?, true, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
is_active = true,
|
||||
license_key = VALUES(license_key),
|
||||
license_type = VALUES(license_type),
|
||||
expiry_date = VALUES(expiry_date),
|
||||
subscriber_id = VALUES(subscriber_id)
|
||||
`;
|
||||
|
||||
await conn.execute(sql, [result.module, names[result.module] || result.module, key, result.type, result.expiryDate, result.subscriberId]);
|
||||
|
||||
// 5. Update Issued License Status
|
||||
await conn.execute("UPDATE issued_licenses SET status = 'ACTIVE' WHERE license_key = ?", [key]);
|
||||
|
||||
console.log(`\nSUCCESS: Module '${result.module}' activated.`);
|
||||
console.log(`Type: ${result.type}, Subscriber: ${result.subscriberId}, Expiry: ${result.expiryDate || 'Permanent'}\n`);
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteCmd(moduleCode) {
|
||||
if (!moduleCode) {
|
||||
console.error('Usage: delete <module_code>');
|
||||
return;
|
||||
}
|
||||
|
||||
const validModules = ['asset', 'production', 'monitoring'];
|
||||
if (!validModules.includes(moduleCode)) {
|
||||
console.error(`Invalid module. Allowed: ${validModules.join(', ')}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const conn = await mysql.createConnection(dbConfig);
|
||||
try {
|
||||
// We set is_active = false
|
||||
const [res] = await conn.execute('UPDATE system_modules SET is_active = false WHERE code = ?', [moduleCode]);
|
||||
if (res.affectedRows > 0) {
|
||||
console.log(`\nSUCCESS: Module '${moduleCode}' deactivated.\n`);
|
||||
} else {
|
||||
console.log(`\nModule '${moduleCode}' not found or already inactive.\n`);
|
||||
}
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
28
tsconfig.app.json
Normal file
28
tsconfig.app.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
tsconfig.json
Normal file
7
tsconfig.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
tsconfig.node.json
Normal file
26
tsconfig.node.json
Normal file
@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
31
vite.config.ts
Normal file
31
vite.config.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3005',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
configure: (proxy, _options) => {
|
||||
proxy.on('error', (err, _req, _res) => {
|
||||
// Suppress anticipated connection errors during stream reset/page reload
|
||||
if ((err as any).code === 'ECONNRESET' || (err as any).code === 'ECONNABORTED') {
|
||||
return;
|
||||
}
|
||||
console.log('proxy error', err);
|
||||
});
|
||||
proxy.on('proxyReq', (_proxyReq, _req, _res) => {
|
||||
// console.log('Sending Request to the Target:', req.method, req.url);
|
||||
});
|
||||
proxy.on('proxyRes', (_proxyRes, _req, _res) => {
|
||||
// console.log('Received Response from the Target:', proxyRes.statusCode, req.url);
|
||||
});
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
})
|
||||
Loading…
x
Reference in New Issue
Block a user