Compare commits

..

14 Commits

Author SHA1 Message Date
choibk
b6584ebaba [FIX] v0.4.4.2 - 모듈 활성화 독립성 강화 및 마이그레이션 로직 개선 2026-01-27 02:04:00 +09:00
choibk
5517fd7ca1 [FIX] v0.4.4.1 - 라이선스 활성화 오류 및 모듈 마이그레이션 이슈 해결 2026-01-27 01:54:53 +09:00
choibk
763217acaf [BUILD] v0.4.4.0 - 통합 로드맵 업데이트 및 모듈 시스템 개선 2026-01-27 01:46:17 +09:00
choibk
bce16b176c [FIX] Remove backup files and update gitignore 2026-01-27 00:47:35 +09:00
choibk
b66a79f555 [BUILD] v0.4.3.5 - 버전 관리 규정 추가 및 운영 스크립트 작성 2026-01-27 00:44:57 +09:00
choibk
e817951c07 [BUILD] v0.4.3.4 - Force update frontend assets 2026-01-26 22:36:49 +09:00
choibk
778982f6c9 [FIX] v0.4.3.3 - Fix unused variable error preventing build 2026-01-26 22:30:02 +09:00
choibk
6bd5013357 [BUILD] v0.4.3.2 - Fix version mismatch issue 2026-01-26 22:25:08 +09:00
choibk
fdfb3283c0 [BUILD] v0.4.3.1 - Module Integration, Quality Module, and Reliability Fixes 2026-01-26 17:08:26 +09:00
choibk
93de44bc22 [UI] v0.4.2.11 - CCTV Monitoring UI Revamp for design consistency 2026-01-26 11:49:29 +09:00
choibk
991ef773be [TEST] v0.4.2.10 - Update mechanism verification tag 2026-01-26 10:53:43 +09:00
choibk
4f7da76304 [FIX] Resolve duplicate path declaration crash 2026-01-26 10:17:55 +09:00
choibk
de4eafa62c [STABLE] v0.4.2.8 - Infrastructure Shield: prioritized .env.local for persistent environment configuration 2026-01-26 10:14:42 +09:00
choibk
ff7d4e4b9a [STABLE] v0.4.2.7 - Accurate version detection and project synchronization 2026-01-26 10:03:56 +09:00
34 changed files with 1431 additions and 328 deletions

3
.gitignore vendored
View File

@ -40,7 +40,10 @@ Desktop.ini
# Project Specific - Server # Project Specific - Server
server/.env server/.env
server/.env.local
server/.env.backup* server/.env.backup*
backup/
*.backup*
server/*.tmp server/*.tmp
server/backups/ server/backups/
server/uploads/* server/uploads/*

View File

@ -2,8 +2,8 @@
**프로젝트명:** SOKUREE Platform - Smart Integrated Management System **프로젝트명:** SOKUREE Platform - Smart Integrated Management System
**최초 작성일:** 2026-01-25 **최초 작성일:** 2026-01-25
**최근 업데이트:** 2026-01-25 **최근 업데이트:** 2026-01-26
**버전:** v0.4.1.0 **버전:** v0.4.4.0
--- ---
@ -17,7 +17,7 @@
### 🟦 Phase 1: 기반 강화 및 보안 고도화 (Platform Stability) ### 🟦 Phase 1: 기반 강화 및 보안 고도화 (Platform Stability)
**목표:** 플랫폼의 안정성 확보 및 사용자 중심의 보안/관리 체계 구축 **목표:** 플랫폼의 안정성 확보 및 사용자 중심의 보안/관리 체계 구축
#### 🏷️ Tag: `v0.4.1.0` (완료) #### 🏷️ Tag: `v0.4.1.0`
- [x] **세션 관리 엔진 최적화** - [x] **세션 관리 엔진 최적화**
- 세션 자동 로그아웃 시간 합산 오류 수정 (시간+분 단위 정상 동작 확인) - 세션 자동 로그아웃 시간 합산 오류 수정 (시간+분 단위 정상 동작 확인)
- 활동 부재 시 자동 로그아웃 잔여 시간 추적 로그 시스템 도입 - 활동 부재 시 자동 로그아웃 잔여 시간 추적 로그 시스템 도입
@ -35,35 +35,75 @@
- [x] CCTV 설정 파일 DB 저장 시 username/password 필드 암호화 처리 - [x] CCTV 설정 파일 DB 저장 시 username/password 필드 암호화 처리
- [x] CCTV 설정 파일 조회 시 username/password 필드 복호화 처리 - [x] CCTV 설정 파일 조회 시 username/password 필드 복호화 처리
- **(수정사항)**: `CameraManagementPage` 신설 및 `cryptoUtil`을 통한 RTSP 계정 정보의 DB 암호화 저장 로직 완성 (AES-256) - **(수정사항)**: `CameraManagementPage` 신설 및 `cryptoUtil`을 통한 RTSP 계정 정보의 DB 암호화 저장 로직 완성 (AES-256)
#### 🏷️ Tag: `v0.4.2.0` #### 🏷️ Tag: `v0.4.2.9`
- [ ] **라이선스 관리 오류 수정** - [x] **라이선스 관리 오류 수정**
- [ ] **시스템 관리 기본설정 오류 수정** - 모듈 활성화 상태 체크 미들웨어(`requireModule`) 보강 및 라이선스 만료 감지 로직 안정화
- [ ] **시스템 업데이트 오류 수정** - [x] **시스템 관리 기본설정 오류 수정**
- 'monitoring' → 'cctv' 모듈 코드 마이그레이션 시 발생하는 중복 키 오류(`ER_DUP_ENTRY`) 완벽 해결
- [x] **시스템 업데이트 엔진 고도화 & 인프라 보호 (Shovel-work/삽질 기록)**
- **인프라 보호 시스템(Infrastructure Shield)**: `.env.local` 우선순위 도입으로 업데이트 시 운영 서버 설정이 초기화되는 문제 근본 해결
- **실시간 버전 동기화**: 정적 파일 대신 Git Tag를 직접 조회하도록 개선하여 업데이트 후 현재 버전 즉시 반영
- **업데이트 안정성**: 소스 교체 전 `.env`/`.env.local` 2중 백업 및 복구 시퀀스 적용
- **서버 가용성**: Synology NAS 등 저사양 환경에서의 블로킹 방지를 위한 `execSync` 차단 및 에러 핸들링 강화
#### 🏷️ Tag: `v0.4.2.11`
- [x] **CCTV 모니터링 UI 리뉴얼**
- 페이지 배경색을 플랫폼 표준인 연한 그레이(`var(--sokuree-bg-main)`)로 변경하여 일체감 형성
- 카메라 슬롯을 독립적인 카드(Card) 형태로 디자인하고 둥근 모서리(`rounded-xl`) 및 그림자(`shadow-sm`) 적용
- 고정된 검은색 배경 대신 브랜드 컬러인 다크 슬레이트(`var(--sokuree-brand-secondary)`) 배경 적용
- 빈 슬롯 디자인을 깔끔한 화이트 카드 형태로 개선하여 시각적 피로도 감소
#### 🏷️ Tag: `v0.4.3.0` #### 🏷️ Tag: `v0.4.3.0` [x]
- [ ] **소모품 관리** - [x] **소모품 및 자재 관리 모듈(`material`) 신규 구축**
- [ ] 이 기능은 자재/재고 관리 모듈 생성하여 메뉴가 아니라 속성(카테고리)으로 추가할 예정 - [x] 자재/재고 관리 독립 모듈 생성 및 '기준정보' 하위 메뉴 배치
- [x] 소모품(공구, 부품, 오일 등)의 재고 상태 시각화 및 카드형 UI 구현 (`MaterialListPage`)
- [x] **부속설비 관리 UI 및 로직 고도화**
- [x] 설비 검색 전용 `AssetSearchModal` 도입으로 수천 개의 자산 중 정밀 검색 및 선택 가능
- [x] 부속 설비 등록 버튼 클릭 시 상위 설비(부모) 정보 및 카테고리 자동 바인딩 로직 구현
- [x] 상위 설비 선택 방식을 드롭다운에서 검색 모달 방식으로 전면 교체하여 데이터 무결성 강화
- [x] **사이드바 메뉴 구조 전면 개편**
- [x] **기준정보(Standard Info) 그룹**: 자산 관리, 자재/재고 관리를 통합하여 관리 효율성 증대
- [x] **설정(Settings) 그룹**: IoT 센서 모니터링, 모듈 관리, 설비 관리 메뉴 신설 및 구조화
- [x] **사용자 맞춤형 권한 제어 엔진 구현**
- [x] `users` 테이블 스키마 확장(`allowed_modules` JSON 도입)을 통한 사용자별 모듈 접근 제한 지원
- [x] 사용자 관리 화면에 모듈별 접근 권한 체크박스 UI 추가하여 직관적인 권한 부여 가능
- [x] 권한에 따른 사이드바 메뉴 동적 필터링 시스템 구축 (최고관리자 면제 로직 포함)
- [ ] **부속설비 등록** #### 🏷️ Tag: `v0.4.3.1` (완료)
- [ ] 자산 등록 화면에서 부속설비 등록 버튼 클릭 시 자산 등록 화면으로 이동하도록 구현 - [x] **모듈 통합 및 구조 최적화**
- [ ] 부속설비 등록 버튼으로 이동 시 상위설비 자동 선택되도록 구현 - [x] '자재 관리'와 '재고 관리' 모듈을 `material` 모듈로 통합 (`자재/재고 관리`)
- [ ] 자산 등록 화면에서 상위설비 드롭다운 메뉴에서 목록이 표시되지 않고 있음 - [x] 중복되는 `inventory` 모듈을 제거하고 비즈니스 로직을 `material`로 흡수하여 관리 효율성 증대
- [ ] 상위설비 드롭다운 메뉴를 입력 텍스트 박스로 변경하고 옆에 검색 아이콘 추가 - [x] **스마트 관리 모듈 라인업 확장**
- [ ] 검색 아이콘 클릭 시 상위설비 검색 화면으로 이동 - [x] 품질 관리 모듈(`quality`) 신규 구축 및 핵심 지표(불량률, 합격률, Cpk 등) 시각화 대시보드 구현
- [ ] 상위설비 검색 화면에서 검색어 입력 후 검색 버튼 클릭 시 검색 결과 목록 표시 - [x] **라이선스 및 시스템 설정 고도화**
- [ ] 검색 결과 목록에서 상위설비 선택 시 자산 등록 화면으로 이동하고 입력 텍스트 박스에 상위설비 이름 표시 - [x] 라이선스 관리 화면(`LicensePage`)에 통합된 '자재/재고' 및 '품질 관리' 모듈 즉시 연동
- [ ] **자산관리 모듈의 현재 메뉴들을 기준정보 메뉴의 하위 메뉴로 변경** - [x] 서버 기동 시 DB(`system_modules`) 내 모듈 정보 자동 동기화 및 전역 권한 설정 최적화
- [ ] - [x] **플랫폼 신뢰성 강화 (Runtime Stability)**
- [ ] **설정 메뉴 추가** - [x] 사용자 관리 페이지의 데이터 가드 로직 강화로 불완전한 데이터 수신 시 발생하던 런타임 오류(흰 화면) 해결
- [ ] IoT 센서 모니터링 메뉴 추가 - [x] **라이선스 발급 표준 정의**
- [ ] 모듈 관리 메뉴 추가 - [x] 발급 관리 프로그램 연동을 위한 모듈별 고유 코드(`asset`, `production`, `material`, `quality`, `cctv`) 및 필수 규격 확정
- [ ] 설비 관리 메뉴 추가
- [ ] #### 🏷️ Tag: `v0.4.3.5` (완료)
- [ ] **시스템 관리 - 사용자 관리** - [x] **버전 관리 표준 확립**
- [ ] 사용자 관리에 모듈 접근 권한을 설정할 수 있도록 구현 - MAJOR.MINOR.PATCH.BUILD 4단계 체계 도입 및 문서화 (`version manage.md`)
- [ ] 관리권한 재 검토 - 배포 표준 절차(Standard Deployment Procedure) 정의
- [ ] 최고관리자, 관리자, 모듈 관리자, 사용자 - [x] **시스템 안정성 및 빌드 최적화**
- 빌드 시 정적 자산 강제 업데이트 스크립트 보강
- 미사용 변수 및 린트 에러 수정을 통한 빌드 실패 방지
- 버전 정보 동기화 로직 최적화 (package.json 기반)
#### 🏷️ Tag: `v0.4.4.2` (완료)
- [x] **모듈 활성화 독립성 및 마이그레이션 신뢰성 강화**
- `inventory``material` 데이터 보존 마이그레이션 로직 정교화 (기존 라이선스 우선)
- 사용자 권한(`allowed_modules`) JSON 마이그레이션 시 `NULL` 값 처리 예외 대응
- 모듈 활성화 API 로그 보강 및 프론트엔드(`LicensePage`) 생명주기(useEffect) 최적화
- [x] **모듈 카테고리 동적 생성 기능**
- 모듈 정의(`IModuleDefinition`) 시 카테고리를 하드코딩하지 않고 직접 문자열로 정의 가능하도록 개선
- [x] **UI 시각화 및 사용성 개선**
- 사이드바 메뉴 선택 시 하이라이트 색상을 브랜드 테마(`var(--sokuree-brand-primary)`)와 일치시켜 시각적 일관성 확보
- [x] **통합 로드맵 및 문서 현행화**
- `INTEGRATED_ROADMAP.md` 업데이트 및 버전 관리 규정 반영
#### 🏷️ Tag: `v0.5.0.x` #### 🏷️ Tag: `v0.5.0.x`
- [ ] 모듈 간 연동을 위한 API 인터페이스 정의 - [ ] 모듈 간 연동을 위한 API 인터페이스 정의

View File

@ -51,4 +51,48 @@ SMART IMS 플랫폼의 모든 배포와 업데이트는 아래의 **MAJOR.MINOR.
## 💡 버전 비교 및 업데이트 공지 원칙 ## 💡 버전 비교 및 업데이트 공지 원칙
1. 플랫폼은 원격 저장소의 최신 태그 버전과 현재 설치된 버전의 **4자리를 순차적으로 비교**합니다. 1. 플랫폼은 원격 저장소의 최신 태그 버전과 현재 설치된 버전의 **4자리를 순차적으로 비교**합니다.
2. 상위 자리수가 더 높을 경우 즉시 시스템 업데이트 안내를 발생시킵니다. 2. 상위 자리수가 더 높을 경우 즉시 시스템 업데이트 안내를 발생시킵니다.
3. 모든 버전은 `v` 접두사를 포함하여 관리하나, 비교 시에는 숫자로 정규화하여 처리합니다. 3. 모든 버전은 `v` 접두사를 포함하여 관리하나, 비교 시에는 숫자로 정규화하여 처리합니다.
---
## 🚀 배포 및 태그 생성 절차 (Standard Deployment Procedure)
> **경고**: 이 절차를 위반할 경우 운영 서버에서 업데이트가 누락되거나 버전 불일치가 발생할 수 있습니다.
### [Step 1] 사전 검증 (Pre-check)
1. **작업 내용 커밋 확인**: `git status` 명령어로 커밋되지 않은(Unstaged/Modified) 파일이 없는지 확인합니다.
- ❌ 실수 패턴: 코드를 수정하고 저장만 한 뒤, 커밋 없이 태그를 생성하면 **구버전 코드**가 배포됩니다.
2. **버전 번호 동기화 (필수)**: 아래 2개 파일의 `version` 필드를 배포할 버전 번호(예: `0.4.3.5`)로 직접 수정합니다.
- `root/package.json`
- `root/server/package.json`
- *이 작업이 누락되면 시스템은 업데이트 후에도 "구버전"으로 인식합니다.*
### [Step 2] 커밋 및 태그 생성 (Commit & Tag)
반드시 **소스코드 커밋을 먼저** 수행하고, 그 커밋에 태그를 붙여야 합니다.
```bash
# 1. 버전 파일 및 변경 사항 커밋
git add .
git commit -m "[BUILD] v0.4.3.5 - 배포 내용 요약"
# 2. 원격 저장소로 코드 푸시 (먼저!)
git push origin main
# 3. 태그 생성 (경량 태그 권장)
git tag v0.4.3.5
# 4. 태그 푸시 (배포 트리거)
git push origin v0.4.3.5
```
### [Step 3] 운영 서버 배포 확인
1. 운영 서버(NAS)에서 업데이트 스크립트 실행
2. 업데이트 완료 후 브라우저에서 '새로고침(Ctrl+F5)' 또는 캐시 비우기 수행
3. [시스템 관리] > [버전 정보] 메뉴에서 **표시되는 버전 번호**와 **빌드 일자**가 최신인지 확인
---
## 🚫 절대 금지 사항 (Do Not)
1. **커밋 없이 태그만 생성 금지**: 운영 서버는 태그가 가리키는 시점의 코드를 가져옵니다. 변경 사항이 커밋되지 않았다면 아무것도 변하지 않습니다.
2. **`package.json` 수정 누락**: 코드는 바꼈는데 명찰(버전 번호)을 안 바꾸면, 시스템은 "이미 최신 버전"이라 판단하고 업데이트 알림을 띄우지 않습니다.

4
package-lock.json generated
View File

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

View File

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

View File

@ -0,0 +1,67 @@
#!/bin/bash
# ==========================================
# Smart IMS Emergency Restore Script (Revised)
# 작성일: 2026-01-26
# ==========================================
PROJECT_DIR="/volume1/web/smartims"
BACKUP_DIR="/volume1/smart_ims"
SERVER_DIR="${PROJECT_DIR}/server"
MYSQL_BIN="/usr/local/mariadb10/bin"
DB_USER="choibk"
DB_PORT="3307"
DB_NAME="sokuree_platform_prod"
DB_HOST="127.0.0.1"
echo "=============================================="
echo "🚨 EMERGENCY RESTORE PROCESS STARTING"
echo "=============================================="
# DB 비밀번호 추출 (Node.js)
if [ -f "${SERVER_DIR}/.env" ]; then
cd "${SERVER_DIR}"
DB_PASS=$(node -e "try{const fs=require('fs');const c=fs.readFileSync('.env','utf8');const m=c.match(/^DB_PASSWORD=(.*)$/m);if(m){let p=m[1].trim();if((p.startsWith('\"')&&p.endsWith('\"'))||(p.startsWith(\"'\")&&p.endsWith(\"'\"))){p=p.slice(1,-1);}process.stdout.write(p);}}catch(e){}")
else
echo "❌ [Error] .env file not found."
exit 1
fi
# 1. 최신 이미지 복구
echo "[Step 1] Finding latest image backup..."
LATEST_IMG=$(ls -t "${BACKUP_DIR}"/backup_images_*.tar.gz 2>/dev/null | head -n 1)
if [ -n "$LATEST_IMG" ]; then
echo "✅ Found: $LATEST_IMG"
echo " Restoring images..."
tar -xzf "$LATEST_IMG" -C "${PROJECT_DIR}"
else
echo "⚠️ [Warning] No image backup found."
fi
# 2. 최신 DB 복구
echo "[Step 2] Finding latest database backup..."
LATEST_SQL=$(ls -t "${BACKUP_DIR}"/backup_db_*.sql 2>/dev/null | head -n 1)
if [ -z "$LATEST_SQL" ]; then
echo "❌ [Error] No database backup found!"
exit 1
else
echo "✅ Found: $LATEST_SQL"
echo " Restoring database..."
"${MYSQL_BIN}/mysql" -h "${DB_HOST}" -u "${DB_USER}" "-p${DB_PASS}" --port "${DB_PORT}" "${DB_NAME}" < "$LATEST_SQL"
if [ $? -eq 0 ]; then
echo "✅ Database restore complete."
else
echo "❌ [Error] Database restore failed."
exit 1
fi
fi
echo "=============================================="
echo "🎉 SYSTEM RESTORE COMPLETED"
echo " Please restart PM2: pm2 reload smartims-api"
echo "=============================================="

View File

@ -0,0 +1,140 @@
#!/bin/bash
# ==========================================
# Smart IMS NAS Update Script (Revised)
# 작성일: 2026-01-26
# ==========================================
# 1. 환경 설정
PROJECT_DIR="/volume1/web/smartims"
BACKUP_DIR="/volume1/smart_ims"
SERVER_DIR="${PROJECT_DIR}/server"
DATE_STAMP=$(date +%Y%m%d_%H%M%S)
# DB 접속 정보
DB_USER="choibk"
DB_PORT="3307"
DB_NAME="sokuree_platform_prod"
DB_HOST="127.0.0.1" # 호스트 명시 (필수)
MYSQL_BIN="/usr/local/mariadb10/bin"
# 로그 파일 설정
mkdir -p "${BACKUP_DIR}/logs"
LOG_FILE="${BACKUP_DIR}/logs/update_${DATE_STAMP}.log"
exec > >(tee -a "${LOG_FILE}") 2>&1
echo "=============================================="
echo "[Update] Started at $(date)"
echo "=============================================="
# 2. .env에서 DB 비밀번호 추출 (Node.js 활용으로 특수문자/따옴표 안전 처리)
if [ -f "${SERVER_DIR}/.env" ]; then
cd "${SERVER_DIR}"
# node 명령어로 파싱하여 쉘 특수문자 이슈 회피
DB_PASS=$(node -e "try{const fs=require('fs');const c=fs.readFileSync('.env','utf8');const m=c.match(/^DB_PASSWORD=(.*)$/m);if(m){let p=m[1].trim();if((p.startsWith('\"')&&p.endsWith('\"'))||(p.startsWith(\"'\")&&p.endsWith(\"'\"))){p=p.slice(1,-1);}process.stdout.write(p);}}catch(e){}")
if [ -z "$DB_PASS" ]; then
echo "[Error] Could not retrieve DB_PASSWORD from .env"
exit 1
fi
echo "[Info] DB Password loaded (Length: ${#DB_PASS})"
else
echo "[Error] .env file not found at ${SERVER_DIR}/.env"
exit 1
fi
# 백업 디렉토리 생성
mkdir -p "${BACKUP_DIR}"
# ==========================================
# 🚨 [STEP 1] 데이터 백업
# ==========================================
echo "[Step 1] Creating Backups..."
# 1-1. 이미지 백업
if [ -d "${PROJECT_DIR}/server/uploads" ]; then
cd "${PROJECT_DIR}"
TAR_NAME="backup_images_${DATE_STAMP}.tar.gz"
echo " - Backing up images..."
tar -czf "${BACKUP_DIR}/${TAR_NAME}" server/uploads/
else
echo " - [Warning] Uploads directory not found. Skipping image backup."
fi
# 1-2. DB 백업
SQL_NAME="backup_db_${DATE_STAMP}.sql"
echo " - Backing up database..."
# -h 127.0.0.1 옵션 추가 및 비밀번호 인자 방식 변경
"${MYSQL_BIN}/mysqldump" -h "${DB_HOST}" -u "${DB_USER}" "-p${DB_PASS}" --port "${DB_PORT}" "${DB_NAME}" --single-transaction --quick --lock-tables=false > "${BACKUP_DIR}/${SQL_NAME}" 2>/dev/null
if [ $? -ne 0 ]; then
echo "[Error] Database backup failed! Please check:"
echo " 1. DB Port ($DB_PORT) matches MariaDB 10 port."
echo " 2. DB Password in .env is correct."
echo " 3. 'choibk' user has permissions."
exit 1
fi
# 빈 파일 체크 (0 바이트면 실패로 간주)
if [ ! -s "${BACKUP_DIR}/${SQL_NAME}" ]; then
echo "[Error] Backup file is empty. Dump failed."
rm "${BACKUP_DIR}/${SQL_NAME}"
exit 1
fi
echo "[Success] All backups completed successfully."
# ==========================================
# 🚀 [STEP 2] 버전 동기화 및 코드 반영
# ==========================================
echo "[Step 2] Updating Source Code..."
cd "${PROJECT_DIR}"
# 목표 태그 설정
TARGET_TAG=$1
if [ -z "$TARGET_TAG" ]; then
echo " - Fetching latest tag info..."
git fetch origin --tags --force
TARGET_TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
echo " - Detected latest tag: ${TARGET_TAG}"
fi
if [ -z "$TARGET_TAG" ]; then
echo "[Error] Target tag not found."
exit 1
fi
echo " - Syncing with remote..."
git fetch origin --tags --force --prune
if [ $? -ne 0 ]; then echo "[Error] Git fetch failed."; exit 1; fi
echo " - Checkout to ${TARGET_TAG}..."
git checkout -f "${TARGET_TAG}"
if [ $? -ne 0 ]; then echo "[Error] Git checkout failed."; exit 1; fi
# ==========================================
# 🏗️ [STEP 3] 시스템 빌드 및 서비스 재시작
# ==========================================
echo "[Step 3] Building and Restarting..."
# 3-1. 프론트엔드
echo " - Building Frontend..."
cd "${PROJECT_DIR}"
npm install --no-audit --no-fund > /dev/null
npm run build
# 3-2. 백엔드
echo " - Installing Backend Dependencies..."
cd "${SERVER_DIR}"
npm install --no-audit --no-fund > /dev/null
echo " - Reloading PM2..."
pm2 reload smartims-api || pm2 start index.js --name "smartims-api"
echo "=============================================="
echo "[Update] Completed Successfully at $(date)"
echo "[Info] Target Version: ${TARGET_TAG}"
echo "=============================================="

View File

@ -1,5 +1,8 @@
const mysql = require('mysql2'); const mysql = require('mysql2');
require('dotenv').config(); const path = require('path');
// Prioritize machine-specific local config (.env.local)
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
require('dotenv').config(); // Fallback to standard .env
const pool = mysql.createPool({ const pool = mysql.createPool({
host: process.env.DB_HOST, host: process.env.DB_HOST,

View File

@ -1,6 +1,9 @@
const express = require('express'); const express = require('express');
const cors = require('cors'); const cors = require('cors');
require('dotenv').config(); const path = require('path');
// Prioritize machine-specific local config (.env.local)
require('dotenv').config({ path: path.join(__dirname, '.env.local') });
require('dotenv').config(); // Fallback to standard .env
const db = require('./db'); const db = require('./db');
const authRoutes = require('./routes/auth'); const authRoutes = require('./routes/auth');
@ -11,7 +14,6 @@ const { isAuthenticated } = require('./middleware/authMiddleware');
const app = express(); const app = express();
const PORT = process.env.PORT || 3005; // Changed to 3005 to avoid conflict with Synology services (3001 issue) const PORT = process.env.PORT || 3005; // Changed to 3005 to avoid conflict with Synology services (3001 issue)
const path = require('path');
const fs = require('fs'); const fs = require('fs');
// Ensure uploads directory exists // Ensure uploads directory exists
@ -182,7 +184,6 @@ const initTables = async () => {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
`; `;
await db.query(assetTable); await db.query(assetTable);
await db.query(assetTable);
await db.query(maintenanceTable); await db.query(maintenanceTable);
// Check/Add 'quantity' column to assets // Check/Add 'quantity' column to assets
@ -255,13 +256,32 @@ const initTables = async () => {
// Ignore // Ignore
} }
const [userTimeoutCols] = await db.query("SHOW COLUMNS FROM users LIKE 'session_timeout'"); const [allowedModulesCols] = await db.query("SHOW COLUMNS FROM users LIKE 'allowed_modules'");
if (userTimeoutCols.length === 0) { if (allowedModulesCols.length === 0) {
await db.query("ALTER TABLE users ADD COLUMN session_timeout INT DEFAULT 10 AFTER role"); await db.query("ALTER TABLE users ADD COLUMN allowed_modules JSON NULL AFTER session_timeout");
console.log("✅ Added 'session_timeout' column to users"); console.log("✅ Added 'allowed_modules' column to users");
// For existing users, grant access to all modules by default
const allModules = JSON.stringify(['asset', 'production', 'cctv', 'material', 'quality']);
await db.query("UPDATE users SET allowed_modules = ?", [allModules]);
} else {
// Migration: Ensure existing users have new modules in their allowed_modules if they are missing
try {
// Handle NULL or empty allowed_modules first to ensure JSON functions work
await db.query("UPDATE users SET allowed_modules = JSON_ARRAY('asset', 'production', 'cctv') WHERE allowed_modules IS NULL OR JSON_LENGTH(allowed_modules) = 0");
// Add material and quality if not present. inventory is removed later or handled via mapping.
await db.query("UPDATE users SET allowed_modules = JSON_ARRAY_APPEND(allowed_modules, '$', 'material') WHERE NOT JSON_CONTAINS(allowed_modules, '\"material\"')");
await db.query("UPDATE users SET allowed_modules = JSON_ARRAY_APPEND(allowed_modules, '$', 'quality') WHERE NOT JSON_CONTAINS(allowed_modules, '\"quality\"')");
// Cleanup legacy names
await db.query("UPDATE users SET allowed_modules = JSON_REMOVE(allowed_modules, JSON_UNQUOTE(JSON_SEARCH(allowed_modules, 'one', 'monitoring'))) WHERE JSON_SEARCH(allowed_modules, 'one', 'monitoring') IS NOT NULL");
await db.query("UPDATE users SET allowed_modules = JSON_REMOVE(allowed_modules, JSON_UNQUOTE(JSON_SEARCH(allowed_modules, 'one', 'inventory'))) WHERE JSON_SEARCH(allowed_modules, 'one', 'inventory') IS NOT NULL");
} catch (e) {
console.warn('⚠️ allowed_modules migration warning (platform may continue):', e.message);
}
} }
console.log('✅ Tables Initialized');
// Create asset_manuals table // Create asset_manuals table
const manualTable = ` const manualTable = `
CREATE TABLE IF NOT EXISTS asset_manuals ( CREATE TABLE IF NOT EXISTS asset_manuals (
@ -437,26 +457,53 @@ const initTables = async () => {
console.log("✅ Added 'subscriber_id' column to system_modules"); console.log("✅ Added 'subscriber_id' column to system_modules");
} }
// Initialize default modules if empty (Disabled by default as per request behavior) // Initialize default modules
const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1'); const defaultModules = [
if (existingModules.length === 0) { { code: 'asset', name: '자산 관리' },
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`; { code: 'production', name: '생산 관리' },
await db.query(insert, ['asset', '자산 관리', false, null]); { code: 'cctv', name: 'CCTV' },
await db.query(insert, ['production', '생산 관리', false, null]); { code: 'material', name: '자재/재고 관리' },
await db.query(insert, ['cctv', 'CCTV', false, null]); { code: 'quality', name: '품질 관리' }
} else { ];
// One-time update: Rename 'monitoring' code to 'cctv' (migration)
// Use subquery or check if cctv exists to avoid ER_DUP_ENTRY for (const mod of defaultModules) {
const [cctvExists] = await db.query("SELECT 1 FROM system_modules WHERE code = 'cctv'"); const [exists] = await db.query('SELECT 1 FROM system_modules WHERE code = ?', [mod.code]);
if (cctvExists.length > 0) { if (exists.length === 0) {
// If cctv already exists, just remove monitoring if it's there await db.query('INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)',
await db.query("DELETE FROM system_modules WHERE code = 'monitoring'"); [mod.code, mod.name, false, null]);
} else { console.log(`✅ Initialized module: ${mod.name} (${mod.code})`);
// If cctv doesn't exist, try renaming monitoring
await db.query("UPDATE system_modules SET code = 'cctv' WHERE code = 'monitoring'");
} }
} }
// One-time update: Rename codes (migration) with data preservation
// We migrate data from legacy rows to new rows if the new row doesn't have a license yet.
// 1. monitoring -> cctv
await db.query(`
UPDATE system_modules m
JOIN system_modules i ON i.code = 'monitoring'
SET m.is_active = i.is_active,
m.license_key = i.license_key,
m.license_type = i.license_type,
m.expiry_date = i.expiry_date,
m.subscriber_id = i.subscriber_id
WHERE m.code = 'cctv' AND (m.license_key IS NULL OR m.license_key = '')
`);
await db.query("DELETE FROM system_modules WHERE code = 'monitoring'");
// 2. inventory -> material
await db.query(`
UPDATE system_modules m
JOIN system_modules i ON i.code = 'inventory'
SET m.is_active = i.is_active,
m.license_key = i.license_key,
m.license_type = i.license_type,
m.expiry_date = i.expiry_date,
m.subscriber_id = i.subscriber_id
WHERE m.code = 'material' AND (m.license_key IS NULL OR m.license_key = '')
`);
await db.query("DELETE FROM system_modules WHERE code = 'inventory'");
console.log('✅ Tables Initialized'); console.log('✅ Tables Initialized');
} catch (err) { } catch (err) {
console.error('❌ Table Initialization Failed:', err); console.error('❌ Table Initialization Failed:', err);
@ -467,13 +514,13 @@ initTables();
const packageJson = require('./package.json'); const packageJson = require('./package.json');
app.get('/api/health', (req, res) => { app.get('/api/health', (req, res) => {
// Light-weight health check // Light-weight health check (Read from package.json to simulate 'installed' version)
const kstOffset = 9 * 60 * 60 * 1000; const kstOffset = 9 * 60 * 60 * 1000;
const kstDate = new Date(Date.now() + kstOffset); const kstDate = new Date(Date.now() + kstOffset);
res.json({ res.json({
status: 'ok', status: 'ok',
version: packageJson.version, version: packageJson.version, // Use static version from file
node_version: process.version, node_version: process.version,
platform: process.platform, platform: process.platform,
arch: process.arch, arch: process.arch,

View File

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

View File

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

View File

@ -135,7 +135,7 @@ router.post('/verify-supervisor', isAuthenticated, async (req, res) => {
// 2. List Users (Admin Only) // 2. List Users (Admin Only)
router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => { router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
try { try {
const [rows] = await db.query('SELECT id, name, department, position, phone, role, session_timeout, last_login, created_at, updated_at FROM users ORDER BY created_at DESC'); const [rows] = await db.query('SELECT id, name, department, position, phone, role, session_timeout, allowed_modules, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
if (!rows || rows.length === 0) { if (!rows || rows.length === 0) {
return res.json([]); return res.json([]);
@ -159,7 +159,7 @@ router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
// 3. Create User // 3. Create User
router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => { router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
const { id, password, name, department, position, phone, role, session_timeout } = req.body; const { id, password, name, department, position, phone, role, session_timeout, allowed_modules } = req.body;
if (!id || !password || !name) { if (!id || !password || !name) {
return res.status(400).json({ error: 'Missing required fields' }); return res.status(400).json({ error: 'Missing required fields' });
@ -176,11 +176,13 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
const encryptedPhone = await encrypt(phone); const encryptedPhone = await encrypt(phone);
const sql = ` const sql = `
INSERT INTO users (id, password, name, department, position, phone, role, session_timeout) INSERT INTO users (id, password, name, department, position, phone, role, session_timeout, allowed_modules)
VALUES (?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`; `;
await db.query(sql, [id, hashedPassword, name, department, position, encryptedPhone, role || 'user', session_timeout || 10]); const modulesJson = allowed_modules ? JSON.stringify(allowed_modules) : JSON.stringify(['asset', 'production', 'cctv', 'material', 'quality']);
await db.query(sql, [id, hashedPassword, name, department, position, encryptedPhone, role || 'user', session_timeout || 10, modulesJson]);
res.status(201).json({ message: 'User created' }); res.status(201).json({ message: 'User created' });
} catch (err) { } catch (err) {
@ -191,7 +193,7 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
// 4. Update User // 4. Update User
router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) => { router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) => {
const { password, name, department, position, phone, role, session_timeout } = req.body; const { password, name, department, position, phone, role, session_timeout, allowed_modules } = req.body;
const userId = req.params.id; const userId = req.params.id;
try { try {
@ -205,6 +207,7 @@ router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) =>
if (phone !== undefined) { updates.push('phone = ?'); params.push(await encrypt(phone)); } if (phone !== undefined) { updates.push('phone = ?'); params.push(await encrypt(phone)); }
if (role) { updates.push('role = ?'); params.push(role); } if (role) { updates.push('role = ?'); params.push(role); }
if (session_timeout !== undefined) { updates.push('session_timeout = ?'); params.push(session_timeout); } if (session_timeout !== undefined) { updates.push('session_timeout = ?'); params.push(session_timeout); }
if (allowed_modules !== undefined) { updates.push('allowed_modules = ?'); params.push(JSON.stringify(allowed_modules)); }
if (updates.length === 0) return res.json({ message: 'No changes' }); if (updates.length === 0) return res.json({ message: 'No changes' });

View File

@ -40,11 +40,18 @@ const ALLOWED_SETTING_KEYS = [
]; ];
// --- .env File Utilities --- // --- .env File Utilities ---
const envPath = path.join(__dirname, '../.env'); // Use .env.local for local machine settings (Ignored by Git)
const localEnvPath = path.join(__dirname, '../.env.local');
const defaultEnvPath = path.join(__dirname, '../.env');
const readEnv = () => { const readEnv = () => {
if (!fs.existsSync(envPath)) return {}; // 1. Check for .env.local first
const content = fs.readFileSync(envPath, 'utf8'); let path = fs.existsSync(localEnvPath) ? localEnvPath : defaultEnvPath;
// 2. Migration: If ONLY .env exists, we'll read from it and later save will create .env.local
if (!fs.existsSync(path)) return {};
const content = fs.readFileSync(path, 'utf8');
const lines = content.split('\n'); const lines = content.split('\n');
const env = {}; const env = {};
lines.forEach(line => { lines.forEach(line => {
@ -57,7 +64,16 @@ const readEnv = () => {
}; };
const writeEnv = (updates) => { const writeEnv = (updates) => {
let content = fs.readFileSync(envPath, 'utf8'); // If .env.local doesn't exist, create it by copying .env (if exists) or from empty
if (!fs.existsSync(localEnvPath)) {
if (fs.existsSync(defaultEnvPath)) {
fs.copyFileSync(defaultEnvPath, localEnvPath);
} else {
fs.writeFileSync(localEnvPath, '', 'utf8');
}
}
let content = fs.readFileSync(localEnvPath, 'utf8');
Object.entries(updates).forEach(([key, value]) => { Object.entries(updates).forEach(([key, value]) => {
const regex = new RegExp(`^${key}=.*`, 'm'); const regex = new RegExp(`^${key}=.*`, 'm');
if (regex.test(content)) { if (regex.test(content)) {
@ -66,7 +82,7 @@ const writeEnv = (updates) => {
content += `\n${key}=${value}`; content += `\n${key}=${value}`;
} }
}); });
fs.writeFileSync(envPath, content, 'utf8'); fs.writeFileSync(localEnvPath, content, 'utf8');
}; };
const mysql = require('mysql2/promise'); const mysql = require('mysql2/promise');
@ -294,7 +310,7 @@ router.get('/modules', isAuthenticated, async (req, res) => {
const [rows] = await db.query('SELECT * FROM system_modules'); const [rows] = await db.query('SELECT * FROM system_modules');
const modules = {}; const modules = {};
const defaults = ['asset', 'production', 'cctv']; const defaults = ['asset', 'production', 'cctv', 'material', 'quality'];
// Get stored subscriber ID // Get stored subscriber ID
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'"); const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
@ -344,7 +360,10 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
// 2. Check Module match // 2. Check Module match
// Allow legacy 'monitoring' licenses to activate 'cctv' module // Allow legacy 'monitoring' licenses to activate 'cctv' module
const isMatch = result.module === code || (code === 'cctv' && result.module === 'monitoring'); // Allow legacy 'inventory' licenses to activate 'material' module
const isMatch = result.module === code ||
(code === 'cctv' && result.module === 'monitoring') ||
(code === 'material' && result.module === 'inventory');
if (!isMatch) { if (!isMatch) {
return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` }); return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` });
@ -399,17 +418,23 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
const names = { const names = {
'asset': '자산 관리', 'asset': '자산 관리',
'production': '생산 관리', 'production': '생산 관리',
'cctv': 'CCTV' 'cctv': 'CCTV',
'material': '자재/재고 관리',
'quality': '품질 관리'
}; };
await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]); await db.query(sql, [code, names[code] || code, licenseKey, result.type, result.expiryDate, result.subscriberId]);
console.log(`✅ [Module Activation] Successfully activated '${code}' for '${result.subscriberId}'`);
// 5. Sync status with License Manager // 5. Sync status with License Manager
const licenseManagerUrl = process.env.LICENSE_MANAGER_URL || 'http://localhost:3006/api'; const licenseManagerUrl = process.env.LICENSE_MANAGER_URL || 'http://sokuree.com:3006/api';
try { try {
console.log(`📡 Syncing with License Manager: ${licenseManagerUrl}/licenses/activate`);
await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey }); await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey });
console.log(`✅ Synced activation status for key: ${licenseKey.substring(0, 20)}...`); console.log(`✅ Synced activation status for key: ${licenseKey.substring(0, 20)}...`);
} catch (syncErr) { } catch (syncErr) {
console.error(`❌ Sync Error Object:`, syncErr);
const errorDetail = syncErr.response ? const errorDetail = syncErr.response ?
`Status: ${syncErr.response.status}, Data: ${JSON.stringify(syncErr.response.data)}` : `Status: ${syncErr.response.status}, Data: ${JSON.stringify(syncErr.response.data)}` :
syncErr.message; syncErr.message;
@ -489,13 +514,14 @@ const getGiteaAuth = async () => {
// 5. Get Version Info (Current, Remote & History from Tags) // 5. Get Version Info (Current, Remote & History from Tags)
router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => { router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => {
try { try {
// Local version detection (No caching) // Local version detection (File-based for update simulation)
const projectRoot = path.join(__dirname, '../..');
let currentVersion = '0.0.0.0'; let currentVersion = '0.0.0.0';
try { try {
const rootPkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8')); const rootPkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
currentVersion = rootPkg.version; currentVersion = rootPkg.version;
} catch (e) { } catch (e) {
currentVersion = '0.4.2.1'; currentVersion = '0.4.2.8';
} }
// Prepare git fetch command // Prepare git fetch command
@ -509,7 +535,6 @@ router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res
fetchCmd = `git fetch ${auth.url} --tags --force --prune`; fetchCmd = `git fetch ${auth.url} --tags --force --prune`;
} }
const projectRoot = path.join(__dirname, '../..');
exec(fetchCmd, { cwd: projectRoot }, (err, stdout, stderr) => { exec(fetchCmd, { cwd: projectRoot }, (err, stdout, stderr) => {
if (err) { if (err) {
console.error('Git fetch failed:', err); console.error('Git fetch failed:', err);
@ -644,6 +669,10 @@ if not exist "%BACKUP_PATH%" (
) )
echo [Update] Backing up Config... echo [Update] Backing up Config...
if exist "server\\.env.local" (
copy /Y "server\\.env.local" "%BACKUP_PATH%\\.env.local.backup.${timestamp}"
copy /Y "server\\.env.local" "server\\.env.local.tmp"
)
if exist "server\\.env" ( if exist "server\\.env" (
copy /Y "server\\.env" "%BACKUP_PATH%\\.env.backup.${timestamp}" copy /Y "server\\.env" "%BACKUP_PATH%\\.env.backup.${timestamp}"
copy /Y "server\\.env" "server\\.env.tmp" copy /Y "server\\.env" "server\\.env.tmp"
@ -654,6 +683,13 @@ git fetch "${remoteUrl}" --tags --force --prune
git checkout -f ${targetTag} git checkout -f ${targetTag}
echo [Update] Restoring Config... echo [Update] Restoring Config...
if exist "server\\.env.local.tmp" (
copy /Y "server\\.env.local.tmp" "server\\.env.local"
del "server\\.env.local.tmp"
) else if exist "%BACKUP_PATH%\\.env.local.backup.${timestamp}" (
copy /Y "%BACKUP_PATH%\\.env.local.backup.${timestamp}" "server\\.env.local"
)
if exist "server\\.env.tmp" ( if exist "server\\.env.tmp" (
copy /Y "server\\.env.tmp" "server\\.env" copy /Y "server\\.env.tmp" "server\\.env"
del "server\\.env.tmp" del "server\\.env.tmp"
@ -698,12 +734,17 @@ else
fi fi
echo "[Update] Backing up Config..." echo "[Update] Backing up Config..."
# Backup .env.local (Priority)
if [ -f "server/.env.local" ]; then
echo "[Info] Backing up 'server/.env.local'."
cp "server/.env.local" "$BACKUP_DIR/.env.local.backup.${timestamp}"
cp "server/.env.local" "server/.env.local.tmp"
fi
# Backup .env (Fallback)
if [ -f "server/.env" ]; then if [ -f "server/.env" ]; then
echo "[Info] Backing up 'server/.env' to '$BACKUP_DIR/.env.backup.${timestamp}' and 'server/.env.tmp'." echo "[Info] Backing up 'server/.env'."
cp "server/.env" "$BACKUP_DIR/.env.backup.${timestamp}" cp "server/.env" "$BACKUP_DIR/.env.backup.${timestamp}"
cp "server/.env" "server/.env.tmp" cp "server/.env" "server/.env.tmp"
else
echo "[Warning] 'server/.env' not found. Skipping config backup."
fi fi
echo "[Update] Syncing Source Code..." echo "[Update] Syncing Source Code..."
@ -722,6 +763,13 @@ fi
echo "[Info] Git checkout to ${targetTag} successful." echo "[Info] Git checkout to ${targetTag} successful."
echo "[Update] Restoring Config..." echo "[Update] Restoring Config..."
if [ -f "server/.env.local.tmp" ]; then
cp "server/.env.local.tmp" "server/.env.local"
rm "server/.env.local.tmp"
elif [ -f "$BACKUP_DIR/.env.local.backup.${timestamp}" ]; then
cp "$BACKUP_DIR/.env.local.backup.${timestamp}" "server/.env.local"
fi
if [ -f "server/.env.tmp" ]; then if [ -f "server/.env.tmp" ]; then
cp "server/.env.tmp" "server/.env" cp "server/.env.tmp" "server/.env"
rm "server/.env.tmp" rm "server/.env.tmp"

19
server/test_sync.js Normal file
View File

@ -0,0 +1,19 @@
const axios = require('axios');
async function test() {
const licenseManagerUrl = 'http://localhost:3006/api';
const licenseKey = 'test-key';
try {
console.log(`📡 Testing sync with ${licenseManagerUrl}/licenses/activate`);
const response = await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey });
console.log('✅ Success:', response.data);
} catch (err) {
if (err.response) {
console.error('❌ Error Response:', err.response.status, err.response.data);
} else {
console.error('❌ Error Message:', err.message);
}
}
}
test();

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom';
import { Card } from '../../../shared/ui/Card'; import { Card } from '../../../shared/ui/Card';
import { Button } from '../../../shared/ui/Button'; import { Button } from '../../../shared/ui/Button';
import { ArrowLeft, Save, Upload, X, Printer, ZoomIn, Trash2, Plus } from 'lucide-react'; import { ArrowLeft, Save, Upload, X, Printer, ZoomIn, Trash2, Plus } from 'lucide-react';
@ -15,6 +15,7 @@ interface AssetBasicInfoProps {
} }
export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) { export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
const navigate = useNavigate();
const { user } = useAuth(); const { user } = useAuth();
// User role is now allowed to edit assets // User role is now allowed to edit assets
const canEdit = user?.role === 'admin' || user?.role === 'supervisor' || user?.role === 'user'; const canEdit = user?.role === 'admin' || user?.role === 'supervisor' || user?.role === 'user';
@ -26,7 +27,7 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
const [showAccModal, setShowAccModal] = useState(false); const [showAccModal, setShowAccModal] = useState(false);
const [newAcc, setNewAcc] = useState({ name: '', spec: '', quantity: 1 }); const [newAcc, setNewAcc] = useState({ name: '', spec: '', quantity: 1 });
React.useEffect(() => { useEffect(() => {
loadAccessories(); loadAccessories();
}, [asset.id]); }, [asset.id]);
@ -39,7 +40,7 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
} }
}; };
React.useEffect(() => { useEffect(() => {
if (isEditing) { if (isEditing) {
const loadAllAssets = async () => { const loadAllAssets = async () => {
try { try {
@ -442,8 +443,7 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
icon={<Plus size={14} />} icon={<Plus size={14} />}
onClick={() => { onClick={() => {
// Navigate to register with pre-filled parent // Navigate to register with pre-filled parent
// Note: Assuming navigation or handling via state navigate(`/asset/register?parentId=${asset.id}`);
window.location.href = `/asset/register?parentId=${asset.id}&categoryId=${allAssets.find(a => a.category === '설비')?.id || ''}`;
}} }}
> >

View File

@ -0,0 +1,147 @@
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Search, Check } from 'lucide-react';
import { assetApi, type Asset } from '../../../shared/api/assetApi';
import { Button } from '../../../shared/ui/Button';
interface AssetSearchModalProps {
isOpen: boolean;
onClose: () => void;
onSelect: (asset: Asset) => void;
title?: string;
categoryFilter?: string; // Optional: filter by category name like '설비'
}
export function AssetSearchModal({ isOpen, onClose, onSelect, title = '자산 검색', categoryFilter }: AssetSearchModalProps) {
const [searchTerm, setSearchTerm] = useState('');
const [assets, setAssets] = useState<Asset[]>([]);
const [filteredAssets, setFilteredAssets] = useState<Asset[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (isOpen) {
loadAssets();
}
}, [isOpen]);
const loadAssets = async () => {
try {
setIsLoading(true);
const data = await assetApi.getAll();
// Apply category filter if provided
let result = data;
if (categoryFilter) {
result = data.filter(a => a.category === categoryFilter);
}
setAssets(result);
setFilteredAssets(result);
} catch (error) {
console.error('Failed to load assets for search:', error);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
if (!searchTerm) {
setFilteredAssets(assets);
return;
}
const lowerSearch = searchTerm.toLowerCase();
const filtered = assets.filter(a =>
a.name.toLowerCase().includes(lowerSearch) ||
a.id.toLowerCase().includes(lowerSearch) ||
(a.serialNumber && a.serialNumber.toLowerCase().includes(lowerSearch))
);
setFilteredAssets(filtered);
}, [searchTerm, assets]);
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl flex flex-col max-h-[80vh] overflow-hidden border border-slate-200">
{/* Header */}
<div className="p-5 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
<Search size={20} className="text-blue-500" />
{title}
</h2>
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1 transition-colors">
<X size={20} />
</button>
</div>
{/* Search Bar */}
<div className="p-4 border-b border-slate-50 bg-white">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all font-medium"
placeholder="설비명 또는 관리번호로 검색..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
autoFocus
/>
</div>
</div>
{/* List Area */}
<div className="flex-1 overflow-y-auto p-2 bg-slate-50/30">
{isLoading ? (
<div className="p-12 text-center text-slate-500 font-medium"> ...</div>
) : filteredAssets.length > 0 ? (
<div className="grid grid-cols-1 gap-2">
{filteredAssets.map(asset => (
<button
key={asset.id}
onClick={() => onSelect(asset)}
className="flex items-center justify-between p-4 bg-white border border-slate-200 rounded-xl hover:border-blue-400 hover:shadow-md hover:bg-blue-50/30 transition-all group text-left"
>
<div className="flex flex-col gap-1">
<div className="flex items-center gap-2">
<span className="text-xs font-bold font-mono text-blue-600 bg-blue-50 px-2 py-0.5 rounded border border-blue-100">
{asset.id}
</span>
<span className="text-sm font-bold text-slate-500 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">
{asset.category}
</span>
</div>
<span className="text-lg font-bold text-slate-800 group-hover:text-blue-700 transition-colors">
{asset.name}
</span>
<div className="flex gap-3 text-sm text-slate-500">
<span>: <span className="text-slate-700 font-medium">{asset.location}</span></span>
{asset.serialNumber && <span>S/N: <span className="text-slate-700 font-medium">{asset.serialNumber}</span></span>}
</div>
</div>
<div className="bg-slate-50 group-hover:bg-blue-500 group-hover:text-white p-2 rounded-full transition-all">
<Check size={20} />
</div>
</button>
))}
</div>
) : (
<div className="p-20 text-center">
<div className="bg-slate-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
<Search size={24} className="text-slate-400" />
</div>
<p className="text-slate-500 font-medium font-lg"> .</p>
<p className="text-slate-400 text-sm mt-1"> .</p>
</div>
)}
</div>
{/* Footer */}
<div className="p-4 border-t border-slate-100 bg-slate-50/50 flex justify-end">
<Button variant="secondary" onClick={onClose}></Button>
</div>
</div>
</div>,
document.body
);
}

View File

@ -4,15 +4,21 @@ import { AssetListPage } from './pages/AssetListPage';
import { AssetRegisterPage } from './pages/AssetRegisterPage'; import { AssetRegisterPage } from './pages/AssetRegisterPage';
import { AssetSettingsPage } from './pages/AssetSettingsPage'; import { AssetSettingsPage } from './pages/AssetSettingsPage';
import { AssetDetailPage } from './pages/AssetDetailPage'; import { AssetDetailPage } from './pages/AssetDetailPage';
import { PlaceholderPage } from '../../shared/ui/PlaceholderPage';
export const assetModule: IModuleDefinition = { export const assetModule: IModuleDefinition = {
moduleName: 'asset-management', moduleName: 'asset-management',
basePath: '/asset', basePath: '/asset',
routes: [ routes: [
{ path: '/dashboard', element: <DashboardPage />, label: '대시보드', group: '기본' }, { path: '/dashboard', element: <DashboardPage />, label: '대시보드' },
{ path: '/list', element: <AssetListPage />, label: '자산 목록', group: '기본' }, { path: '/list', element: <AssetListPage />, label: '자산 목록', group: '기준정보' },
{ path: '/register', element: <AssetRegisterPage />, label: '자산 등록', group: '기본' }, { path: '/register', element: <AssetRegisterPage />, label: '자산 등록', group: '기준정보' },
{ path: '/settings', element: <AssetSettingsPage />, label: '자산 설정', group: '기본' },
{ path: '/settings/modules', element: <PlaceholderPage title="모듈 관리" description="준비 중인 페이지입니다. 라이선스 관리와는 별도로 각 모듈의 세부 작동 방식을 설정하는 공간입니다." />, label: '모듈 관리', group: '설정' },
{ path: '/settings', element: <AssetSettingsPage />, label: '자산 설정', group: '설정' },
{ path: '/settings/iot', element: <PlaceholderPage title="IOT 센서 관리" description="자산에 부착된 IOT 센서의 상태를 모니터링하고 임계치를 설정할 수 있는 기능이 준비 중입니다." />, label: 'IOT 센서 관리', group: '설정' },
{ path: '/settings/facilities', element: <PlaceholderPage title="설비 관리" description="생산 설비의 효율 및 정비 주기를 전문적으로 관리하기 위한 전용 메뉴를 개발 중입니다." />, label: '설비 관리', group: '설정' },
{ path: '/detail/:assetId', element: <AssetDetailPage /> }, { path: '/detail/:assetId', element: <AssetDetailPage /> },
{ path: '/facilities', element: <AssetListPage />, label: '시설물 관리', position: 'top' }, { path: '/facilities', element: <AssetListPage />, label: '시설물 관리', position: 'top' },

View File

@ -1,16 +1,18 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { Card } from '../../../shared/ui/Card'; import { Card } from '../../../shared/ui/Card';
import { Button } from '../../../shared/ui/Button'; import { Button } from '../../../shared/ui/Button';
import { Input } from '../../../shared/ui/Input'; import { Input } from '../../../shared/ui/Input';
import { Select } from '../../../shared/ui/Select'; import { Select } from '../../../shared/ui/Select';
import { ArrowLeft, Save, Upload } from 'lucide-react'; import { ArrowLeft, Save, Upload, Search, X } from 'lucide-react';
import { getCategories, getLocations, getIDRule } from './AssetSettingsPage'; import { getCategories, getLocations, getIDRule } from './AssetSettingsPage';
import { assetApi, type Asset } from '../../../shared/api/assetApi'; import { assetApi, type Asset } from '../../../shared/api/assetApi';
import { AssetSearchModal } from '../components/AssetSearchModal';
import './AssetRegisterPage.css'; import './AssetRegisterPage.css';
export function AssetRegisterPage() { export function AssetRegisterPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams();
// Load Settings Data // Load Settings Data
const categories = getCategories(); const categories = getCategories();
@ -19,9 +21,10 @@ export function AssetRegisterPage() {
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
id: '', // Asset ID (Auto-generated) id: '', // Asset ID (Auto-generated)
parentId: '', parentId: searchParams.get('parentId') || '',
parentName: '', // For display
name: '', name: '',
categoryId: '', // Use ID for selection categoryId: searchParams.get('categoryId') || '', // Use ID for selection
model: '', model: '',
serialNo: '', serialNo: '',
locationId: '', // Use ID for selection locationId: '', // Use ID for selection
@ -34,19 +37,32 @@ export function AssetRegisterPage() {
manufacturer: '' manufacturer: ''
}); });
const [allAssets, setAllAssets] = useState<Asset[]>([]); const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
useEffect(() => { useEffect(() => {
const loadAllAssets = async () => { const loadInitialData = async () => {
try { try {
const data = await assetApi.getAll(); const data = await assetApi.getAll();
setAllAssets(data);
// If parentId is provided via URL, find its name
const pId = searchParams.get('parentId');
if (pId) {
const parent = data.find(a => a.id === pId);
if (parent) {
setFormData(prev => ({
...prev,
parentName: parent.name,
// If it's a sub-equipment, it's likely the same category as parent if parent is facility
categoryId: prev.categoryId || categories.find(c => c.name === '설비')?.id || ''
}));
}
}
} catch (err) { } catch (err) {
console.error("Failed to load assets for parent selection", err); console.error("Failed to load assets for initial data", err);
} }
}; };
loadAllAssets(); loadInitialData();
}, []); }, [searchParams, categories]);
// Auto-generate Asset ID // Auto-generate Asset ID
useEffect(() => { useEffect(() => {
@ -71,9 +87,7 @@ export function AssetRegisterPage() {
return ''; return '';
}).join(''); }).join('');
const finalId = generatedId.replace('001', '001'); setFormData(prev => ({ ...prev, id: generatedId }));
setFormData(prev => ({ ...prev, id: finalId }));
}, [formData.categoryId, formData.purchaseDate, idRule, categories]); }, [formData.categoryId, formData.purchaseDate, idRule, categories]);
@ -147,6 +161,23 @@ export function AssetRegisterPage() {
const isFacility = categories.find(c => c.id === formData.categoryId)?.name === '설비'; const isFacility = categories.find(c => c.id === formData.categoryId)?.name === '설비';
const handleSelectParent = (parent: Asset) => {
setFormData(prev => ({
...prev,
parentId: parent.id,
parentName: parent.name
}));
setIsSearchModalOpen(false);
};
const clearParent = () => {
setFormData(prev => ({
...prev,
parentId: '',
parentName: ''
}));
};
return ( return (
<div className="page-container"> <div className="page-container">
<div className="page-header-right"> <div className="page-header-right">
@ -174,19 +205,38 @@ export function AssetRegisterPage() {
/> />
{isFacility && ( {isFacility && (
<Select <div className="ui-field-container">
label="상위 설비 (메인 설비)" <label className="ui-label"> ( )</label>
name="parentId" <div className="flex gap-2">
value={formData.parentId} <div className="relative flex-1">
onChange={handleChange} <Input
options={[ name="parentName"
{ label: '(없음 - 메인 설비로 등록)', value: '' }, value={formData.parentName ? `[${formData.parentId}] ${formData.parentName}` : ''}
...allAssets placeholder="검색 아이콘을 클릭하여 선택하세요"
.filter(a => a.category === '설비' && a.id !== formData.id) readOnly
.map(a => ({ label: `[${a.id}] ${a.name}`, value: a.id })) className="bg-slate-50 cursor-default"
]} />
placeholder="메인 설비가 있는 경우 선택" {formData.parentId && (
/> <button
type="button"
onClick={clearParent}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-red-500"
>
<X size={16} />
</button>
)}
</div>
<Button
type="button"
variant="secondary"
onClick={() => setIsSearchModalOpen(true)}
className="shrink-0 h-[42px]"
icon={<Search size={18} />}
>
</Button>
</div>
</div>
)} )}
<Input <Input
@ -369,6 +419,14 @@ export function AssetRegisterPage() {
</div> </div>
</form> </form>
</Card> </Card>
<AssetSearchModal
isOpen={isSearchModalOpen}
onClose={() => setIsSearchModalOpen(false)}
onSelect={handleSelectParent}
title="상위 설비 검색"
categoryFilter="설비"
/>
</div> </div>
); );
} }

View File

@ -64,7 +64,7 @@ export function JSMpegPlayer({ url, width, height, className }: JSMpegPlayerProp
}, [url, retryCount]); // Re-run when url or retryCount changes }, [url, retryCount]); // Re-run when url or retryCount changes
return ( return (
<div className={`video-container bg-black flex items-center justify-center overflow-hidden ${className || ''}`} style={{ width, height }}> <div className={`video-container bg-transparent flex items-center justify-center overflow-hidden ${className || ''}`} style={{ width, height }}>
<canvas key={`${url}-${retryCount}`} ref={canvasRef} className="w-full h-full object-contain" /> <canvas key={`${url}-${retryCount}`} ref={canvasRef} className="w-full h-full object-contain" />
</div> </div>
); );

View File

@ -40,7 +40,7 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr
}; };
return ( return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className={`relative bg-black overflow-hidden border ${disabled ? 'border-slate-800' : 'border-slate-700 hover:border-indigo-500'} transition-colors group h-full w-full`}> <div ref={setNodeRef} style={style} {...attributes} {...listeners} className={`relative bg-[#0f172a] rounded-xl overflow-hidden border shadow-sm ${disabled ? 'border-slate-200' : 'border-slate-200 hover:border-indigo-500'} transition-all group h-full w-full`}>
{children} {children}
</div> </div>
); );
@ -309,7 +309,7 @@ export function MonitoringPage() {
const slots = Array.from({ length: totalSlots }).map((_, i) => filteredCameras[i] || null); const slots = Array.from({ length: totalSlots }).map((_, i) => filteredCameras[i] || null);
return ( return (
<div className="w-full h-full bg-[#0a0a0a] flex flex-col min-h-0"> <div className="w-full h-full bg-[#f1f5f9] flex flex-col min-h-0">
<HeaderDropdown /> <HeaderDropdown />
<div className="flex-1 p-2 overflow-hidden flex flex-col min-h-0"> <div className="flex-1 p-2 overflow-hidden flex flex-col min-h-0">
@ -323,7 +323,7 @@ export function MonitoringPage() {
strategy={rectSortingStrategy} strategy={rectSortingStrategy}
disabled={true} disabled={true}
> >
<div className={`grid h-full w-full gap-1 ${layoutInfo.cols} ${totalSlots > layoutInfo.total ? 'overflow-y-auto' : ''}`} <div className={`grid h-full w-full gap-4 ${layoutInfo.cols} ${totalSlots > layoutInfo.total ? 'overflow-y-auto' : ''}`}
style={{ gridAutoRows: (viewLayout === '1' || viewLayout === '1*2') ? '100%' : `${100 / Math.sqrt(layoutInfo.total)}%` }}> style={{ gridAutoRows: (viewLayout === '1' || viewLayout === '1*2') ? '100%' : `${100 / Math.sqrt(layoutInfo.total)}%` }}>
{slots.map((camera, index) => ( {slots.map((camera, index) => (
<div key={camera ? camera.id : `empty-${index}`} className="h-full w-full"> <div key={camera ? camera.id : `empty-${index}`} className="h-full w-full">
@ -362,7 +362,7 @@ export function MonitoringPage() {
</div> </div>
{/* Streaming Video */} {/* Streaming Video */}
<div className="flex-1 bg-black flex items-center justify-center"> <div className="flex-1 bg-[#0f172a] flex items-center justify-center">
<JSMpegPlayer <JSMpegPlayer
key={`${camera.id}-${streamVersions[camera.id] || 0}`} key={`${camera.id}-${streamVersions[camera.id] || 0}`}
url={getStreamUrl(camera.id)} url={getStreamUrl(camera.id)}
@ -377,9 +377,9 @@ export function MonitoringPage() {
</div> </div>
</SortableCamera> </SortableCamera>
) : ( ) : (
<div className="h-full w-full bg-[#121212] border border-slate-900/50 flex flex-col items-center justify-center text-slate-700 select-none"> <div className="h-full w-full bg-white rounded-xl border border-slate-200 flex flex-col items-center justify-center text-slate-300 select-none shadow-sm">
<Video size={32} className="opacity-10 mb-2" /> <Video size={32} className="opacity-20 mb-2" />
<span className="text-[11px] font-bold opacity-20 uppercase tracking-widest">No Buffer</span> <span className="text-[11px] font-bold opacity-40 uppercase tracking-widest">No Buffer</span>
</div> </div>
)} )}
</div> </div>

View File

@ -0,0 +1,30 @@
import { MaterialOverviewPage } from './pages/MaterialOverviewPage';
import { MaterialListPage } from './pages/MaterialListPage';
import { Package } from 'lucide-react';
import type { IModuleDefinition } from '../../core/types';
export const materialModule: IModuleDefinition = {
moduleName: 'material-management',
label: '자재/재고 관리',
basePath: '/material',
description: '공장 소모품 및 원자재 재고 관리 모듈',
routes: [
{
path: '/overview',
element: <MaterialOverviewPage />,
label: '자재 현황',
icon: <Package size={16} />
},
{
path: '/list',
element: <MaterialListPage />,
label: '자재 목록'
},
{
path: '/consumables',
element: <MaterialListPage />,
label: '소모품 관리'
}
],
requiredRoles: ['admin', 'manager', 'user']
};

View File

@ -0,0 +1,75 @@
import { Card } from '../../../shared/ui/Card';
import { Package, Search, Filter, Plus, Edit2, Trash2 } from 'lucide-react';
import { Button } from '../../../shared/ui/Button';
export function MaterialListPage() {
return (
<div className="page-container animate-fade-in">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900"> / </h1>
<p className="text-slate-500 mt-1">, , .</p>
</div>
<Button icon={<Plus size={18} />}> </Button>
</div>
<Card className="p-4 mb-6 bg-slate-50/50 border-slate-200">
<div className="flex gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
<input
type="text"
placeholder="자재명, 규격, 제조사 검색..."
className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
/>
</div>
<Button variant="secondary" icon={<Filter size={18} />}></Button>
</div>
</Card>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{/* Mock Data for Demonstration */}
{[
{ id: 'MAT-001', name: '절삭유 (Coolant)', spec: '50L drum', stock: 5, unit: 'EA', loc: '자재창고 A', status: 'normal' },
{ id: 'MAT-002', name: 'CNC 전용 공구홀더', spec: 'BT40', stock: 12, unit: 'EA', loc: '자재창고 B', status: 'low' },
{ id: 'MAT-003', name: '가공용 팁 (Insert)', spec: 'CNMG120408', stock: 2, unit: 'BOX', loc: '현장 캐비닛', status: 'critical' },
{ id: 'MAT-004', name: '스핀들 그리스', spec: '200g tube', stock: 8, unit: 'EA', loc: '자재창고 A', status: 'normal' },
].map(item => (
<Card key={item.id} className="hover:shadow-md transition-all border-slate-200 overflow-hidden">
<div className="p-4 border-b border-slate-100 bg-slate-50/30 flex justify-between items-start">
<div className="bg-white p-2 rounded-lg border border-slate-200 shadow-sm">
<Package className="text-blue-500" size={24} />
</div>
<div className={`px-2 py-1 rounded text-[10px] font-bold uppercase ${item.status === 'critical' ? 'bg-red-100 text-red-700' :
item.status === 'low' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'
}`}>
{item.status === 'critical' ? '재고 부족' : item.status === 'low' ? '보충 필요' : '정상'}
</div>
</div>
<div className="p-4">
<h3 className="font-bold text-slate-800 truncate mb-1" title={item.name}>{item.name}</h3>
<p className="text-xs text-slate-500 mb-4">{item.spec}</p>
<div className="flex justify-between items-center bg-slate-50 p-3 rounded-lg border border-slate-100">
<div>
<span className="text-[10px] text-slate-400 block uppercase font-bold tracking-tight"> </span>
<span className={`text-xl font-black ${item.status === 'critical' ? 'text-red-600' : 'text-slate-800'
}`}>{item.stock}</span>
<span className="text-xs text-slate-500 ml-1">{item.unit}</span>
</div>
<div className="text-right">
<span className="text-[10px] text-slate-400 block uppercase font-bold tracking-tight"></span>
<span className="text-xs font-bold text-slate-600">{item.loc}</span>
</div>
</div>
</div>
<div className="px-4 py-3 bg-slate-50/50 border-t border-slate-100 flex justify-end gap-1">
<button className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"><Edit2 size={14} /></button>
<button className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"><Trash2 size={14} /></button>
</div>
</Card>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,49 @@
import { Card } from '../../../shared/ui/Card';
import { Package, ArrowRightLeft, AlertTriangle, BarChart3 } from 'lucide-react';
export function MaterialOverviewPage() {
const stats = [
{ label: '전체 품목', value: '1,280', icon: <Package className="text-blue-600" />, change: '+12' },
{ label: '입출고 현황', value: '45', icon: <ArrowRightLeft className="text-emerald-600" />, change: '오늘' },
{ label: '재고 부족', value: '8', icon: <AlertTriangle className="text-amber-600" />, change: '-2' },
{ label: '재고 회전율', value: '85%', icon: <BarChart3 className="text-indigo-600" />, change: '+5%' },
];
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{stats.map((stat, i) => (
<Card key={i} className="p-4 border-slate-200 shadow-sm">
<div className="flex justify-between items-start">
<div className="p-2 bg-slate-50 rounded-lg">
{stat.icon}
</div>
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${stat.change.includes('+') ? 'bg-emerald-50 text-emerald-600' :
stat.change.includes('-') ? 'bg-red-50 text-red-600' : 'bg-slate-100 text-slate-600'
}`}>
{stat.change}
</span>
</div>
<div className="mt-4">
<h3 className="text-sm font-medium text-slate-500">{stat.label}</h3>
<p className="text-2xl font-bold text-slate-900 mt-1">{stat.value}</p>
</div>
</Card>
))}
</div>
<Card className="p-12 text-center border-dashed border-2 border-slate-200 bg-slate-50/50">
<Package size={48} className="mx-auto text-slate-300 mb-4" />
<h3 className="text-lg font-bold text-slate-800"> </h3>
<p className="text-slate-500 mt-2"> .</p>
</Card>
</div>
);
}

View File

@ -0,0 +1,19 @@
import { QualityOverviewPage } from './pages/QualityOverviewPage';
import { ClipboardCheck } from 'lucide-react';
import type { IModuleDefinition } from '../../core/types';
export const qualityModule: IModuleDefinition = {
moduleName: 'quality-management',
label: '품질 관리',
basePath: '/quality',
description: '공정 및 제품 품질 관리 모듈',
routes: [
{
path: '/overview',
element: <QualityOverviewPage />,
label: '품질 현황',
icon: <ClipboardCheck size={16} />
}
],
requiredRoles: ['admin', 'manager', 'user']
};

View File

@ -0,0 +1,49 @@
import { Card } from '../../../shared/ui/Card';
import { ClipboardCheck, ShieldAlert, CheckCircle2, Factory } from 'lucide-react';
export function QualityOverviewPage() {
const stats = [
{ label: '금일 검사수', value: '342', icon: <ClipboardCheck className="text-blue-600" />, status: '정상' },
{ label: '불량 발생', value: '3', icon: <ShieldAlert className="text-red-600" />, status: '-1.2%' },
{ label: '합격률', value: '99.1%', icon: <CheckCircle2 className="text-emerald-600" />, status: '+0.5%' },
{ label: '공정 능력(Cpk)', value: '1.67', icon: <Factory className="text-indigo-600" />, status: '안정' },
];
return (
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<div>
<h1 className="text-2xl font-bold text-slate-900"> </h1>
<p className="text-slate-500 mt-1"> .</p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
{stats.map((stat, i) => (
<Card key={i} className="p-4 border-slate-200 shadow-sm">
<div className="flex justify-between items-start">
<div className="p-2 bg-slate-50 rounded-lg">
{stat.icon}
</div>
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${stat.status.includes('+') ? 'bg-emerald-50 text-emerald-600' :
stat.status.includes('-') ? 'bg-red-50 text-red-600' : 'bg-blue-50 text-blue-600'
}`}>
{stat.status}
</span>
</div>
<div className="mt-4">
<h3 className="text-sm font-medium text-slate-500">{stat.label}</h3>
<p className="text-2xl font-bold text-slate-900 mt-1">{stat.value}</p>
</div>
</Card>
))}
</div>
<Card className="p-12 text-center border-dashed border-2 border-slate-200 bg-slate-50/50">
<ClipboardCheck size={48} className="mx-auto text-slate-300 mb-4" />
<h3 className="text-lg font-bold text-slate-800"> </h3>
<p className="text-slate-500 mt-2">// .</p>
</Card>
</div>
);
}

View File

@ -9,6 +9,8 @@ import { LoginPage } from '../pages/auth/LoginPage';
import { assetModule } from '../modules/asset/module'; import { assetModule } from '../modules/asset/module';
import { cctvModule } from '../modules/cctv/module'; import { cctvModule } from '../modules/cctv/module';
import { productionModule } from '../modules/production/module'; import { productionModule } from '../modules/production/module';
import { materialModule } from '../modules/material/module';
import { qualityModule } from '../modules/quality/module';
import ModuleLoader from './ModuleLoader'; import ModuleLoader from './ModuleLoader';
// Platform / System Pages // Platform / System Pages
@ -21,7 +23,13 @@ import { GeneralPreferencesPage } from './pages/GeneralPreferencesPage';
import './styles/global.css'; import './styles/global.css';
const modules = [assetModule, cctvModule, productionModule]; const modules = [
assetModule,
cctvModule,
productionModule,
materialModule,
qualityModule
];
const DefaultRedirect = () => { const DefaultRedirect = () => {
const { user } = useAuth(); const { user } = useAuth();
@ -50,18 +58,18 @@ export const App = () => {
{/* User Preferences (Accessible by everyone authenticated) */} {/* User Preferences (Accessible by everyone authenticated) */}
<Route path="/preferences" element={<GeneralPreferencesPage />} /> <Route path="/preferences" element={<GeneralPreferencesPage />} />
{/* Dynamic Module Routes */}
<Route path="/*" element={<ModuleLoader modules={modules} />} />
{/* Navigation Fallback within Layout */}
<Route index element={<DefaultRedirect />} />
{/* Platform Admin Routes (Could also be moved to an Admin module later) */} {/* Platform Admin Routes (Could also be moved to an Admin module later) */}
<Route path="/admin/users" element={<UserManagementPage />} /> <Route path="/admin/users" element={<UserManagementPage />} />
<Route path="/admin/settings" element={<BasicSettingsPage />} /> <Route path="/admin/settings" element={<BasicSettingsPage />} />
<Route path="/admin/license" element={<LicensePage />} /> <Route path="/admin/license" element={<LicensePage />} />
<Route path="/admin/version" element={<VersionPage />} /> <Route path="/admin/version" element={<VersionPage />} />
<Route path="/admin" element={<Navigate to="/admin/settings" replace />} /> <Route path="/admin" element={<Navigate to="/admin/settings" replace />} />
{/* Dynamic Module Routes */}
<Route path="/*" element={<ModuleLoader modules={modules} />} />
{/* Navigation Fallback within Layout */}
<Route index element={<DefaultRedirect />} />
</Route> </Route>
{/* Fallback */} {/* Fallback */}

View File

@ -17,6 +17,7 @@ interface UserFormData {
phone: string; phone: string;
role: 'supervisor' | 'admin' | 'user'; role: 'supervisor' | 'admin' | 'user';
session_timeout: number; session_timeout: number;
allowed_modules: string[];
} }
export function UserManagementPage() { export function UserManagementPage() {
@ -36,7 +37,8 @@ export function UserManagementPage() {
position: '', position: '',
phone: '', phone: '',
role: 'user', role: 'user',
session_timeout: 10 session_timeout: 10,
allowed_modules: ['asset', 'production', 'cctv']
}); });
useEffect(() => { useEffect(() => {
@ -49,7 +51,7 @@ export function UserManagementPage() {
setLoading(true); setLoading(true);
try { try {
const res = await apiClient.get('/users'); const res = await apiClient.get('/users');
setUsers(res.data); setUsers(Array.isArray(res.data) ? res.data : []);
} catch (error) { } catch (error) {
console.error('Failed to fetch users', error); console.error('Failed to fetch users', error);
alert('사용자 목록을 불러오지 못했습니다.'); alert('사용자 목록을 불러오지 못했습니다.');
@ -67,29 +69,33 @@ export function UserManagementPage() {
position: '', position: '',
phone: '', phone: '',
role: 'user', role: 'user',
session_timeout: 10 session_timeout: 10,
allowed_modules: ['asset', 'production', 'cctv']
}); });
setIsEditing(false); setIsEditing(false);
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleOpenEdit = (user: User) => { const handleOpenEdit = (user: any) => {
const u = user || {};
setFormData({ setFormData({
id: user.id, id: u.id || '',
password: '', password: '',
name: user.name, name: u.name || '',
department: user.department || '', department: u.department || '',
position: user.position || '', position: u.position || '',
phone: user.phone || '', phone: u.phone || '',
role: user.role, role: u.role || 'user',
session_timeout: (user as any).session_timeout || 10 session_timeout: u.session_timeout || 10,
allowed_modules: Array.isArray(u.allowed_modules) ? u.allowed_modules :
(typeof u.allowed_modules === 'string' && u.allowed_modules.startsWith('[') ? JSON.parse(u.allowed_modules) : ['asset', 'production', 'cctv'])
}); });
setIsEditing(true); setIsEditing(true);
setIsModalOpen(true); setIsModalOpen(true);
}; };
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
if (!confirm('정말 이 사용자를 삭제하시겠습니까?')) return; if (!id || !confirm('정말 이 사용자를 삭제하시겠습니까?')) return;
try { try {
await apiClient.delete(`/users/${id}`); await apiClient.delete(`/users/${id}`);
fetchUsers(); fetchUsers();
@ -106,6 +112,18 @@ export function UserManagementPage() {
else return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`; else return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
}; };
const handleModuleToggle = (moduleCode: string) => {
setFormData(prev => {
const currentModules = Array.isArray(prev.allowed_modules) ? prev.allowed_modules : [];
const isSelected = currentModules.includes(moduleCode);
if (isSelected) {
return { ...prev, allowed_modules: currentModules.filter(m => m !== moduleCode) };
} else {
return { ...prev, allowed_modules: [...currentModules, moduleCode] };
}
});
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
try { try {
@ -170,6 +188,7 @@ export function UserManagementPage() {
<th className="px-6 py-4"> / </th> <th className="px-6 py-4"> / </th>
<th className="px-6 py-4"></th> <th className="px-6 py-4"></th>
<th className="px-6 py-4"> / </th> <th className="px-6 py-4"> / </th>
<th className="px-6 py-4"> </th>
<th className="px-6 py-4"> ()</th> <th className="px-6 py-4"> ()</th>
<th className="px-6 py-4"></th> <th className="px-6 py-4"></th>
<th className="px-6 py-4"> </th> <th className="px-6 py-4"> </th>
@ -179,46 +198,91 @@ export function UserManagementPage() {
<tbody className="divide-y divide-slate-100 bg-white"> <tbody className="divide-y divide-slate-100 bg-white">
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-400"> <td colSpan={8} className="px-6 py-12 text-center text-slate-400">
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<RefreshCcw size={24} className="animate-spin text-indigo-500" /> <RefreshCcw size={24} className="animate-spin text-indigo-500" />
<span> ...</span> <span> ...</span>
</div> </div>
</td> </td>
</tr> </tr>
) : users.map((user) => ( ) : (Array.isArray(users) ? users : []).map((u: any) => {
<tr key={user.id} className="hover:bg-slate-50/50 transition-colors"> if (!u || !u.id) return null;
<td className="px-6 py-4">
<div className="font-bold text-slate-900">{user.id}</div> // Defensive parsing for modules
<div className="mt-1">{getRoleBadge(user.role)}</div> let userModules: string[] = [];
</td> try {
<td className="px-6 py-4 font-semibold text-slate-800">{user.name}</td> const rawMods = u.allowed_modules;
<td className="px-6 py-4"> if (Array.isArray(rawMods)) {
<div className="text-slate-900 font-bold">{user.department || '-'}</div> userModules = rawMods.filter(m => typeof m === 'string');
<div className="text-slate-600 text-[11px] font-medium">{user.position}</div> } else if (typeof rawMods === 'string' && rawMods.trim().startsWith('[')) {
</td> const parsed = JSON.parse(rawMods);
<td className="px-6 py-4"> if (Array.isArray(parsed)) {
<span className="text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded ring-1 ring-indigo-100">{(user as any).session_timeout || 10}</span> userModules = parsed.filter(m => typeof m === 'string');
</td> }
<td className="px-6 py-4 text-slate-700 font-medium">{user.phone || '-'}</td> }
<td className="px-6 py-4 text-slate-400 text-[11px]"> } catch (e) {
{user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'} userModules = [];
</td> }
<td className="px-6 py-4">
<div className="flex justify-center gap-1"> // Date formatting
<button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(user)}> let loginStr = '미접속';
<Edit2 size={16} /> try {
</button> if (u.last_login) {
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(user.id)}> const d = new Date(u.last_login);
<Trash2 size={16} /> if (!isNaN(d.getTime()) && d.getFullYear() > 1970) {
</button> loginStr = d.toLocaleString();
</div> }
</td> }
</tr> } catch (e) {
))} loginStr = '날짜오류';
{!loading && users.length === 0 && ( }
return (
<tr key={u.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-6 py-4">
<div className="font-bold text-slate-900">{u.id}</div>
<div className="mt-1">{getRoleBadge(u.role || 'user')}</div>
</td>
<td className="px-6 py-4 font-semibold text-slate-800">{u.name || '-'}</td>
<td className="px-6 py-4">
<div className="text-slate-900 font-bold">{u.department || '-'}</div>
<div className="text-slate-600 text-[11px] font-medium">{u.position || '-'}</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-wrap gap-1">
{userModules.map((m, mIdx) => (
<span key={`${u.id}-${m}-${mIdx}`} className="px-1.5 py-0.5 bg-slate-100 text-slate-600 text-[10px] rounded border border-slate-200 font-medium">
{m === 'asset' ? '자산' : m === 'production' ? '생산' : m === 'cctv' ? 'CCTV' : m}
</span>
))}
{userModules.length === 0 && (
<span className="text-slate-300 text-[10px]"> </span>
)}
</div>
</td>
<td className="px-6 py-4">
<span className="text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded ring-1 ring-indigo-100">{u.session_timeout || 10}</span>
</td>
<td className="px-6 py-4 text-slate-700 font-medium">{u.phone || '-'}</td>
<td className="px-6 py-4 text-slate-400 text-[11px]">
{loginStr}
</td>
<td className="px-6 py-4">
<div className="flex justify-center gap-1">
<button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(u)}>
<Edit2 size={16} />
</button>
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(u.id)}>
<Trash2 size={16} />
</button>
</div>
</td>
</tr>
);
})}
{!loading && (!Array.isArray(users) || users.length === 0) && (
<tr> <tr>
<td colSpan={6} className="px-6 py-12 text-center text-slate-400"> .</td> <td colSpan={8} className="px-6 py-12 text-center text-slate-400"> .</td>
</tr> </tr>
)} )}
</tbody> </tbody>
@ -229,7 +293,7 @@ export function UserManagementPage() {
{/* Modal */} {/* Modal */}
{isModalOpen && ( {isModalOpen && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200"> <div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
<Card className="w-full max-w-md shadow-2xl border-slate-200 overflow-hidden"> <Card className="w-full max-w-lg shadow-2xl border-slate-200 overflow-hidden">
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50"> <div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<h2 className="text-lg font-bold text-slate-800"> <h2 className="text-lg font-bold text-slate-800">
{isEditing ? '사용자 정보 수정' : '새 사용자 등록'} {isEditing ? '사용자 정보 수정' : '새 사용자 등록'}
@ -239,36 +303,37 @@ export function UserManagementPage() {
</button> </button>
</div> </div>
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off"> <form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
<div> <div className="grid grid-cols-2 gap-4">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> <span className="text-red-500">*</span></label> <div>
<Input <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> <span className="text-red-500">*</span></label>
name="user_id_new" <Input
autoComplete="off" name="user_id_new"
readOnly={!isEditing} autoComplete="off"
onFocus={(e) => e.target.readOnly = false} readOnly={!isEditing}
value={formData.id} onFocus={(e) => e.target.readOnly = false}
onChange={(e) => setFormData({ ...formData, id: e.target.value })} value={formData.id}
disabled={isEditing} onChange={(e) => setFormData({ ...formData, id: e.target.value })}
placeholder="로그인 아이디 입력" disabled={isEditing}
required placeholder="로그인 아이디 입력"
/> required
</div> />
</div>
<div> <div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">
<span className="text-red-500">{!isEditing && '*'}</span> <span className="text-red-500">{!isEditing && '*'}</span>
</label> </label>
<Input <Input
name="user_password_new" name="user_password_new"
autoComplete="new-password" autoComplete="new-password"
readOnly={!isEditing} readOnly={!isEditing}
onFocus={(e) => e.target.readOnly = false} onFocus={(e) => e.target.readOnly = false}
type="password" type="password"
value={formData.password} value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })} onChange={(e) => setFormData({ ...formData, password: e.target.value })}
placeholder={isEditing ? "(변경시에만 입력)" : "초기 비밀번호 입력"} placeholder={isEditing ? "(변경시에만 입력)" : "초기 비밀번호 입력"}
required={!isEditing} required={!isEditing}
/> />
</div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -280,7 +345,7 @@ export function UserManagementPage() {
required required
/> />
</div> </div>
<div> <div className="space-y-1">
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"></label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"></label>
<select <select
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all outline-none" className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all outline-none"
@ -320,6 +385,31 @@ export function UserManagementPage() {
</div> </div>
</div> </div>
<div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> </label>
<div className="grid grid-cols-3 gap-2 mt-2">
{[
{ code: 'asset', label: '자산 관리' },
{ code: 'production', label: '생산 관리' },
{ code: 'cctv', label: 'CCTV 관제' }
].map(mod => {
const currentModules = Array.isArray(formData.allowed_modules) ? formData.allowed_modules : [];
const isChecked = currentModules.includes(mod.code);
return (
<label key={mod.code} className={`flex items-center gap-2 p-2 border rounded-lg cursor-pointer transition-all ${isChecked ? 'bg-indigo-50 border-indigo-200 text-indigo-700' : 'bg-slate-50 border-slate-200 text-slate-500'}`}>
<input
type="checkbox"
className="w-4 h-4 rounded text-indigo-600 focus:ring-indigo-500"
checked={isChecked}
onChange={() => handleModuleToggle(mod.code)}
/>
<span className="text-sm font-medium">{mod.label}</span>
</label>
);
})}
</div>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> </label> <label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5"> </label>

View File

@ -9,6 +9,7 @@ export interface User {
position?: string; position?: string;
phone?: string; phone?: string;
session_timeout?: number; session_timeout?: number;
allowed_modules?: string[];
last_login?: string; last_login?: string;
} }

View File

@ -25,11 +25,10 @@ export function SystemProvider({ children }: { children: ReactNode }) {
const refreshModules = async () => { const refreshModules = async () => {
try { try {
const response = await apiClient.get('/system/modules'); const response = await apiClient.get('/system/modules');
console.log('[SystemContext] Modules from server:', response.data.modules);
if (response.data.modules) { if (response.data.modules) {
setModules(response.data.modules); setModules(response.data.modules);
} else { } else {
// Fallback or assume old format (unsafe with new backend)
// Better to assume new format based on my changes
setModules(response.data.modules || response.data); setModules(response.data.modules || response.data);
} }
} catch (error) { } catch (error) {

View File

@ -0,0 +1,49 @@
import { Construction, ArrowRight } from 'lucide-react';
interface PlaceholderPageProps {
title: string;
description: string;
}
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] p-8 text-center animate-in fade-in slide-in-from-bottom-4 duration-700">
<div className="w-20 h-20 bg-blue-50 rounded-3xl flex items-center justify-center mb-6 shadow-sm border border-blue-100">
<Construction className="text-blue-600 w-10 h-10" />
</div>
<h1 className="text-3xl font-extrabold text-slate-800 mb-4 tracking-tight">
{title}
</h1>
<p className="text-slate-500 max-w-md text-lg leading-relaxed mb-8">
{description}
</p>
<div className="bg-slate-50 border border-slate-200 rounded-2xl p-6 max-w-lg w-full">
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest mb-4"> </h3>
<div className="space-y-3">
<div className="flex items-center gap-3 text-sm text-slate-600">
<div className="w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]"></div>
<span> </span>
</div>
<div className="flex items-center gap-3 text-sm text-slate-400">
<div className="w-2 h-2 rounded-full bg-slate-300"></div>
<span> </span>
</div>
<div className="flex items-center gap-3 text-sm text-slate-400">
<div className="w-2 h-2 rounded-full bg-slate-300"></div>
<span> </span>
</div>
</div>
</div>
<button
onClick={() => window.history.back()}
className="mt-10 flex items-center gap-2 text-blue-600 font-bold hover:gap-3 transition-all group"
>
<ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
</button>
</div>
);
}

View File

@ -1,9 +1,10 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useSystem } from '../../shared/context/SystemContext'; import { useSystem } from '../../shared/context/SystemContext';
import { apiClient } from '../../shared/api/client'; import { apiClient } from '../../shared/api/client';
import { Check, X, Key, Shield, AlertTriangle, Terminal, Printer } from 'lucide-react'; import { Check, X, Key, Shield, AlertTriangle, Terminal, Printer } from 'lucide-react';
export function LicensePage() { export function LicensePage() {
// Force Update Check v0.4.3.4
const { modules, refreshModules } = useSystem(); const { modules, refreshModules } = useSystem();
const [selectedModule, setSelectedModule] = useState<string | null>(null); const [selectedModule, setSelectedModule] = useState<string | null>(null);
const [licenseKey, setLicenseKey] = useState(''); const [licenseKey, setLicenseKey] = useState('');
@ -13,7 +14,9 @@ export function LicensePage() {
const moduleInfo: Record<string, { title: string, desc: string }> = { const moduleInfo: Record<string, { title: string, desc: string }> = {
'asset': { title: '자산 관리 모듈', desc: '자산 등록, 조회, 수정 및 유지보수 이력 관리' }, 'asset': { title: '자산 관리 모듈', desc: '자산 등록, 조회, 수정 및 유지보수 이력 관리' },
'production': { title: '생산 관리 모듈', desc: '생산 계획, 실적 및 공정 관리' }, 'production': { title: '생산 관리 모듈', desc: '생산 계획, 실적 및 공정 관리' },
'cctv': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' } 'cctv': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' },
'material': { title: '자재/재고 관리 모듈', desc: '원자재 입출고 및 실시간 재고 현황 모니터링' },
'quality': { title: '품질 관리 모듈', desc: '제품 품질 검사 및 통계 관리' }
}; };
// Subscriber Configuration State // Subscriber Configuration State
@ -29,9 +32,9 @@ export function LicensePage() {
const [verifyError, setVerifyError] = useState(''); const [verifyError, setVerifyError] = useState('');
// Initial Load for Subscriber ID // Initial Load for Subscriber ID
useState(() => { useEffect(() => {
fetchSubscriberId(); fetchSubscriberId();
}); }, []);
async function fetchSubscriberId() { async function fetchSubscriberId() {
try { try {

View File

@ -2,7 +2,7 @@ import { useState } from 'react';
import { Outlet, Link, useLocation } from 'react-router-dom'; import { Outlet, Link, useLocation } from 'react-router-dom';
import { useAuth } from '../../shared/auth/AuthContext'; import { useAuth } from '../../shared/auth/AuthContext';
import { useSystem } from '../../shared/context/SystemContext'; import { useSystem } from '../../shared/context/SystemContext';
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info, Home, UserCog } from 'lucide-react'; import { Settings, LogOut, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info, UserCog, Box, Package, ClipboardCheck } from 'lucide-react';
import type { IModuleDefinition } from '../../core/types'; import type { IModuleDefinition } from '../../core/types';
import './MainLayout.css'; import './MainLayout.css';
@ -11,12 +11,21 @@ interface MainLayoutProps {
} }
export function MainLayout({ modulesList }: MainLayoutProps) { export function MainLayout({ modulesList }: MainLayoutProps) {
// Force Update Check v0.4.3.4
const location = useLocation(); const location = useLocation();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { modules } = useSystem(); const { modules } = useSystem();
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']); const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management', 'standard-info', 'settings-group']);
const isAdmin = user ? (['admin', 'supervisor'] as string[]).includes(user.role) : false; const isAdmin = user ? (['admin', 'supervisor'] as string[]).includes(user.role) : false;
const isSupervisor = user?.role === 'supervisor';
const checkModulePermission = (moduleKey: string) => {
// Supervisor and Admin always have access to see active modules
if (isSupervisor || isAdmin) return true;
// Check allowed_modules list for other roles (manager, user, etc.)
return user?.allowed_modules?.includes(moduleKey);
};
const checkRole = (requiredRoles: string[]) => { const checkRole = (requiredRoles: string[]) => {
if (!user) return false; if (!user) return false;
@ -39,26 +48,19 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
<div className="layout-container"> <div className="layout-container">
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-header"> <div className="sidebar-header">
<div className="brand"> <Link to="/home" className="brand" style={{ textDecoration: 'none' }}>
<Box className="brand-icon" size={24} /> <Box className="brand-icon" size={24} />
<span className="brand-text">Smart IMS</span> <span className="brand-text">Smart IMS</span>
</div> </Link>
</div> </div>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
{/* Platform Home */}
<div className="module-group mb-4">
<Link to="/home" className={`nav-item ${location.pathname === '/home' ? 'active' : ''}`}>
<Home size={18} />
<span> </span>
</Link>
</div>
{/* System Management (Platform Core) */} {/* System Management (Platform Core) */}
{isAdmin && ( {isAdmin && (
<div className="module-group"> <div className="module-group">
<button <button
className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`} className="module-header" // Removed active class based on expanded state
onClick={() => toggleModule('sys_mgmt')} onClick={() => toggleModule('sys_mgmt')}
> >
<div className="module-title"> <div className="module-title">
@ -91,115 +93,175 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
</div> </div>
)} )}
{/* Dynamic Modules Injection */} {/* 1. Asset Management (자산관리) - Promoted to main section as requested */}
{modulesList.map((mod) => { {modulesList.filter(m => m.moduleName === 'asset-management').map((mod) => {
const moduleKey = mod.moduleName.split('-')[0]; const moduleKey = mod.moduleName.split('-')[0];
if (!isModuleActive(moduleKey)) return null; if (!isModuleActive(moduleKey) || !checkModulePermission(moduleKey)) return null;
// Check roles with hierarchy
if (mod.requiredRoles && !checkRole(mod.requiredRoles)) return null;
const hasSubMenu = mod.routes.filter(r => r.label).length > 1;
const isExpanded = expandedModules.includes(mod.moduleName); const isExpanded = expandedModules.includes(mod.moduleName);
// Group routes by 'group' property
const groups: Record<string, typeof mod.routes> = {};
const ungrouped: typeof mod.routes = [];
mod.routes.filter(r => r.label && (!r.position || r.position === 'sidebar')).forEach(r => {
if (r.group) {
if (!groups[r.group]) groups[r.group] = [];
groups[r.group].push(r);
} else {
ungrouped.push(r);
}
});
return ( return (
<div key={mod.moduleName} className="module-group"> <div key={mod.moduleName} className="module-group">
{hasSubMenu ? ( <button
<> className={`module-header ${isExpanded ? 'bg-white/5' : ''}`}
<button onClick={() => toggleModule(mod.moduleName)}
className={`module-header ${isExpanded ? 'active' : ''}`} >
onClick={() => toggleModule(mod.moduleName)} <div className="module-title">
> <Layers size={18} />
<div className="module-title"> <span> </span>
{mod.moduleName === 'cctv' ? <Video size={18} /> : <Layers size={18} />} </div>
<span>{mod.label || (mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1) + ' 관리')}</span> {isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div> </button>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
{isExpanded && (
<div className="module-items">
{(() => {
const groupedRoutes: Record<string, typeof mod.routes> = {};
const ungroupedRoutes: typeof mod.routes = [];
mod.routes.filter(r => { {isExpanded && (
const isLabelVisible = !!r.label; <div className="module-items">
const isSidebarPosition = !r.position || r.position === 'sidebar'; {/* Direct links (e.g. Dashboard) */}
const isSettingsRoute = r.path.includes('settings'); {ungrouped.map(route => (
const hasPermission = user?.role !== 'user' || !isSettingsRoute; <Link
key={`${mod.moduleName}-${route.path}`}
to={`${mod.basePath}${route.path}`}
className={`nav-item ${location.pathname === `${mod.basePath}${route.path}` ? 'active' : ''}`}
>
<span>{route.label}</span>
</Link>
))}
return isLabelVisible && isSidebarPosition && hasPermission; {/* Grouped links (기준정보, 설정) */}
}).forEach(route => { {Object.entries(groups).map(([groupName, routes]) => {
if (route.group) { const groupKey = `${mod.moduleName}-group-${groupName}`;
if (!groupedRoutes[route.group]) groupedRoutes[route.group] = []; const isGroupExpanded = expandedModules.includes(groupKey);
groupedRoutes[route.group].push(route);
} else {
ungroupedRoutes.push(route);
}
});
return ( return (
<> <div key={groupName} className="nested-module-group pl-2 mt-1">
{ungroupedRoutes.map(route => ( <button
<Link className={`nav-item w-full flex justify-between items-center ${isGroupExpanded ? 'bg-slate-800/40' : ''}`}
key={`${mod.moduleName}-${route.path}`} onClick={() => toggleModule(groupKey)}
to={`${mod.basePath}${route.path}`} >
className={`nav-item ${location.pathname.startsWith(`${mod.basePath}${route.path}`) ? 'active' : ''}`} <div className="flex items-center gap-2">
> <span>{groupName}</span>
{route.icon || <Box size={18} />} </div>
<span>{route.label}</span> {isGroupExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</Link> </button>
))}
{Object.entries(groupedRoutes).map(([groupName, routes]) => ( {isGroupExpanded && (
<div key={groupName} className="menu-group-section"> <div className="pl-4 mt-1 border-l border-slate-700 ml-4 flex flex-col gap-1">
<div className="menu-group-label text-xs font-bold text-slate-500 uppercase px-3 py-2 mt-2 mb-1"> {routes.map(route => {
{groupName} const isSettingsRoute = route.path.includes('settings');
</div> const hasPermission = user?.role !== 'user' || !isSettingsRoute;
{routes.map(route => ( if (!hasPermission) return null;
<Link
key={`${mod.moduleName}-${route.path}`} return (
to={`${mod.basePath}${route.path}`} <Link
className={`nav-item ${location.pathname.startsWith(`${mod.basePath}${route.path}`) ? 'active' : ''}`} key={`${mod.moduleName}-${route.path}`}
> to={`${mod.basePath}${route.path}`}
{route.icon || <Box size={18} />} className={`nav-item py-1.5 text-xs ${location.pathname === `${mod.basePath}${route.path}` ? 'active' : ''}`}
<span>{route.label}</span> >
</Link> <span>{route.label}</span>
))} </Link>
</div> );
))} })}
</> </div>
); )}
})()} </div>
</div> );
)} })}
</> </div>
) : (
<Link
to={`${mod.basePath}${mod.routes[0]?.path || ''}`}
className={`module-header ${location.pathname.startsWith(mod.basePath) ? 'active' : ''}`}
style={{ textDecoration: 'none' }}
>
<div className="module-title">
<Video size={18} />
<span>{mod.label || mod.moduleName.split('-')[0].toUpperCase()}</span>
</div>
</Link>
)} )}
</div> </div>
); );
})} })}
<div className="sidebar-divider my-2 border-t border-slate-100 opacity-10"></div>
{/* Other Modules (CCTV, Production, Material, etc.) - Promoted to top level */}
{modulesList
.filter(m => m.moduleName !== 'asset-management')
.sort((a, b) => {
const order: Record<string, number> = {
'production-management': 1,
'material-management': 2,
'quality-management': 3,
'cctv': 4
};
return (order[a.moduleName] || 99) - (order[b.moduleName] || 99);
})
.map((mod) => {
const moduleKey = mod.moduleName.split('-')[0];
const active = isModuleActive(moduleKey);
const permitted = checkModulePermission(moduleKey);
console.log(`[Sidebar] Module: ${mod.moduleName}, Key: ${moduleKey}, Active: ${active}, Permitted: ${permitted}`);
if (!active || !permitted) return null;
if (mod.requiredRoles && !checkRole(mod.requiredRoles)) return null;
const isExpanded = expandedModules.includes(mod.moduleName);
let Icon = Layers;
if (mod.moduleName === 'cctv') Icon = Video;
if (mod.moduleName === 'material-management') Icon = Package;
if (mod.moduleName === 'production-management') Icon = Box;
if (mod.moduleName === 'quality-management') Icon = ClipboardCheck;
const label = mod.label || (
mod.moduleName === 'cctv' ? 'CCTV 모듈' :
mod.moduleName === 'production-management' ? '생산관리' :
mod.moduleName === 'material-management' ? '자재/재고 관리' :
mod.moduleName === 'quality-management' ? '품질 관리' : mod.moduleName
);
return (
<div key={mod.moduleName} className="module-group">
<button
className={`module-header ${isExpanded ? 'bg-white/5' : ''}`}
onClick={() => toggleModule(mod.moduleName)}
>
<div className="module-title">
<Icon size={18} />
<span>{label}</span>
</div>
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</button>
{isExpanded && (
<div className="module-items">
{mod.routes.filter(r => r.label && (!r.position || r.position === 'sidebar')).map(route => (
<Link
key={`${mod.moduleName}-${route.path}`}
to={`${mod.basePath}${route.path}`}
className={`nav-item ${location.pathname === `${mod.basePath}${route.path}` ? 'active' : ''}`}
>
<span>{route.label}</span>
</Link>
))}
</div>
)}
</div>
);
})}
<div className="sidebar-divider my-4 border-t border-slate-100 opacity-50"></div> <div className="sidebar-divider my-4 border-t border-slate-100 opacity-50"></div>
{/* User Preferences - Accessible to all roles */} {/* User Preferences - Accessible to all roles */}
<div className="module-group"> <div className="module-group">
<Link to="/preferences" className={`nav-item ${location.pathname === '/preferences' ? 'active' : ''}`}> <Link to="/preferences" className={`nav-item ${location.pathname === '/preferences' ? 'active' : ''}`}>
<UserCog size={18} /> <UserCog size={18} />
<span> </span> <span> </span>
</Link> </Link>
</div> </div>
</nav> </nav >
<div className="sidebar-footer"> <div className="sidebar-footer">
<div className="user-info"> <div className="user-info">
@ -215,7 +277,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
<LogOut size={18} /> <LogOut size={18} />
</button> </button>
</div> </div>
</aside> </aside >
<main className="main-content"> <main className="main-content">
<header className="top-header"> <header className="top-header">
@ -268,6 +330,6 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
<Outlet /> <Outlet />
</div> </div>
</main> </main>
</div> </div >
); );
} }

View File

@ -111,7 +111,7 @@ Usage: node tools/license_manager.cjs <command> [args]
Commands: Commands:
list Show active modules in Database list Show active modules in Database
generate <module> <type> Generate a new license key generate <module> <type> Generate a new license key
Modules: asset, production, monitoring Modules: asset, production, material, quality, cctv
Types: dev, sub, demo Types: dev, sub, demo
Flags: --subscriber <id> (REQUIRED) Flags: --subscriber <id> (REQUIRED)
[--activate] [--activate]
@ -134,7 +134,7 @@ async function listModules() {
console.log('| Code | Active | Type | Expiry | Subscriber |'); console.log('| Code | Active | Type | Expiry | Subscriber |');
console.log('---------------------------------------------------------------------------------------'); console.log('---------------------------------------------------------------------------------------');
const defaults = ['asset', 'production', 'monitoring']; const defaults = ['asset', 'production', 'cctv', 'material', 'quality'];
const data = {}; const data = {};
rows.forEach(r => data[r.code] = r); rows.forEach(r => data[r.code] = r);
@ -185,7 +185,7 @@ async function showCmd(identifier) {
const conn = await mysql.createConnection(dbConfig); const conn = await mysql.createConnection(dbConfig);
try { try {
// Check if identifier is a known module code // Check if identifier is a known module code
const validModules = ['asset', 'production', 'monitoring']; const validModules = ['asset', 'production', 'cctv', 'material', 'quality'];
const isModule = validModules.includes(identifier) || validModules.includes(identifier.toLowerCase()); const isModule = validModules.includes(identifier) || validModules.includes(identifier.toLowerCase());
if (isModule) { if (isModule) {
@ -253,10 +253,9 @@ async function generateCmd(moduleCode, type) {
} }
// ... Validation (Same as before) ... // ... Validation (Same as before) ...
const validModules = ['asset', 'production', 'monitoring']; const validModules = ['asset', 'production', 'cctv', 'material', 'quality'];
const validTypes = ['dev', 'sub', 'demo']; const validTypes = ['dev', 'sub', 'demo'];
const modMap = { 'cctv': 'monitoring' }; const finalCode = moduleCode;
const finalCode = modMap[moduleCode] || moduleCode;
if (!validModules.includes(finalCode)) { if (!validModules.includes(finalCode)) {
console.error(`Invalid module. Allowed: ${validModules.join(', ')}`); console.error(`Invalid module. Allowed: ${validModules.join(', ')}`);
@ -422,7 +421,13 @@ async function activateCmd(key) {
} }
// 4. Activate New License // 4. Activate New License
const names = { 'asset': '자산 관리', 'production': '생산 관리', 'monitoring': 'CCTV' }; const names = {
'asset': '자산 관리',
'production': '생산 관리',
'cctv': 'CCTV',
'material': '자재/재고 관리',
'quality': '품질 관리'
};
const sql = ` const sql = `
INSERT INTO system_modules (code, name, is_active, license_key, license_type, expiry_date, subscriber_id) INSERT INTO system_modules (code, name, is_active, license_key, license_type, expiry_date, subscriber_id)
VALUES (?, ?, true, ?, ?, ?, ?) VALUES (?, ?, true, ?, ?, ?, ?)
@ -452,7 +457,7 @@ async function deleteCmd(moduleCode) {
return; return;
} }
const validModules = ['asset', 'production', 'monitoring']; const validModules = ['asset', 'production', 'cctv', 'material', 'quality'];
if (!validModules.includes(moduleCode)) { if (!validModules.includes(moduleCode)) {
console.error(`Invalid module. Allowed: ${validModules.join(', ')}`); console.error(`Invalid module. Allowed: ${validModules.join(', ')}`);
return; return;

39
update_system.bat Normal file
View File

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