This commit is contained in:
choibk 2026-01-22 23:42:55 +09:00
commit 5ead239b71
72 changed files with 17094 additions and 0 deletions

67
.gitignore vendored Normal file
View 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
View 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
View 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
View 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

View 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, 해상도)을 직관적으로 제어할 수 있도록 폼을 개선했습니다.

View 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 비대칭 암호화 및 구독자 기반 관리 적용)

View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

45
package.json Normal file
View 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
View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

1
public/vite.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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();

View 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 };

View 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 };

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

View 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

File diff suppressed because it is too large Load Diff

30
server/package.json Normal file
View 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
View 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
View 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
View 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');

View 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 };

View 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
View 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
View 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
View 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
View 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>,
)

View 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;
}
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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>
);
}

View 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;
}

View 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>
)
}

View 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>
);
}

View 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>
);
}

View 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);
}

View 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>
);
}

View 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;
}

View 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>
);
}

View 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
View 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
View 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;
});

View 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;
}

View 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;
}

View 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
View 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
View 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>
);
}

View 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
View 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
View 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';

View 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>
);
}

View 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;
}

View 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
View 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
View 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)');

View 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-----

View 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
View 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
View 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
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
tsconfig.node.json Normal file
View 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
View 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);
});
},
},
}
}
})