Compare commits
No commits in common. "main" and "v0.4.2.2" have entirely different histories.
6
.gitignore
vendored
6
.gitignore
vendored
@ -40,12 +40,6 @@ Desktop.ini
|
||||
|
||||
# Project Specific - Server
|
||||
server/.env
|
||||
server/.env.local
|
||||
server/.env.backup*
|
||||
backup/
|
||||
*.backup*
|
||||
server/*.tmp
|
||||
server/backups/
|
||||
server/uploads/*
|
||||
!server/uploads/.gitkeep
|
||||
server/server.zip
|
||||
|
||||
@ -2,8 +2,8 @@
|
||||
|
||||
**프로젝트명:** SOKUREE Platform - Smart Integrated Management System
|
||||
**최초 작성일:** 2026-01-25
|
||||
**최근 업데이트:** 2026-01-26
|
||||
**버전:** v0.4.4.0
|
||||
**최근 업데이트:** 2026-01-25
|
||||
**버전:** v0.4.1.0
|
||||
|
||||
---
|
||||
|
||||
@ -17,7 +17,7 @@
|
||||
### 🟦 Phase 1: 기반 강화 및 보안 고도화 (Platform Stability)
|
||||
**목표:** 플랫폼의 안정성 확보 및 사용자 중심의 보안/관리 체계 구축
|
||||
|
||||
#### 🏷️ Tag: `v0.4.1.0`
|
||||
#### 🏷️ Tag: `v0.4.1.0` (완료)
|
||||
- [x] **세션 관리 엔진 최적화**
|
||||
- 세션 자동 로그아웃 시간 합산 오류 수정 (시간+분 단위 정상 동작 확인)
|
||||
- 활동 부재 시 자동 로그아웃 잔여 시간 추적 로그 시스템 도입
|
||||
@ -35,75 +35,35 @@
|
||||
- [x] CCTV 설정 파일 DB 저장 시 username/password 필드 암호화 처리
|
||||
- [x] CCTV 설정 파일 조회 시 username/password 필드 복호화 처리
|
||||
- **(수정사항)**: `CameraManagementPage` 신설 및 `cryptoUtil`을 통한 RTSP 계정 정보의 DB 암호화 저장 로직 완성 (AES-256)
|
||||
#### 🏷️ Tag: `v0.4.2.9`
|
||||
- [x] **라이선스 관리 오류 수정**
|
||||
- 모듈 활성화 상태 체크 미들웨어(`requireModule`) 보강 및 라이선스 만료 감지 로직 안정화
|
||||
- [x] **시스템 관리 기본설정 오류 수정**
|
||||
- 'monitoring' → 'cctv' 모듈 코드 마이그레이션 시 발생하는 중복 키 오류(`ER_DUP_ENTRY`) 완벽 해결
|
||||
- [x] **시스템 업데이트 엔진 고도화 & 인프라 보호 (Shovel-work/삽질 기록)**
|
||||
- **인프라 보호 시스템(Infrastructure Shield)**: `.env.local` 우선순위 도입으로 업데이트 시 운영 서버 설정이 초기화되는 문제 근본 해결
|
||||
- **실시간 버전 동기화**: 정적 파일 대신 Git Tag를 직접 조회하도록 개선하여 업데이트 후 현재 버전 즉시 반영
|
||||
- **업데이트 안정성**: 소스 교체 전 `.env`/`.env.local` 2중 백업 및 복구 시퀀스 적용
|
||||
- **서버 가용성**: Synology NAS 등 저사양 환경에서의 블로킹 방지를 위한 `execSync` 차단 및 에러 핸들링 강화
|
||||
|
||||
#### 🏷️ Tag: `v0.4.2.11`
|
||||
- [x] **CCTV 모니터링 UI 리뉴얼**
|
||||
- 페이지 배경색을 플랫폼 표준인 연한 그레이(`var(--sokuree-bg-main)`)로 변경하여 일체감 형성
|
||||
- 카메라 슬롯을 독립적인 카드(Card) 형태로 디자인하고 둥근 모서리(`rounded-xl`) 및 그림자(`shadow-sm`) 적용
|
||||
- 고정된 검은색 배경 대신 브랜드 컬러인 다크 슬레이트(`var(--sokuree-brand-secondary)`) 배경 적용
|
||||
- 빈 슬롯 디자인을 깔끔한 화이트 카드 형태로 개선하여 시각적 피로도 감소
|
||||
#### 🏷️ Tag: `v0.4.2.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.0`
|
||||
- [ ] **소모품 관리**
|
||||
- [ ] 이 기능은 자재/재고 관리 모듈 생성하여 메뉴가 아니라 속성(카테고리)으로 추가할 예정
|
||||
|
||||
#### 🏷️ Tag: `v0.4.3.1` (완료)
|
||||
- [x] **모듈 통합 및 구조 최적화**
|
||||
- [x] '자재 관리'와 '재고 관리' 모듈을 `material` 모듈로 통합 (`자재/재고 관리`)
|
||||
- [x] 중복되는 `inventory` 모듈을 제거하고 비즈니스 로직을 `material`로 흡수하여 관리 효율성 증대
|
||||
- [x] **스마트 관리 모듈 라인업 확장**
|
||||
- [x] 품질 관리 모듈(`quality`) 신규 구축 및 핵심 지표(불량률, 합격률, Cpk 등) 시각화 대시보드 구현
|
||||
- [x] **라이선스 및 시스템 설정 고도화**
|
||||
- [x] 라이선스 관리 화면(`LicensePage`)에 통합된 '자재/재고' 및 '품질 관리' 모듈 즉시 연동
|
||||
- [x] 서버 기동 시 DB(`system_modules`) 내 모듈 정보 자동 동기화 및 전역 권한 설정 최적화
|
||||
- [x] **플랫폼 신뢰성 강화 (Runtime Stability)**
|
||||
- [x] 사용자 관리 페이지의 데이터 가드 로직 강화로 불완전한 데이터 수신 시 발생하던 런타임 오류(흰 화면) 해결
|
||||
- [x] **라이선스 발급 표준 정의**
|
||||
- [x] 발급 관리 프로그램 연동을 위한 모듈별 고유 코드(`asset`, `production`, `material`, `quality`, `cctv`) 및 필수 규격 확정
|
||||
|
||||
#### 🏷️ Tag: `v0.4.3.5` (완료)
|
||||
- [x] **버전 관리 표준 확립**
|
||||
- MAJOR.MINOR.PATCH.BUILD 4단계 체계 도입 및 문서화 (`version manage.md`)
|
||||
- 배포 표준 절차(Standard Deployment Procedure) 정의
|
||||
- [x] **시스템 안정성 및 빌드 최적화**
|
||||
- 빌드 시 정적 자산 강제 업데이트 스크립트 보강
|
||||
- 미사용 변수 및 린트 에러 수정을 통한 빌드 실패 방지
|
||||
- 버전 정보 동기화 로직 최적화 (package.json 기반)
|
||||
|
||||
#### 🏷️ Tag: `v0.4.4.2` (완료)
|
||||
- [x] **모듈 활성화 독립성 및 마이그레이션 신뢰성 강화**
|
||||
- `inventory` → `material` 데이터 보존 마이그레이션 로직 정교화 (기존 라이선스 우선)
|
||||
- 사용자 권한(`allowed_modules`) JSON 마이그레이션 시 `NULL` 값 처리 예외 대응
|
||||
- 모듈 활성화 API 로그 보강 및 프론트엔드(`LicensePage`) 생명주기(useEffect) 최적화
|
||||
- [x] **모듈 카테고리 동적 생성 기능**
|
||||
- 모듈 정의(`IModuleDefinition`) 시 카테고리를 하드코딩하지 않고 직접 문자열로 정의 가능하도록 개선
|
||||
- [x] **UI 시각화 및 사용성 개선**
|
||||
- 사이드바 메뉴 선택 시 하이라이트 색상을 브랜드 테마(`var(--sokuree-brand-primary)`)와 일치시켜 시각적 일관성 확보
|
||||
- [x] **통합 로드맵 및 문서 현행화**
|
||||
- `INTEGRATED_ROADMAP.md` 업데이트 및 버전 관리 규정 반영
|
||||
- [ ] **부속설비 등록**
|
||||
- [ ] 자산 등록 화면에서 부속설비 등록 버튼 클릭 시 자산 등록 화면으로 이동하도록 구현
|
||||
- [ ] 부속설비 등록 버튼으로 이동 시 상위설비 자동 선택되도록 구현
|
||||
- [ ] 자산 등록 화면에서 상위설비 드롭다운 메뉴에서 목록이 표시되지 않고 있음
|
||||
- [ ] 상위설비 드롭다운 메뉴를 입력 텍스트 박스로 변경하고 옆에 검색 아이콘 추가
|
||||
- [ ] 검색 아이콘 클릭 시 상위설비 검색 화면으로 이동
|
||||
- [ ] 상위설비 검색 화면에서 검색어 입력 후 검색 버튼 클릭 시 검색 결과 목록 표시
|
||||
- [ ] 검색 결과 목록에서 상위설비 선택 시 자산 등록 화면으로 이동하고 입력 텍스트 박스에 상위설비 이름 표시
|
||||
- [ ] **자산관리 모듈의 현재 메뉴들을 기준정보 메뉴의 하위 메뉴로 변경**
|
||||
- [ ]
|
||||
- [ ] **설정 메뉴 추가**
|
||||
- [ ] IoT 센서 모니터링 메뉴 추가
|
||||
- [ ] 모듈 관리 메뉴 추가
|
||||
- [ ] 설비 관리 메뉴 추가
|
||||
- [ ]
|
||||
- [ ] **시스템 관리 - 사용자 관리**
|
||||
- [ ] 사용자 관리에 모듈 접근 권한을 설정할 수 있도록 구현
|
||||
- [ ] 관리권한 재 검토
|
||||
- [ ] 최고관리자, 관리자, 모듈 관리자, 사용자
|
||||
|
||||
#### 🏷️ Tag: `v0.5.0.x`
|
||||
- [ ] 모듈 간 연동을 위한 API 인터페이스 정의
|
||||
|
||||
@ -51,48 +51,4 @@ SMART IMS 플랫폼의 모든 배포와 업데이트는 아래의 **MAJOR.MINOR.
|
||||
## 💡 버전 비교 및 업데이트 공지 원칙
|
||||
1. 플랫폼은 원격 저장소의 최신 태그 버전과 현재 설치된 버전의 **4자리를 순차적으로 비교**합니다.
|
||||
2. 상위 자리수가 더 높을 경우 즉시 시스템 업데이트 안내를 발생시킵니다.
|
||||
3. 모든 버전은 `v` 접두사를 포함하여 관리하나, 비교 시에는 숫자로 정규화하여 처리합니다.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 배포 및 태그 생성 절차 (Standard Deployment Procedure)
|
||||
|
||||
> **경고**: 이 절차를 위반할 경우 운영 서버에서 업데이트가 누락되거나 버전 불일치가 발생할 수 있습니다.
|
||||
|
||||
### [Step 1] 사전 검증 (Pre-check)
|
||||
1. **작업 내용 커밋 확인**: `git status` 명령어로 커밋되지 않은(Unstaged/Modified) 파일이 없는지 확인합니다.
|
||||
- ❌ 실수 패턴: 코드를 수정하고 저장만 한 뒤, 커밋 없이 태그를 생성하면 **구버전 코드**가 배포됩니다.
|
||||
|
||||
2. **버전 번호 동기화 (필수)**: 아래 2개 파일의 `version` 필드를 배포할 버전 번호(예: `0.4.3.5`)로 직접 수정합니다.
|
||||
- `root/package.json`
|
||||
- `root/server/package.json`
|
||||
- *이 작업이 누락되면 시스템은 업데이트 후에도 "구버전"으로 인식합니다.*
|
||||
|
||||
### [Step 2] 커밋 및 태그 생성 (Commit & Tag)
|
||||
반드시 **소스코드 커밋을 먼저** 수행하고, 그 커밋에 태그를 붙여야 합니다.
|
||||
|
||||
```bash
|
||||
# 1. 버전 파일 및 변경 사항 커밋
|
||||
git add .
|
||||
git commit -m "[BUILD] v0.4.3.5 - 배포 내용 요약"
|
||||
|
||||
# 2. 원격 저장소로 코드 푸시 (먼저!)
|
||||
git push origin main
|
||||
|
||||
# 3. 태그 생성 (경량 태그 권장)
|
||||
git tag v0.4.3.5
|
||||
|
||||
# 4. 태그 푸시 (배포 트리거)
|
||||
git push origin v0.4.3.5
|
||||
```
|
||||
|
||||
### [Step 3] 운영 서버 배포 확인
|
||||
1. 운영 서버(NAS)에서 업데이트 스크립트 실행
|
||||
2. 업데이트 완료 후 브라우저에서 '새로고침(Ctrl+F5)' 또는 캐시 비우기 수행
|
||||
3. [시스템 관리] > [버전 정보] 메뉴에서 **표시되는 버전 번호**와 **빌드 일자**가 최신인지 확인
|
||||
|
||||
---
|
||||
|
||||
## 🚫 절대 금지 사항 (Do Not)
|
||||
1. **커밋 없이 태그만 생성 금지**: 운영 서버는 태그가 가리키는 시점의 코드를 가져옵니다. 변경 사항이 커밋되지 않았다면 아무것도 변하지 않습니다.
|
||||
2. **`package.json` 수정 누락**: 코드는 바꼈는데 명찰(버전 번호)을 안 바꾸면, 시스템은 "이미 최신 버전"이라 판단하고 업데이트 알림을 띄우지 않습니다.
|
||||
3. 모든 버전은 `v` 접두사를 포함하여 관리하나, 비교 시에는 숫자로 정규화하여 처리합니다.
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "smartims",
|
||||
"version": "0.4.3.1",
|
||||
"version": "0.4.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "smartims",
|
||||
"version": "0.4.3.1",
|
||||
"version": "0.4.0.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "smartims",
|
||||
"private": true,
|
||||
"version": "0.4.4.2",
|
||||
"version": "0.4.0.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# Smart IMS Emergency Restore Script (Revised)
|
||||
# 작성일: 2026-01-26
|
||||
# ==========================================
|
||||
|
||||
PROJECT_DIR="/volume1/web/smartims"
|
||||
BACKUP_DIR="/volume1/smart_ims"
|
||||
SERVER_DIR="${PROJECT_DIR}/server"
|
||||
MYSQL_BIN="/usr/local/mariadb10/bin"
|
||||
|
||||
DB_USER="choibk"
|
||||
DB_PORT="3307"
|
||||
DB_NAME="sokuree_platform_prod"
|
||||
DB_HOST="127.0.0.1"
|
||||
|
||||
echo "=============================================="
|
||||
echo "🚨 EMERGENCY RESTORE PROCESS STARTING"
|
||||
echo "=============================================="
|
||||
|
||||
# DB 비밀번호 추출 (Node.js)
|
||||
if [ -f "${SERVER_DIR}/.env" ]; then
|
||||
cd "${SERVER_DIR}"
|
||||
DB_PASS=$(node -e "try{const fs=require('fs');const c=fs.readFileSync('.env','utf8');const m=c.match(/^DB_PASSWORD=(.*)$/m);if(m){let p=m[1].trim();if((p.startsWith('\"')&&p.endsWith('\"'))||(p.startsWith(\"'\")&&p.endsWith(\"'\"))){p=p.slice(1,-1);}process.stdout.write(p);}}catch(e){}")
|
||||
else
|
||||
echo "❌ [Error] .env file not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 1. 최신 이미지 복구
|
||||
echo "[Step 1] Finding latest image backup..."
|
||||
LATEST_IMG=$(ls -t "${BACKUP_DIR}"/backup_images_*.tar.gz 2>/dev/null | head -n 1)
|
||||
|
||||
if [ -n "$LATEST_IMG" ]; then
|
||||
echo "✅ Found: $LATEST_IMG"
|
||||
echo " Restoring images..."
|
||||
tar -xzf "$LATEST_IMG" -C "${PROJECT_DIR}"
|
||||
else
|
||||
echo "⚠️ [Warning] No image backup found."
|
||||
fi
|
||||
|
||||
# 2. 최신 DB 복구
|
||||
echo "[Step 2] Finding latest database backup..."
|
||||
LATEST_SQL=$(ls -t "${BACKUP_DIR}"/backup_db_*.sql 2>/dev/null | head -n 1)
|
||||
|
||||
if [ -z "$LATEST_SQL" ]; then
|
||||
echo "❌ [Error] No database backup found!"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ Found: $LATEST_SQL"
|
||||
echo " Restoring database..."
|
||||
|
||||
"${MYSQL_BIN}/mysql" -h "${DB_HOST}" -u "${DB_USER}" "-p${DB_PASS}" --port "${DB_PORT}" "${DB_NAME}" < "$LATEST_SQL"
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "✅ Database restore complete."
|
||||
else
|
||||
echo "❌ [Error] Database restore failed."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "=============================================="
|
||||
echo "🎉 SYSTEM RESTORE COMPLETED"
|
||||
echo " Please restart PM2: pm2 reload smartims-api"
|
||||
echo "=============================================="
|
||||
@ -1,140 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# ==========================================
|
||||
# Smart IMS NAS Update Script (Revised)
|
||||
# 작성일: 2026-01-26
|
||||
# ==========================================
|
||||
|
||||
# 1. 환경 설정
|
||||
PROJECT_DIR="/volume1/web/smartims"
|
||||
BACKUP_DIR="/volume1/smart_ims"
|
||||
SERVER_DIR="${PROJECT_DIR}/server"
|
||||
DATE_STAMP=$(date +%Y%m%d_%H%M%S)
|
||||
|
||||
# DB 접속 정보
|
||||
DB_USER="choibk"
|
||||
DB_PORT="3307"
|
||||
DB_NAME="sokuree_platform_prod"
|
||||
DB_HOST="127.0.0.1" # 호스트 명시 (필수)
|
||||
MYSQL_BIN="/usr/local/mariadb10/bin"
|
||||
|
||||
# 로그 파일 설정
|
||||
mkdir -p "${BACKUP_DIR}/logs"
|
||||
LOG_FILE="${BACKUP_DIR}/logs/update_${DATE_STAMP}.log"
|
||||
exec > >(tee -a "${LOG_FILE}") 2>&1
|
||||
|
||||
echo "=============================================="
|
||||
echo "[Update] Started at $(date)"
|
||||
echo "=============================================="
|
||||
|
||||
# 2. .env에서 DB 비밀번호 추출 (Node.js 활용으로 특수문자/따옴표 안전 처리)
|
||||
if [ -f "${SERVER_DIR}/.env" ]; then
|
||||
cd "${SERVER_DIR}"
|
||||
# node 명령어로 파싱하여 쉘 특수문자 이슈 회피
|
||||
DB_PASS=$(node -e "try{const fs=require('fs');const c=fs.readFileSync('.env','utf8');const m=c.match(/^DB_PASSWORD=(.*)$/m);if(m){let p=m[1].trim();if((p.startsWith('\"')&&p.endsWith('\"'))||(p.startsWith(\"'\")&&p.endsWith(\"'\"))){p=p.slice(1,-1);}process.stdout.write(p);}}catch(e){}")
|
||||
|
||||
if [ -z "$DB_PASS" ]; then
|
||||
echo "[Error] Could not retrieve DB_PASSWORD from .env"
|
||||
exit 1
|
||||
fi
|
||||
echo "[Info] DB Password loaded (Length: ${#DB_PASS})"
|
||||
else
|
||||
echo "[Error] .env file not found at ${SERVER_DIR}/.env"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 백업 디렉토리 생성
|
||||
mkdir -p "${BACKUP_DIR}"
|
||||
|
||||
# ==========================================
|
||||
# 🚨 [STEP 1] 데이터 백업
|
||||
# ==========================================
|
||||
echo "[Step 1] Creating Backups..."
|
||||
|
||||
# 1-1. 이미지 백업
|
||||
if [ -d "${PROJECT_DIR}/server/uploads" ]; then
|
||||
cd "${PROJECT_DIR}"
|
||||
TAR_NAME="backup_images_${DATE_STAMP}.tar.gz"
|
||||
echo " - Backing up images..."
|
||||
tar -czf "${BACKUP_DIR}/${TAR_NAME}" server/uploads/
|
||||
else
|
||||
echo " - [Warning] Uploads directory not found. Skipping image backup."
|
||||
fi
|
||||
|
||||
# 1-2. DB 백업
|
||||
SQL_NAME="backup_db_${DATE_STAMP}.sql"
|
||||
echo " - Backing up database..."
|
||||
|
||||
# -h 127.0.0.1 옵션 추가 및 비밀번호 인자 방식 변경
|
||||
"${MYSQL_BIN}/mysqldump" -h "${DB_HOST}" -u "${DB_USER}" "-p${DB_PASS}" --port "${DB_PORT}" "${DB_NAME}" --single-transaction --quick --lock-tables=false > "${BACKUP_DIR}/${SQL_NAME}" 2>/dev/null
|
||||
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[Error] Database backup failed! Please check:"
|
||||
echo " 1. DB Port ($DB_PORT) matches MariaDB 10 port."
|
||||
echo " 2. DB Password in .env is correct."
|
||||
echo " 3. 'choibk' user has permissions."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 빈 파일 체크 (0 바이트면 실패로 간주)
|
||||
if [ ! -s "${BACKUP_DIR}/${SQL_NAME}" ]; then
|
||||
echo "[Error] Backup file is empty. Dump failed."
|
||||
rm "${BACKUP_DIR}/${SQL_NAME}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[Success] All backups completed successfully."
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 🚀 [STEP 2] 버전 동기화 및 코드 반영
|
||||
# ==========================================
|
||||
echo "[Step 2] Updating Source Code..."
|
||||
cd "${PROJECT_DIR}"
|
||||
|
||||
# 목표 태그 설정
|
||||
TARGET_TAG=$1
|
||||
if [ -z "$TARGET_TAG" ]; then
|
||||
echo " - Fetching latest tag info..."
|
||||
git fetch origin --tags --force
|
||||
TARGET_TAG=$(git describe --tags $(git rev-list --tags --max-count=1))
|
||||
echo " - Detected latest tag: ${TARGET_TAG}"
|
||||
fi
|
||||
|
||||
if [ -z "$TARGET_TAG" ]; then
|
||||
echo "[Error] Target tag not found."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo " - Syncing with remote..."
|
||||
git fetch origin --tags --force --prune
|
||||
if [ $? -ne 0 ]; then echo "[Error] Git fetch failed."; exit 1; fi
|
||||
|
||||
echo " - Checkout to ${TARGET_TAG}..."
|
||||
git checkout -f "${TARGET_TAG}"
|
||||
if [ $? -ne 0 ]; then echo "[Error] Git checkout failed."; exit 1; fi
|
||||
|
||||
|
||||
# ==========================================
|
||||
# 🏗️ [STEP 3] 시스템 빌드 및 서비스 재시작
|
||||
# ==========================================
|
||||
echo "[Step 3] Building and Restarting..."
|
||||
|
||||
# 3-1. 프론트엔드
|
||||
echo " - Building Frontend..."
|
||||
cd "${PROJECT_DIR}"
|
||||
npm install --no-audit --no-fund > /dev/null
|
||||
npm run build
|
||||
|
||||
# 3-2. 백엔드
|
||||
echo " - Installing Backend Dependencies..."
|
||||
cd "${SERVER_DIR}"
|
||||
npm install --no-audit --no-fund > /dev/null
|
||||
|
||||
echo " - Reloading PM2..."
|
||||
pm2 reload smartims-api || pm2 start index.js --name "smartims-api"
|
||||
|
||||
echo "=============================================="
|
||||
echo "[Update] Completed Successfully at $(date)"
|
||||
echo "[Info] Target Version: ${TARGET_TAG}"
|
||||
echo "=============================================="
|
||||
@ -1,8 +1,5 @@
|
||||
const mysql = require('mysql2');
|
||||
const 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
|
||||
require('dotenv').config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
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
|
||||
require('dotenv').config();
|
||||
|
||||
const db = require('./db');
|
||||
const authRoutes = require('./routes/auth');
|
||||
@ -14,6 +11,7 @@ const { isAuthenticated } = require('./middleware/authMiddleware');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3005; // Changed to 3005 to avoid conflict with Synology services (3001 issue)
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// Ensure uploads directory exists
|
||||
@ -184,6 +182,7 @@ const initTables = async () => {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`;
|
||||
await db.query(assetTable);
|
||||
await db.query(assetTable);
|
||||
await db.query(maintenanceTable);
|
||||
|
||||
// Check/Add 'quantity' column to assets
|
||||
@ -256,32 +255,13 @@ const initTables = async () => {
|
||||
// Ignore
|
||||
}
|
||||
|
||||
const [allowedModulesCols] = await db.query("SHOW COLUMNS FROM users LIKE 'allowed_modules'");
|
||||
if (allowedModulesCols.length === 0) {
|
||||
await db.query("ALTER TABLE users ADD COLUMN allowed_modules JSON NULL AFTER session_timeout");
|
||||
console.log("✅ Added 'allowed_modules' column to users");
|
||||
|
||||
// For existing users, grant access to all modules by default
|
||||
const allModules = JSON.stringify(['asset', 'production', 'cctv', 'material', '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);
|
||||
}
|
||||
const [userTimeoutCols] = await db.query("SHOW COLUMNS FROM users LIKE 'session_timeout'");
|
||||
if (userTimeoutCols.length === 0) {
|
||||
await db.query("ALTER TABLE users ADD COLUMN session_timeout INT DEFAULT 10 AFTER role");
|
||||
console.log("✅ Added 'session_timeout' column to users");
|
||||
}
|
||||
|
||||
console.log('✅ Tables Initialized');
|
||||
// Create asset_manuals table
|
||||
const manualTable = `
|
||||
CREATE TABLE IF NOT EXISTS asset_manuals (
|
||||
@ -457,53 +437,18 @@ const initTables = async () => {
|
||||
console.log("✅ Added 'subscriber_id' column to system_modules");
|
||||
}
|
||||
|
||||
// Initialize default modules
|
||||
const defaultModules = [
|
||||
{ code: 'asset', name: '자산 관리' },
|
||||
{ code: 'production', name: '생산 관리' },
|
||||
{ code: 'cctv', name: 'CCTV' },
|
||||
{ code: 'material', name: '자재/재고 관리' },
|
||||
{ code: 'quality', name: '품질 관리' }
|
||||
];
|
||||
|
||||
for (const mod of defaultModules) {
|
||||
const [exists] = await db.query('SELECT 1 FROM system_modules WHERE code = ?', [mod.code]);
|
||||
if (exists.length === 0) {
|
||||
await db.query('INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)',
|
||||
[mod.code, mod.name, false, null]);
|
||||
console.log(`✅ Initialized module: ${mod.name} (${mod.code})`);
|
||||
}
|
||||
// Initialize default modules if empty (Disabled by default as per request behavior)
|
||||
const [existingModules] = await db.query('SELECT 1 FROM system_modules LIMIT 1');
|
||||
if (existingModules.length === 0) {
|
||||
const insert = `INSERT INTO system_modules (code, name, is_active, license_type) VALUES (?, ?, ?, ?)`;
|
||||
await db.query(insert, ['asset', '자산 관리', false, null]);
|
||||
await db.query(insert, ['production', '생산 관리', false, null]);
|
||||
await db.query(insert, ['cctv', 'CCTV', false, null]);
|
||||
} else {
|
||||
// One-time update: Rename 'monitoring' code to 'cctv' (migration)
|
||||
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');
|
||||
} catch (err) {
|
||||
console.error('❌ Table Initialization Failed:', err);
|
||||
@ -514,13 +459,13 @@ initTables();
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
// Light-weight health check (Read from package.json to simulate 'installed' version)
|
||||
// Light-weight health check
|
||||
const kstOffset = 9 * 60 * 60 * 1000;
|
||||
const kstDate = new Date(Date.now() + kstOffset);
|
||||
|
||||
res.json({
|
||||
status: 'ok',
|
||||
version: packageJson.version, // Use static version from file
|
||||
version: packageJson.version,
|
||||
node_version: process.version,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
|
||||
4
server/package-lock.json
generated
4
server/package-lock.json
generated
@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.4.3.1",
|
||||
"version": "0.4.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "server",
|
||||
"version": "0.4.3.1",
|
||||
"version": "0.4.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"axios": "^1.13.2",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.4.4.2",
|
||||
"version": "0.4.0.1",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
|
||||
@ -135,7 +135,7 @@ router.post('/verify-supervisor', isAuthenticated, async (req, res) => {
|
||||
// 2. List Users (Admin Only)
|
||||
router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query('SELECT id, name, department, position, phone, role, session_timeout, allowed_modules, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
|
||||
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');
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return res.json([]);
|
||||
@ -159,7 +159,7 @@ router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
|
||||
// 3. Create User
|
||||
router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { id, password, name, department, position, phone, role, session_timeout, allowed_modules } = req.body;
|
||||
const { id, password, name, department, position, phone, role, session_timeout } = req.body;
|
||||
|
||||
if (!id || !password || !name) {
|
||||
return res.status(400).json({ error: 'Missing required fields' });
|
||||
@ -176,13 +176,11 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const encryptedPhone = await encrypt(phone);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO users (id, password, name, department, position, phone, role, session_timeout, allowed_modules)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO users (id, password, name, department, position, phone, role, session_timeout)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
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]);
|
||||
await db.query(sql, [id, hashedPassword, name, department, position, encryptedPhone, role || 'user', session_timeout || 10]);
|
||||
|
||||
res.status(201).json({ message: 'User created' });
|
||||
} catch (err) {
|
||||
@ -193,7 +191,7 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
|
||||
// 4. Update User
|
||||
router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { password, name, department, position, phone, role, session_timeout, allowed_modules } = req.body;
|
||||
const { password, name, department, position, phone, role, session_timeout } = req.body;
|
||||
const userId = req.params.id;
|
||||
|
||||
try {
|
||||
@ -207,7 +205,6 @@ router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) =>
|
||||
if (phone !== undefined) { updates.push('phone = ?'); params.push(await encrypt(phone)); }
|
||||
if (role) { updates.push('role = ?'); params.push(role); }
|
||||
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' });
|
||||
|
||||
|
||||
@ -40,18 +40,11 @@ const ALLOWED_SETTING_KEYS = [
|
||||
];
|
||||
|
||||
// --- .env File Utilities ---
|
||||
// Use .env.local for local machine settings (Ignored by Git)
|
||||
const localEnvPath = path.join(__dirname, '../.env.local');
|
||||
const defaultEnvPath = path.join(__dirname, '../.env');
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
|
||||
const readEnv = () => {
|
||||
// 1. Check for .env.local first
|
||||
let path = fs.existsSync(localEnvPath) ? localEnvPath : defaultEnvPath;
|
||||
|
||||
// 2. Migration: If ONLY .env exists, we'll read from it and later save will create .env.local
|
||||
if (!fs.existsSync(path)) return {};
|
||||
|
||||
const content = fs.readFileSync(path, 'utf8');
|
||||
if (!fs.existsSync(envPath)) return {};
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const env = {};
|
||||
lines.forEach(line => {
|
||||
@ -64,16 +57,7 @@ const readEnv = () => {
|
||||
};
|
||||
|
||||
const writeEnv = (updates) => {
|
||||
// If .env.local doesn't exist, create it by copying .env (if exists) or from empty
|
||||
if (!fs.existsSync(localEnvPath)) {
|
||||
if (fs.existsSync(defaultEnvPath)) {
|
||||
fs.copyFileSync(defaultEnvPath, localEnvPath);
|
||||
} else {
|
||||
fs.writeFileSync(localEnvPath, '', 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
let content = fs.readFileSync(localEnvPath, 'utf8');
|
||||
let content = fs.readFileSync(envPath, 'utf8');
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`^${key}=.*`, 'm');
|
||||
if (regex.test(content)) {
|
||||
@ -82,7 +66,7 @@ const writeEnv = (updates) => {
|
||||
content += `\n${key}=${value}`;
|
||||
}
|
||||
});
|
||||
fs.writeFileSync(localEnvPath, content, 'utf8');
|
||||
fs.writeFileSync(envPath, content, 'utf8');
|
||||
};
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
@ -310,7 +294,7 @@ router.get('/modules', isAuthenticated, async (req, res) => {
|
||||
const [rows] = await db.query('SELECT * FROM system_modules');
|
||||
|
||||
const modules = {};
|
||||
const defaults = ['asset', 'production', 'cctv', 'material', 'quality'];
|
||||
const defaults = ['asset', 'production', 'cctv'];
|
||||
|
||||
// Get stored subscriber ID
|
||||
const [subRows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'subscriber_id'");
|
||||
@ -360,10 +344,7 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
||||
|
||||
// 2. Check Module match
|
||||
// Allow legacy 'monitoring' licenses to activate 'cctv' module
|
||||
// Allow legacy 'inventory' licenses to activate 'material' module
|
||||
const isMatch = result.module === code ||
|
||||
(code === 'cctv' && result.module === 'monitoring') ||
|
||||
(code === 'material' && result.module === 'inventory');
|
||||
const isMatch = result.module === code || (code === 'cctv' && result.module === 'monitoring');
|
||||
|
||||
if (!isMatch) {
|
||||
return res.status(400).json({ error: `This license is for '${result.module}' module, not '${code}'` });
|
||||
@ -418,23 +399,17 @@ router.post('/modules/:code/activate', isAuthenticated, hasRole('admin'), async
|
||||
const names = {
|
||||
'asset': '자산 관리',
|
||||
'production': '생산 관리',
|
||||
'cctv': 'CCTV',
|
||||
'material': '자재/재고 관리',
|
||||
'quality': '품질 관리'
|
||||
'cctv': 'CCTV'
|
||||
};
|
||||
|
||||
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
|
||||
const licenseManagerUrl = process.env.LICENSE_MANAGER_URL || 'http://sokuree.com:3006/api';
|
||||
const licenseManagerUrl = process.env.LICENSE_MANAGER_URL || 'http://localhost:3006/api';
|
||||
try {
|
||||
console.log(`📡 Syncing with License Manager: ${licenseManagerUrl}/licenses/activate`);
|
||||
await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey });
|
||||
console.log(`✅ Synced activation status for key: ${licenseKey.substring(0, 20)}...`);
|
||||
} catch (syncErr) {
|
||||
console.error(`❌ Sync Error Object:`, syncErr);
|
||||
const errorDetail = syncErr.response ?
|
||||
`Status: ${syncErr.response.status}, Data: ${JSON.stringify(syncErr.response.data)}` :
|
||||
syncErr.message;
|
||||
@ -514,14 +489,13 @@ const getGiteaAuth = async () => {
|
||||
// 5. Get Version Info (Current, Remote & History from Tags)
|
||||
router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
// Local version detection (File-based for update simulation)
|
||||
const projectRoot = path.join(__dirname, '../..');
|
||||
// Local version detection (No caching)
|
||||
let currentVersion = '0.0.0.0';
|
||||
try {
|
||||
const rootPkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
|
||||
const rootPkg = JSON.parse(fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8'));
|
||||
currentVersion = rootPkg.version;
|
||||
} catch (e) {
|
||||
currentVersion = '0.4.2.8';
|
||||
currentVersion = '0.4.2.1';
|
||||
}
|
||||
|
||||
// Prepare git fetch command
|
||||
@ -535,7 +509,7 @@ router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res
|
||||
fetchCmd = `git fetch ${auth.url} --tags --force --prune`;
|
||||
}
|
||||
|
||||
exec(fetchCmd, { cwd: projectRoot }, (err, stdout, stderr) => {
|
||||
exec(fetchCmd, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
console.error('Git fetch failed:', err);
|
||||
const sanitizedError = stderr.replace(/:[^@]+@/g, ':****@');
|
||||
@ -552,7 +526,7 @@ router.get('/version/remote', isAuthenticated, hasRole('admin'), async (req, res
|
||||
const format = '%(refname:short)|%(contents:subject)|%(contents:body)|%(creatordate:iso8601)';
|
||||
const historyCmd = `git for-each-ref refs/tags --sort=-creatordate --format="${format}" --count=50`;
|
||||
|
||||
exec(historyCmd, { cwd: projectRoot }, (err, stdout, stderr) => {
|
||||
exec(historyCmd, (err, stdout, stderr) => {
|
||||
const lines = stdout ? stdout.trim().split('\n') : [];
|
||||
const allTags = lines.map(line => {
|
||||
const [tag, subject, body, date] = line.split('|');
|
||||
@ -639,7 +613,7 @@ router.post('/version/update', isAuthenticated, hasRole('admin'), async (req, re
|
||||
|
||||
// Build auth URL for git commands
|
||||
let remoteUrl = auth.url;
|
||||
if (auth.user && auth.pass && auth.url.includes('https://')) {
|
||||
if (auth.user && auth.pass) {
|
||||
remoteUrl = auth.url.replace('https://', `https://${encodeURIComponent(auth.user)}:${encodeURIComponent(auth.pass)}@`);
|
||||
}
|
||||
|
||||
@ -658,44 +632,18 @@ router.post('/version/update', isAuthenticated, hasRole('admin'), async (req, re
|
||||
scriptContent = `
|
||||
@echo off
|
||||
echo [Update] Starting update to ${targetTag}...
|
||||
if not exist "${backupDir}" mkdir "${backupDir}"
|
||||
|
||||
REM Ensure backup directory
|
||||
set BACKUP_PATH=${backupDir}
|
||||
if not exist "%BACKUP_PATH%" mkdir "%BACKUP_PATH%" 2>nul
|
||||
if not exist "%BACKUP_PATH%" (
|
||||
echo [Warning] Global backup failed, using local backup.
|
||||
set BACKUP_PATH=.\\server\\backups
|
||||
if not exist ".\\server\\backups" mkdir ".\\server\\backups"
|
||||
)
|
||||
|
||||
echo [Update] Backing up Config...
|
||||
if exist "server\\.env.local" (
|
||||
copy /Y "server\\.env.local" "%BACKUP_PATH%\\.env.local.backup.${timestamp}"
|
||||
copy /Y "server\\.env.local" "server\\.env.local.tmp"
|
||||
)
|
||||
if exist "server\\.env" (
|
||||
copy /Y "server\\.env" "%BACKUP_PATH%\\.env.backup.${timestamp}"
|
||||
copy /Y "server\\.env" "server\\.env.tmp"
|
||||
)
|
||||
echo [Update] Backing up Uploads & Config...
|
||||
tar -czvf "${backupDir}/backup_images_${timestamp}.tar.gz" server/uploads/
|
||||
if exist "server\\.env" copy "server\\.env" "${backupDir}\\env_backup_${timestamp}"
|
||||
|
||||
echo [Update] Syncing Source Code...
|
||||
git fetch "${remoteUrl}" --tags --force --prune
|
||||
git fetch "${remoteUrl}" +refs/tags/*:refs/tags/* --force --prune --prune-tags
|
||||
git checkout -f ${targetTag}
|
||||
|
||||
echo [Update] Restoring Config...
|
||||
if exist "server\\.env.local.tmp" (
|
||||
copy /Y "server\\.env.local.tmp" "server\\.env.local"
|
||||
del "server\\.env.local.tmp"
|
||||
) else if exist "%BACKUP_PATH%\\.env.local.backup.${timestamp}" (
|
||||
copy /Y "%BACKUP_PATH%\\.env.local.backup.${timestamp}" "server\\.env.local"
|
||||
)
|
||||
|
||||
if exist "server\\.env.tmp" (
|
||||
copy /Y "server\\.env.tmp" "server\\.env"
|
||||
del "server\\.env.tmp"
|
||||
) else if exist "%BACKUP_PATH%\\.env.backup.${timestamp}" (
|
||||
copy /Y "%BACKUP_PATH%\\.env.backup.${timestamp}" "server\\.env"
|
||||
)
|
||||
if exist "${backupDir}\\env_backup_${timestamp}" copy /Y "${backupDir}\\env_backup_${timestamp}" "server\\.env"
|
||||
|
||||
echo [Update] Installing & Building...
|
||||
call npm install
|
||||
@ -703,7 +651,8 @@ call npm run build
|
||||
cd server
|
||||
call npm install
|
||||
|
||||
echo [Update] Done.
|
||||
echo [Update] Restarting Server...
|
||||
echo "Please restart your dev server manually if needed."
|
||||
`;
|
||||
} else {
|
||||
// Linux/Synology Script
|
||||
@ -711,71 +660,22 @@ echo [Update] Done.
|
||||
scriptContent = `#!/bin/bash
|
||||
exec > >(tee -a update.log) 2>&1
|
||||
echo "[Update] Starting update to ${targetTag}..."
|
||||
|
||||
# Ensure backup directory
|
||||
BACKUP_DIR="${backupDir}"
|
||||
mkdir -p "$BACKUP_DIR" || {
|
||||
echo "[Warning] Global backup failed, using local backup."
|
||||
BACKUP_DIR="./server/backups"
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
}
|
||||
mkdir -p ${backupDir}
|
||||
|
||||
echo "[Update] Backing up Database..."
|
||||
if [ -f "${dumpTool}" ]; then
|
||||
echo "[Info] MySQL dump tool found. Attempting database backup..."
|
||||
${dumpTool} -u ${dbUser} --password='${dbPass}' --port ${dbPort} ${dbName} > "$BACKUP_DIR/backup_db_${timestamp}.sql" 2>/dev/null
|
||||
if [ $? -eq 0 ]; then
|
||||
echo "[Info] Database backup successful."
|
||||
else
|
||||
echo "[Warning] Database backup failed, continuing..."
|
||||
fi
|
||||
else
|
||||
echo "[Warning] MySQL dump tool not found. Skipping DB backup."
|
||||
fi
|
||||
${dumpTool} -u ${dbUser} --password='${dbPass}' --port ${dbPort} ${dbName} > ${backupDir}/backup_db_${timestamp}.sql || echo "DB Backup Failed, continuing..."
|
||||
|
||||
echo "[Update] Backing up Config..."
|
||||
# Backup .env.local (Priority)
|
||||
if [ -f "server/.env.local" ]; then
|
||||
echo "[Info] Backing up 'server/.env.local'."
|
||||
cp "server/.env.local" "$BACKUP_DIR/.env.local.backup.${timestamp}"
|
||||
cp "server/.env.local" "server/.env.local.tmp"
|
||||
fi
|
||||
# Backup .env (Fallback)
|
||||
if [ -f "server/.env" ]; then
|
||||
echo "[Info] Backing up 'server/.env'."
|
||||
cp "server/.env" "$BACKUP_DIR/.env.backup.${timestamp}"
|
||||
cp "server/.env" "server/.env.tmp"
|
||||
fi
|
||||
echo "[Update] Backing up Uploads & Config..."
|
||||
tar -czvf ${backupDir}/backup_images_${timestamp}.tar.gz server/uploads/
|
||||
cp server/.env ${backupDir}/.env.backup.${timestamp}
|
||||
|
||||
echo "[Update] Syncing Source Code..."
|
||||
git remote set-url origin "${remoteUrl}"
|
||||
git fetch origin --tags --force --prune
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[Error] Git fetch failed. Exiting update."
|
||||
exit 1
|
||||
fi
|
||||
echo "[Info] Git fetch successful."
|
||||
git fetch origin +refs/tags/*:refs/tags/* --force --prune --prune-tags
|
||||
git checkout -f ${targetTag}
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "[Error] Git checkout to ${targetTag} failed. Exiting update."
|
||||
exit 1
|
||||
fi
|
||||
echo "[Info] Git checkout to ${targetTag} successful."
|
||||
|
||||
echo "[Update] Restoring Config..."
|
||||
if [ -f "server/.env.local.tmp" ]; then
|
||||
cp "server/.env.local.tmp" "server/.env.local"
|
||||
rm "server/.env.local.tmp"
|
||||
elif [ -f "$BACKUP_DIR/.env.local.backup.${timestamp}" ]; then
|
||||
cp "$BACKUP_DIR/.env.local.backup.${timestamp}" "server/.env.local"
|
||||
fi
|
||||
|
||||
if [ -f "server/.env.tmp" ]; then
|
||||
cp "server/.env.tmp" "server/.env"
|
||||
rm "server/.env.tmp"
|
||||
elif [ -f "$BACKUP_DIR/.env.backup.${timestamp}" ]; then
|
||||
cp "$BACKUP_DIR/.env.backup.${timestamp}" "server/.env"
|
||||
fi
|
||||
cp ${backupDir}/.env.backup.${timestamp} server/.env
|
||||
|
||||
echo "[Update] Installing & Building..."
|
||||
npm install
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
const axios = require('axios');
|
||||
|
||||
async function test() {
|
||||
const licenseManagerUrl = 'http://localhost:3006/api';
|
||||
const licenseKey = 'test-key';
|
||||
try {
|
||||
console.log(`📡 Testing sync with ${licenseManagerUrl}/licenses/activate`);
|
||||
const response = await axios.post(`${licenseManagerUrl}/licenses/activate`, { licenseKey });
|
||||
console.log('✅ Success:', response.data);
|
||||
} catch (err) {
|
||||
if (err.response) {
|
||||
console.error('❌ Error Response:', err.response.status, err.response.data);
|
||||
} else {
|
||||
console.error('❌ Error Message:', err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test();
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Card } from '../../../shared/ui/Card';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { ArrowLeft, Save, Upload, X, Printer, ZoomIn, Trash2, Plus } from 'lucide-react';
|
||||
@ -15,7 +15,6 @@ interface AssetBasicInfoProps {
|
||||
}
|
||||
|
||||
export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
// User role is now allowed to edit assets
|
||||
const canEdit = user?.role === 'admin' || user?.role === 'supervisor' || user?.role === 'user';
|
||||
@ -27,7 +26,7 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
||||
const [showAccModal, setShowAccModal] = useState(false);
|
||||
const [newAcc, setNewAcc] = useState({ name: '', spec: '', quantity: 1 });
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
loadAccessories();
|
||||
}, [asset.id]);
|
||||
|
||||
@ -40,7 +39,7 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
React.useEffect(() => {
|
||||
if (isEditing) {
|
||||
const loadAllAssets = async () => {
|
||||
try {
|
||||
@ -443,7 +442,8 @@ export function AssetBasicInfo({ asset, onRefresh }: AssetBasicInfoProps) {
|
||||
icon={<Plus size={14} />}
|
||||
onClick={() => {
|
||||
// Navigate to register with pre-filled parent
|
||||
navigate(`/asset/register?parentId=${asset.id}`);
|
||||
// Note: Assuming navigation or handling via state
|
||||
window.location.href = `/asset/register?parentId=${asset.id}&categoryId=${allAssets.find(a => a.category === '설비')?.id || ''}`;
|
||||
}}
|
||||
>
|
||||
부속 설비 등록
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Search, Check } from 'lucide-react';
|
||||
import { assetApi, type Asset } from '../../../shared/api/assetApi';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
|
||||
interface AssetSearchModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSelect: (asset: Asset) => void;
|
||||
title?: string;
|
||||
categoryFilter?: string; // Optional: filter by category name like '설비'
|
||||
}
|
||||
|
||||
export function AssetSearchModal({ isOpen, onClose, onSelect, title = '자산 검색', categoryFilter }: AssetSearchModalProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [filteredAssets, setFilteredAssets] = useState<Asset[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadAssets();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadAssets = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const data = await assetApi.getAll();
|
||||
|
||||
// Apply category filter if provided
|
||||
let result = data;
|
||||
if (categoryFilter) {
|
||||
result = data.filter(a => a.category === categoryFilter);
|
||||
}
|
||||
|
||||
setAssets(result);
|
||||
setFilteredAssets(result);
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets for search:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!searchTerm) {
|
||||
setFilteredAssets(assets);
|
||||
return;
|
||||
}
|
||||
|
||||
const lowerSearch = searchTerm.toLowerCase();
|
||||
const filtered = assets.filter(a =>
|
||||
a.name.toLowerCase().includes(lowerSearch) ||
|
||||
a.id.toLowerCase().includes(lowerSearch) ||
|
||||
(a.serialNumber && a.serialNumber.toLowerCase().includes(lowerSearch))
|
||||
);
|
||||
setFilteredAssets(filtered);
|
||||
}, [searchTerm, assets]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl flex flex-col max-h-[80vh] overflow-hidden border border-slate-200">
|
||||
{/* Header */}
|
||||
<div className="p-5 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||
<h2 className="text-xl font-bold text-slate-800 flex items-center gap-2">
|
||||
<Search size={20} className="text-blue-500" />
|
||||
{title}
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1 transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="p-4 border-b border-slate-50 bg-white">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
className="w-full pl-10 pr-4 py-3 bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all font-medium"
|
||||
placeholder="설비명 또는 관리번호로 검색..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* List Area */}
|
||||
<div className="flex-1 overflow-y-auto p-2 bg-slate-50/30">
|
||||
{isLoading ? (
|
||||
<div className="p-12 text-center text-slate-500 font-medium">데이터를 불러오는 중...</div>
|
||||
) : filteredAssets.length > 0 ? (
|
||||
<div className="grid grid-cols-1 gap-2">
|
||||
{filteredAssets.map(asset => (
|
||||
<button
|
||||
key={asset.id}
|
||||
onClick={() => onSelect(asset)}
|
||||
className="flex items-center justify-between p-4 bg-white border border-slate-200 rounded-xl hover:border-blue-400 hover:shadow-md hover:bg-blue-50/30 transition-all group text-left"
|
||||
>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-bold font-mono text-blue-600 bg-blue-50 px-2 py-0.5 rounded border border-blue-100">
|
||||
{asset.id}
|
||||
</span>
|
||||
<span className="text-sm font-bold text-slate-500 bg-slate-100 px-2 py-0.5 rounded border border-slate-200">
|
||||
{asset.category}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-lg font-bold text-slate-800 group-hover:text-blue-700 transition-colors">
|
||||
{asset.name}
|
||||
</span>
|
||||
<div className="flex gap-3 text-sm text-slate-500">
|
||||
<span>위치: <span className="text-slate-700 font-medium">{asset.location}</span></span>
|
||||
{asset.serialNumber && <span>S/N: <span className="text-slate-700 font-medium">{asset.serialNumber}</span></span>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-slate-50 group-hover:bg-blue-500 group-hover:text-white p-2 rounded-full transition-all">
|
||||
<Check size={20} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-20 text-center">
|
||||
<div className="bg-slate-100 w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<Search size={24} className="text-slate-400" />
|
||||
</div>
|
||||
<p className="text-slate-500 font-medium font-lg">검색 결과가 없습니다.</p>
|
||||
<p className="text-slate-400 text-sm mt-1">검색어를 다른 단어로 시도해 보세요.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 border-t border-slate-100 bg-slate-50/50 flex justify-end">
|
||||
<Button variant="secondary" onClick={onClose}>닫기</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@ -4,21 +4,15 @@ import { AssetListPage } from './pages/AssetListPage';
|
||||
import { AssetRegisterPage } from './pages/AssetRegisterPage';
|
||||
import { AssetSettingsPage } from './pages/AssetSettingsPage';
|
||||
import { AssetDetailPage } from './pages/AssetDetailPage';
|
||||
import { PlaceholderPage } from '../../shared/ui/PlaceholderPage';
|
||||
|
||||
export const assetModule: IModuleDefinition = {
|
||||
moduleName: 'asset-management',
|
||||
basePath: '/asset',
|
||||
routes: [
|
||||
{ path: '/dashboard', element: <DashboardPage />, label: '대시보드' },
|
||||
{ path: '/list', element: <AssetListPage />, label: '자산 목록', group: '기준정보' },
|
||||
{ path: '/register', element: <AssetRegisterPage />, 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: '/dashboard', element: <DashboardPage />, label: '대시보드', group: '기본' },
|
||||
{ path: '/list', element: <AssetListPage />, label: '자산 목록', group: '기본' },
|
||||
{ path: '/register', element: <AssetRegisterPage />, label: '자산 등록', group: '기본' },
|
||||
{ path: '/settings', element: <AssetSettingsPage />, label: '자산 설정', group: '기본' },
|
||||
{ path: '/detail/:assetId', element: <AssetDetailPage /> },
|
||||
|
||||
{ path: '/facilities', element: <AssetListPage />, label: '시설물 관리', position: 'top' },
|
||||
|
||||
@ -1,18 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Card } from '../../../shared/ui/Card';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { Input } from '../../../shared/ui/Input';
|
||||
import { Select } from '../../../shared/ui/Select';
|
||||
import { ArrowLeft, Save, Upload, Search, X } from 'lucide-react';
|
||||
import { ArrowLeft, Save, Upload } from 'lucide-react';
|
||||
import { getCategories, getLocations, getIDRule } from './AssetSettingsPage';
|
||||
import { assetApi, type Asset } from '../../../shared/api/assetApi';
|
||||
import { AssetSearchModal } from '../components/AssetSearchModal';
|
||||
import './AssetRegisterPage.css';
|
||||
|
||||
export function AssetRegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
|
||||
// Load Settings Data
|
||||
const categories = getCategories();
|
||||
@ -21,10 +19,9 @@ export function AssetRegisterPage() {
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
id: '', // Asset ID (Auto-generated)
|
||||
parentId: searchParams.get('parentId') || '',
|
||||
parentName: '', // For display
|
||||
parentId: '',
|
||||
name: '',
|
||||
categoryId: searchParams.get('categoryId') || '', // Use ID for selection
|
||||
categoryId: '', // Use ID for selection
|
||||
model: '',
|
||||
serialNo: '',
|
||||
locationId: '', // Use ID for selection
|
||||
@ -37,32 +34,19 @@ export function AssetRegisterPage() {
|
||||
manufacturer: ''
|
||||
});
|
||||
|
||||
const [isSearchModalOpen, setIsSearchModalOpen] = useState(false);
|
||||
const [allAssets, setAllAssets] = useState<Asset[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadInitialData = async () => {
|
||||
const loadAllAssets = async () => {
|
||||
try {
|
||||
const data = await assetApi.getAll();
|
||||
|
||||
// If parentId is provided via URL, find its name
|
||||
const pId = searchParams.get('parentId');
|
||||
if (pId) {
|
||||
const parent = data.find(a => a.id === pId);
|
||||
if (parent) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
parentName: parent.name,
|
||||
// If it's a sub-equipment, it's likely the same category as parent if parent is facility
|
||||
categoryId: prev.categoryId || categories.find(c => c.name === '설비')?.id || ''
|
||||
}));
|
||||
}
|
||||
}
|
||||
setAllAssets(data);
|
||||
} catch (err) {
|
||||
console.error("Failed to load assets for initial data", err);
|
||||
console.error("Failed to load assets for parent selection", err);
|
||||
}
|
||||
};
|
||||
loadInitialData();
|
||||
}, [searchParams, categories]);
|
||||
loadAllAssets();
|
||||
}, []);
|
||||
|
||||
// Auto-generate Asset ID
|
||||
useEffect(() => {
|
||||
@ -87,7 +71,9 @@ export function AssetRegisterPage() {
|
||||
return '';
|
||||
}).join('');
|
||||
|
||||
setFormData(prev => ({ ...prev, id: generatedId }));
|
||||
const finalId = generatedId.replace('001', '001');
|
||||
|
||||
setFormData(prev => ({ ...prev, id: finalId }));
|
||||
|
||||
}, [formData.categoryId, formData.purchaseDate, idRule, categories]);
|
||||
|
||||
@ -161,23 +147,6 @@ export function AssetRegisterPage() {
|
||||
|
||||
const isFacility = categories.find(c => c.id === formData.categoryId)?.name === '설비';
|
||||
|
||||
const handleSelectParent = (parent: Asset) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
parentId: parent.id,
|
||||
parentName: parent.name
|
||||
}));
|
||||
setIsSearchModalOpen(false);
|
||||
};
|
||||
|
||||
const clearParent = () => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
parentId: '',
|
||||
parentName: ''
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header-right">
|
||||
@ -205,38 +174,19 @@ export function AssetRegisterPage() {
|
||||
/>
|
||||
|
||||
{isFacility && (
|
||||
<div className="ui-field-container">
|
||||
<label className="ui-label">상위 설비 (메인 설비)</label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Input
|
||||
name="parentName"
|
||||
value={formData.parentName ? `[${formData.parentId}] ${formData.parentName}` : ''}
|
||||
placeholder="검색 아이콘을 클릭하여 선택하세요"
|
||||
readOnly
|
||||
className="bg-slate-50 cursor-default"
|
||||
/>
|
||||
{formData.parentId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearParent}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-red-500"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => setIsSearchModalOpen(true)}
|
||||
className="shrink-0 h-[42px]"
|
||||
icon={<Search size={18} />}
|
||||
>
|
||||
검색
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
label="상위 설비 (메인 설비)"
|
||||
name="parentId"
|
||||
value={formData.parentId}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ label: '(없음 - 메인 설비로 등록)', value: '' },
|
||||
...allAssets
|
||||
.filter(a => a.category === '설비' && a.id !== formData.id)
|
||||
.map(a => ({ label: `[${a.id}] ${a.name}`, value: a.id }))
|
||||
]}
|
||||
placeholder="메인 설비가 있는 경우 선택"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Input
|
||||
@ -419,14 +369,6 @@ export function AssetRegisterPage() {
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<AssetSearchModal
|
||||
isOpen={isSearchModalOpen}
|
||||
onClose={() => setIsSearchModalOpen(false)}
|
||||
onSelect={handleSelectParent}
|
||||
title="상위 설비 검색"
|
||||
categoryFilter="설비"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -64,7 +64,7 @@ export function JSMpegPlayer({ url, width, height, className }: JSMpegPlayerProp
|
||||
}, [url, retryCount]); // Re-run when url or retryCount changes
|
||||
|
||||
return (
|
||||
<div className={`video-container bg-transparent flex items-center justify-center overflow-hidden ${className || ''}`} style={{ width, height }}>
|
||||
<div className={`video-container bg-black flex items-center justify-center overflow-hidden ${className || ''}`} style={{ width, height }}>
|
||||
<canvas key={`${url}-${retryCount}`} ref={canvasRef} className="w-full h-full object-contain" />
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -40,7 +40,7 @@ function SortableCamera({ camera, children, disabled }: { camera: Camera, childr
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className={`relative bg-[#0f172a] rounded-xl overflow-hidden border shadow-sm ${disabled ? 'border-slate-200' : 'border-slate-200 hover:border-indigo-500'} transition-all group h-full w-full`}>
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners} className={`relative bg-black overflow-hidden border ${disabled ? 'border-slate-800' : 'border-slate-700 hover:border-indigo-500'} transition-colors group h-full w-full`}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@ -309,7 +309,7 @@ export function MonitoringPage() {
|
||||
const slots = Array.from({ length: totalSlots }).map((_, i) => filteredCameras[i] || null);
|
||||
|
||||
return (
|
||||
<div className="w-full h-full bg-[#f1f5f9] flex flex-col min-h-0">
|
||||
<div className="w-full h-full bg-[#0a0a0a] flex flex-col min-h-0">
|
||||
<HeaderDropdown />
|
||||
|
||||
<div className="flex-1 p-2 overflow-hidden flex flex-col min-h-0">
|
||||
@ -323,7 +323,7 @@ export function MonitoringPage() {
|
||||
strategy={rectSortingStrategy}
|
||||
disabled={true}
|
||||
>
|
||||
<div className={`grid h-full w-full gap-4 ${layoutInfo.cols} ${totalSlots > layoutInfo.total ? 'overflow-y-auto' : ''}`}
|
||||
<div className={`grid h-full w-full gap-1 ${layoutInfo.cols} ${totalSlots > layoutInfo.total ? 'overflow-y-auto' : ''}`}
|
||||
style={{ gridAutoRows: (viewLayout === '1' || viewLayout === '1*2') ? '100%' : `${100 / Math.sqrt(layoutInfo.total)}%` }}>
|
||||
{slots.map((camera, index) => (
|
||||
<div key={camera ? camera.id : `empty-${index}`} className="h-full w-full">
|
||||
@ -362,7 +362,7 @@ export function MonitoringPage() {
|
||||
</div>
|
||||
|
||||
{/* Streaming Video */}
|
||||
<div className="flex-1 bg-[#0f172a] flex items-center justify-center">
|
||||
<div className="flex-1 bg-black flex items-center justify-center">
|
||||
<JSMpegPlayer
|
||||
key={`${camera.id}-${streamVersions[camera.id] || 0}`}
|
||||
url={getStreamUrl(camera.id)}
|
||||
@ -377,9 +377,9 @@ export function MonitoringPage() {
|
||||
</div>
|
||||
</SortableCamera>
|
||||
) : (
|
||||
<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-20 mb-2" />
|
||||
<span className="text-[11px] font-bold opacity-40 uppercase tracking-widest">No Buffer</span>
|
||||
<div className="h-full w-full bg-[#121212] border border-slate-900/50 flex flex-col items-center justify-center text-slate-700 select-none">
|
||||
<Video size={32} className="opacity-10 mb-2" />
|
||||
<span className="text-[11px] font-bold opacity-20 uppercase tracking-widest">No Buffer</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,30 +0,0 @@
|
||||
import { MaterialOverviewPage } from './pages/MaterialOverviewPage';
|
||||
import { MaterialListPage } from './pages/MaterialListPage';
|
||||
import { Package } from 'lucide-react';
|
||||
import type { IModuleDefinition } from '../../core/types';
|
||||
|
||||
export const materialModule: IModuleDefinition = {
|
||||
moduleName: 'material-management',
|
||||
label: '자재/재고 관리',
|
||||
basePath: '/material',
|
||||
description: '공장 소모품 및 원자재 재고 관리 모듈',
|
||||
routes: [
|
||||
{
|
||||
path: '/overview',
|
||||
element: <MaterialOverviewPage />,
|
||||
label: '자재 현황',
|
||||
icon: <Package size={16} />
|
||||
},
|
||||
{
|
||||
path: '/list',
|
||||
element: <MaterialListPage />,
|
||||
label: '자재 목록'
|
||||
},
|
||||
{
|
||||
path: '/consumables',
|
||||
element: <MaterialListPage />,
|
||||
label: '소모품 관리'
|
||||
}
|
||||
],
|
||||
requiredRoles: ['admin', 'manager', 'user']
|
||||
};
|
||||
@ -1,75 +0,0 @@
|
||||
import { Card } from '../../../shared/ui/Card';
|
||||
import { Package, Search, Filter, Plus, Edit2, Trash2 } from 'lucide-react';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
|
||||
export function MaterialListPage() {
|
||||
return (
|
||||
<div className="page-container animate-fade-in">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">자재 / 소모품 관리</h1>
|
||||
<p className="text-slate-500 mt-1">공구, 부품, 오일 등 공장 소모품 및 자재 재고를 관리합니다.</p>
|
||||
</div>
|
||||
<Button icon={<Plus size={18} />}>자재 등록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="p-4 mb-6 bg-slate-50/50 border-slate-200">
|
||||
<div className="flex gap-4">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" size={18} />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="자재명, 규격, 제조사 검색..."
|
||||
className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" icon={<Filter size={18} />}>필터</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{/* Mock Data for Demonstration */}
|
||||
{[
|
||||
{ id: 'MAT-001', name: '절삭유 (Coolant)', spec: '50L drum', stock: 5, unit: 'EA', loc: '자재창고 A', status: 'normal' },
|
||||
{ id: 'MAT-002', name: 'CNC 전용 공구홀더', spec: 'BT40', stock: 12, unit: 'EA', loc: '자재창고 B', status: 'low' },
|
||||
{ id: 'MAT-003', name: '가공용 팁 (Insert)', spec: 'CNMG120408', stock: 2, unit: 'BOX', loc: '현장 캐비닛', status: 'critical' },
|
||||
{ id: 'MAT-004', name: '스핀들 그리스', spec: '200g tube', stock: 8, unit: 'EA', loc: '자재창고 A', status: 'normal' },
|
||||
].map(item => (
|
||||
<Card key={item.id} className="hover:shadow-md transition-all border-slate-200 overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-100 bg-slate-50/30 flex justify-between items-start">
|
||||
<div className="bg-white p-2 rounded-lg border border-slate-200 shadow-sm">
|
||||
<Package className="text-blue-500" size={24} />
|
||||
</div>
|
||||
<div className={`px-2 py-1 rounded text-[10px] font-bold uppercase ${item.status === 'critical' ? 'bg-red-100 text-red-700' :
|
||||
item.status === 'low' ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'
|
||||
}`}>
|
||||
{item.status === 'critical' ? '재고 부족' : item.status === 'low' ? '보충 필요' : '정상'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<h3 className="font-bold text-slate-800 truncate mb-1" title={item.name}>{item.name}</h3>
|
||||
<p className="text-xs text-slate-500 mb-4">{item.spec}</p>
|
||||
|
||||
<div className="flex justify-between items-center bg-slate-50 p-3 rounded-lg border border-slate-100">
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 block uppercase font-bold tracking-tight">현재 재고</span>
|
||||
<span className={`text-xl font-black ${item.status === 'critical' ? 'text-red-600' : 'text-slate-800'
|
||||
}`}>{item.stock}</span>
|
||||
<span className="text-xs text-slate-500 ml-1">{item.unit}</span>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[10px] text-slate-400 block uppercase font-bold tracking-tight">위치</span>
|
||||
<span className="text-xs font-bold text-slate-600">{item.loc}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-4 py-3 bg-slate-50/50 border-t border-slate-100 flex justify-end gap-1">
|
||||
<button className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded transition-colors"><Edit2 size={14} /></button>
|
||||
<button className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded transition-colors"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
import { Card } from '../../../shared/ui/Card';
|
||||
import { Package, ArrowRightLeft, AlertTriangle, BarChart3 } from 'lucide-react';
|
||||
|
||||
export function MaterialOverviewPage() {
|
||||
const stats = [
|
||||
{ label: '전체 품목', value: '1,280', icon: <Package className="text-blue-600" />, change: '+12' },
|
||||
{ label: '입출고 현황', value: '45', icon: <ArrowRightLeft className="text-emerald-600" />, change: '오늘' },
|
||||
{ label: '재고 부족', value: '8', icon: <AlertTriangle className="text-amber-600" />, change: '-2' },
|
||||
{ label: '재고 회전율', value: '85%', icon: <BarChart3 className="text-indigo-600" />, change: '+5%' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">재고 관리 개요</h1>
|
||||
<p className="text-slate-500 mt-1">실시간 자재 및 제품 재고 현황을 확인합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{stats.map((stat, i) => (
|
||||
<Card key={i} className="p-4 border-slate-200 shadow-sm">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="p-2 bg-slate-50 rounded-lg">
|
||||
{stat.icon}
|
||||
</div>
|
||||
<span className={`text-xs font-bold px-2 py-0.5 rounded-full ${stat.change.includes('+') ? 'bg-emerald-50 text-emerald-600' :
|
||||
stat.change.includes('-') ? 'bg-red-50 text-red-600' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{stat.change}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-medium text-slate-500">{stat.label}</h3>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{stat.value}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="p-12 text-center border-dashed border-2 border-slate-200 bg-slate-50/50">
|
||||
<Package size={48} className="mx-auto text-slate-300 mb-4" />
|
||||
<h3 className="text-lg font-bold text-slate-800">상세 재고 목록 준비 중</h3>
|
||||
<p className="text-slate-500 mt-2">입출고 관리 및 실사 기능을 구현 중입니다.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
import { QualityOverviewPage } from './pages/QualityOverviewPage';
|
||||
import { ClipboardCheck } from 'lucide-react';
|
||||
import type { IModuleDefinition } from '../../core/types';
|
||||
|
||||
export const qualityModule: IModuleDefinition = {
|
||||
moduleName: 'quality-management',
|
||||
label: '품질 관리',
|
||||
basePath: '/quality',
|
||||
description: '공정 및 제품 품질 관리 모듈',
|
||||
routes: [
|
||||
{
|
||||
path: '/overview',
|
||||
element: <QualityOverviewPage />,
|
||||
label: '품질 현황',
|
||||
icon: <ClipboardCheck size={16} />
|
||||
}
|
||||
],
|
||||
requiredRoles: ['admin', 'manager', 'user']
|
||||
};
|
||||
@ -1,49 +0,0 @@
|
||||
import { Card } from '../../../shared/ui/Card';
|
||||
import { ClipboardCheck, ShieldAlert, CheckCircle2, Factory } from 'lucide-react';
|
||||
|
||||
export function QualityOverviewPage() {
|
||||
const stats = [
|
||||
{ label: '금일 검사수', value: '342', icon: <ClipboardCheck className="text-blue-600" />, status: '정상' },
|
||||
{ label: '불량 발생', value: '3', icon: <ShieldAlert className="text-red-600" />, status: '-1.2%' },
|
||||
{ label: '합격률', value: '99.1%', icon: <CheckCircle2 className="text-emerald-600" />, status: '+0.5%' },
|
||||
{ label: '공정 능력(Cpk)', value: '1.67', icon: <Factory className="text-indigo-600" />, status: '안정' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">품질 관리 개요</h1>
|
||||
<p className="text-slate-500 mt-1">제품 품질 검사 현황 및 공정 통계를 모니터링합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||
{stats.map((stat, i) => (
|
||||
<Card key={i} className="p-4 border-slate-200 shadow-sm">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="p-2 bg-slate-50 rounded-lg">
|
||||
{stat.icon}
|
||||
</div>
|
||||
<span className={`text-[10px] font-bold px-2 py-0.5 rounded-full ${stat.status.includes('+') ? 'bg-emerald-50 text-emerald-600' :
|
||||
stat.status.includes('-') ? 'bg-red-50 text-red-600' : 'bg-blue-50 text-blue-600'
|
||||
}`}>
|
||||
{stat.status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h3 className="text-sm font-medium text-slate-500">{stat.label}</h3>
|
||||
<p className="text-2xl font-bold text-slate-900 mt-1">{stat.value}</p>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card className="p-12 text-center border-dashed border-2 border-slate-200 bg-slate-50/50">
|
||||
<ClipboardCheck size={48} className="mx-auto text-slate-300 mb-4" />
|
||||
<h3 className="text-lg font-bold text-slate-800">품질 검사 수순 준비 중</h3>
|
||||
<p className="text-slate-500 mt-2">수입/공정/출하 검사 성적서 기능을 구현 중입니다.</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -9,8 +9,6 @@ import { LoginPage } from '../pages/auth/LoginPage';
|
||||
import { assetModule } from '../modules/asset/module';
|
||||
import { cctvModule } from '../modules/cctv/module';
|
||||
import { productionModule } from '../modules/production/module';
|
||||
import { materialModule } from '../modules/material/module';
|
||||
import { qualityModule } from '../modules/quality/module';
|
||||
import ModuleLoader from './ModuleLoader';
|
||||
|
||||
// Platform / System Pages
|
||||
@ -23,13 +21,7 @@ import { GeneralPreferencesPage } from './pages/GeneralPreferencesPage';
|
||||
|
||||
import './styles/global.css';
|
||||
|
||||
const modules = [
|
||||
assetModule,
|
||||
cctvModule,
|
||||
productionModule,
|
||||
materialModule,
|
||||
qualityModule
|
||||
];
|
||||
const modules = [assetModule, cctvModule, productionModule];
|
||||
|
||||
const DefaultRedirect = () => {
|
||||
const { user } = useAuth();
|
||||
@ -58,18 +50,18 @@ export const App = () => {
|
||||
{/* User Preferences (Accessible by everyone authenticated) */}
|
||||
<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) */}
|
||||
<Route path="/admin/users" element={<UserManagementPage />} />
|
||||
<Route path="/admin/settings" element={<BasicSettingsPage />} />
|
||||
<Route path="/admin/license" element={<LicensePage />} />
|
||||
<Route path="/admin/version" element={<VersionPage />} />
|
||||
<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>
|
||||
|
||||
{/* Fallback */}
|
||||
|
||||
@ -333,7 +333,7 @@ export function BasicSettingsPage() {
|
||||
|
||||
{/* Section 2.5: Gitea Repository Update Settings */}
|
||||
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<div className="p-6 border-b border-slate-50 bg-slate-50/50 flex justify-between items-center">
|
||||
<div className="p-6 border-b border-slate-50 bg-slate-50/50">
|
||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<RefreshCcw size={20} className="text-emerald-600" />
|
||||
시스템 업데이트 저장소 설정 (Gitea)
|
||||
|
||||
@ -17,7 +17,6 @@ interface UserFormData {
|
||||
phone: string;
|
||||
role: 'supervisor' | 'admin' | 'user';
|
||||
session_timeout: number;
|
||||
allowed_modules: string[];
|
||||
}
|
||||
|
||||
export function UserManagementPage() {
|
||||
@ -37,8 +36,7 @@ export function UserManagementPage() {
|
||||
position: '',
|
||||
phone: '',
|
||||
role: 'user',
|
||||
session_timeout: 10,
|
||||
allowed_modules: ['asset', 'production', 'cctv']
|
||||
session_timeout: 10
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
@ -51,7 +49,7 @@ export function UserManagementPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await apiClient.get('/users');
|
||||
setUsers(Array.isArray(res.data) ? res.data : []);
|
||||
setUsers(res.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users', error);
|
||||
alert('사용자 목록을 불러오지 못했습니다.');
|
||||
@ -69,33 +67,29 @@ export function UserManagementPage() {
|
||||
position: '',
|
||||
phone: '',
|
||||
role: 'user',
|
||||
session_timeout: 10,
|
||||
allowed_modules: ['asset', 'production', 'cctv']
|
||||
session_timeout: 10
|
||||
});
|
||||
setIsEditing(false);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleOpenEdit = (user: any) => {
|
||||
const u = user || {};
|
||||
const handleOpenEdit = (user: User) => {
|
||||
setFormData({
|
||||
id: u.id || '',
|
||||
id: user.id,
|
||||
password: '',
|
||||
name: u.name || '',
|
||||
department: u.department || '',
|
||||
position: u.position || '',
|
||||
phone: u.phone || '',
|
||||
role: u.role || 'user',
|
||||
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'])
|
||||
name: user.name,
|
||||
department: user.department || '',
|
||||
position: user.position || '',
|
||||
phone: user.phone || '',
|
||||
role: user.role,
|
||||
session_timeout: (user as any).session_timeout || 10
|
||||
});
|
||||
setIsEditing(true);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
if (!id || !confirm('정말 이 사용자를 삭제하시겠습니까?')) return;
|
||||
if (!confirm('정말 이 사용자를 삭제하시겠습니까?')) return;
|
||||
try {
|
||||
await apiClient.delete(`/users/${id}`);
|
||||
fetchUsers();
|
||||
@ -112,18 +106,6 @@ export function UserManagementPage() {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
@ -188,7 +170,6 @@ 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>
|
||||
@ -198,91 +179,46 @@ export function UserManagementPage() {
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-12 text-center text-slate-400">
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-slate-400">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCcw size={24} className="animate-spin text-indigo-500" />
|
||||
<span>사용자 데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (Array.isArray(users) ? users : []).map((u: any) => {
|
||||
if (!u || !u.id) return null;
|
||||
|
||||
// Defensive parsing for modules
|
||||
let userModules: string[] = [];
|
||||
try {
|
||||
const rawMods = u.allowed_modules;
|
||||
if (Array.isArray(rawMods)) {
|
||||
userModules = rawMods.filter(m => typeof m === 'string');
|
||||
} else if (typeof rawMods === 'string' && rawMods.trim().startsWith('[')) {
|
||||
const parsed = JSON.parse(rawMods);
|
||||
if (Array.isArray(parsed)) {
|
||||
userModules = parsed.filter(m => typeof m === 'string');
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
userModules = [];
|
||||
}
|
||||
|
||||
// Date formatting
|
||||
let loginStr = '미접속';
|
||||
try {
|
||||
if (u.last_login) {
|
||||
const d = new Date(u.last_login);
|
||||
if (!isNaN(d.getTime()) && d.getFullYear() > 1970) {
|
||||
loginStr = d.toLocaleString();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
loginStr = '날짜오류';
|
||||
}
|
||||
|
||||
return (
|
||||
<tr key={u.id} className="hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-bold text-slate-900">{u.id}</div>
|
||||
<div className="mt-1">{getRoleBadge(u.role || 'user')}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-semibold text-slate-800">{u.name || '-'}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-slate-900 font-bold">{u.department || '-'}</div>
|
||||
<div className="text-slate-600 text-[11px] font-medium">{u.position || '-'}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{userModules.map((m, mIdx) => (
|
||||
<span key={`${u.id}-${m}-${mIdx}`} className="px-1.5 py-0.5 bg-slate-100 text-slate-600 text-[10px] rounded border border-slate-200 font-medium">
|
||||
{m === 'asset' ? '자산' : m === 'production' ? '생산' : m === 'cctv' ? 'CCTV' : m}
|
||||
</span>
|
||||
))}
|
||||
{userModules.length === 0 && (
|
||||
<span className="text-slate-300 text-[10px]">권한 없음</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<span className="text-xs font-bold text-indigo-600 bg-indigo-50 px-2 py-0.5 rounded ring-1 ring-indigo-100">{u.session_timeout || 10}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-700 font-medium">{u.phone || '-'}</td>
|
||||
<td className="px-6 py-4 text-slate-400 text-[11px]">
|
||||
{loginStr}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex justify-center gap-1">
|
||||
<button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(u)}>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(u.id)}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{!loading && (!Array.isArray(users) || users.length === 0) && (
|
||||
) : users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-bold text-slate-900">{user.id}</div>
|
||||
<div className="mt-1">{getRoleBadge(user.role)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-semibold text-slate-800">{user.name}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-slate-900 font-bold">{user.department || '-'}</div>
|
||||
<div className="text-slate-600 text-[11px] font-medium">{user.position}</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">{(user as any).session_timeout || 10}</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-700 font-medium">{user.phone || '-'}</td>
|
||||
<td className="px-6 py-4 text-slate-400 text-[11px]">
|
||||
{user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex justify-center gap-1">
|
||||
<button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(user)}>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(user.id)}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!loading && users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-6 py-12 text-center text-slate-400">등록된 사용자가 없습니다.</td>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-slate-400">등록된 사용자가 없습니다.</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
@ -293,7 +229,7 @@ export function UserManagementPage() {
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-slate-900/60 backdrop-blur-sm p-4 animate-in fade-in duration-200">
|
||||
<Card className="w-full max-w-lg shadow-2xl border-slate-200 overflow-hidden">
|
||||
<Card className="w-full max-w-md shadow-2xl border-slate-200 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||
<h2 className="text-lg font-bold text-slate-800">
|
||||
{isEditing ? '사용자 정보 수정' : '새 사용자 등록'}
|
||||
@ -303,37 +239,36 @@ export function UserManagementPage() {
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">아이디 <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
name="user_id_new"
|
||||
autoComplete="off"
|
||||
readOnly={!isEditing}
|
||||
onFocus={(e) => e.target.readOnly = false}
|
||||
value={formData.id}
|
||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||
disabled={isEditing}
|
||||
placeholder="로그인 아이디 입력"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">
|
||||
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
||||
</label>
|
||||
<Input
|
||||
name="user_password_new"
|
||||
autoComplete="new-password"
|
||||
readOnly={!isEditing}
|
||||
onFocus={(e) => e.target.readOnly = false}
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder={isEditing ? "(변경시에만 입력)" : "초기 비밀번호 입력"}
|
||||
required={!isEditing}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">아이디 <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
name="user_id_new"
|
||||
autoComplete="off"
|
||||
readOnly={!isEditing}
|
||||
onFocus={(e) => e.target.readOnly = false}
|
||||
value={formData.id}
|
||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||
disabled={isEditing}
|
||||
placeholder="로그인 아이디 입력"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">
|
||||
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
||||
</label>
|
||||
<Input
|
||||
name="user_password_new"
|
||||
autoComplete="new-password"
|
||||
readOnly={!isEditing}
|
||||
onFocus={(e) => e.target.readOnly = false}
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder={isEditing ? "(변경시에만 입력)" : "초기 비밀번호 입력"}
|
||||
required={!isEditing}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
@ -345,7 +280,7 @@ export function UserManagementPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">권한</label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 py-2 text-sm font-medium focus:outline-none focus:ring-2 focus:ring-indigo-500/20 focus:border-indigo-500 transition-all outline-none"
|
||||
@ -385,31 +320,6 @@ export function UserManagementPage() {
|
||||
</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>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">핸드폰 번호</label>
|
||||
|
||||
@ -9,7 +9,6 @@ export interface User {
|
||||
position?: string;
|
||||
phone?: string;
|
||||
session_timeout?: number;
|
||||
allowed_modules?: string[];
|
||||
last_login?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -25,10 +25,11 @@ export function SystemProvider({ children }: { children: ReactNode }) {
|
||||
const refreshModules = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/system/modules');
|
||||
console.log('[SystemContext] Modules from server:', response.data.modules);
|
||||
if (response.data.modules) {
|
||||
setModules(response.data.modules);
|
||||
} else {
|
||||
// Fallback or assume old format (unsafe with new backend)
|
||||
// Better to assume new format based on my changes
|
||||
setModules(response.data.modules || response.data);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@ -1,49 +0,0 @@
|
||||
import { Construction, ArrowRight } from 'lucide-react';
|
||||
|
||||
interface PlaceholderPageProps {
|
||||
title: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export function PlaceholderPage({ title, description }: PlaceholderPageProps) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[60vh] p-8 text-center animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<div className="w-20 h-20 bg-blue-50 rounded-3xl flex items-center justify-center mb-6 shadow-sm border border-blue-100">
|
||||
<Construction className="text-blue-600 w-10 h-10" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-extrabold text-slate-800 mb-4 tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
|
||||
<p className="text-slate-500 max-w-md text-lg leading-relaxed mb-8">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-2xl p-6 max-w-lg w-full">
|
||||
<h3 className="text-sm font-bold text-slate-400 uppercase tracking-widest mb-4">현재 진행 상황</h3>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 shadow-[0_0_8px_rgba(34,197,94,0.6)]"></div>
|
||||
<span>기본 정적 레이아웃 설계 완료</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-slate-400">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-300"></div>
|
||||
<span>데이터베이스 스키마 정의 진행 중</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-sm text-slate-400">
|
||||
<div className="w-2 h-2 rounded-full bg-slate-300"></div>
|
||||
<span>프론트엔드 비즈니스 로직 개발 예정</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => window.history.back()}
|
||||
className="mt-10 flex items-center gap-2 text-blue-600 font-bold hover:gap-3 transition-all group"
|
||||
>
|
||||
이전 페이지로 돌아가기 <ArrowRight size={18} className="group-hover:translate-x-1 transition-transform" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,10 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState } from 'react';
|
||||
import { useSystem } from '../../shared/context/SystemContext';
|
||||
import { apiClient } from '../../shared/api/client';
|
||||
import { Check, X, Key, Shield, AlertTriangle, Terminal, Printer } from 'lucide-react';
|
||||
|
||||
export function LicensePage() {
|
||||
// Force Update Check v0.4.3.4
|
||||
const { modules, refreshModules } = useSystem();
|
||||
const [selectedModule, setSelectedModule] = useState<string | null>(null);
|
||||
const [licenseKey, setLicenseKey] = useState('');
|
||||
@ -14,9 +13,7 @@ export function LicensePage() {
|
||||
const moduleInfo: Record<string, { title: string, desc: string }> = {
|
||||
'asset': { title: '자산 관리 모듈', desc: '자산 등록, 조회, 수정 및 유지보수 이력 관리' },
|
||||
'production': { title: '생산 관리 모듈', desc: '생산 계획, 실적 및 공정 관리' },
|
||||
'cctv': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' },
|
||||
'material': { title: '자재/재고 관리 모듈', desc: '원자재 입출고 및 실시간 재고 현황 모니터링' },
|
||||
'quality': { title: '품질 관리 모듈', desc: '제품 품질 검사 및 통계 관리' }
|
||||
'cctv': { title: 'CCTV 모듈', desc: '실시간 영상 모니터링 및 녹화 관리' }
|
||||
};
|
||||
|
||||
// Subscriber Configuration State
|
||||
@ -32,9 +29,9 @@ export function LicensePage() {
|
||||
const [verifyError, setVerifyError] = useState('');
|
||||
|
||||
// Initial Load for Subscriber ID
|
||||
useEffect(() => {
|
||||
useState(() => {
|
||||
fetchSubscriberId();
|
||||
}, []);
|
||||
});
|
||||
|
||||
async function fetchSubscriberId() {
|
||||
try {
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../shared/auth/AuthContext';
|
||||
import { useSystem } from '../../shared/context/SystemContext';
|
||||
import { Settings, LogOut, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info, UserCog, Box, Package, ClipboardCheck } from 'lucide-react';
|
||||
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info, Home, UserCog } from 'lucide-react';
|
||||
import type { IModuleDefinition } from '../../core/types';
|
||||
import './MainLayout.css';
|
||||
|
||||
@ -11,21 +11,12 @@ interface MainLayoutProps {
|
||||
}
|
||||
|
||||
export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
// Force Update Check v0.4.3.4
|
||||
const location = useLocation();
|
||||
const { user, logout } = useAuth();
|
||||
const { modules } = useSystem();
|
||||
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management', 'standard-info', 'settings-group']);
|
||||
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']);
|
||||
|
||||
const isAdmin = user ? (['admin', 'supervisor'] as string[]).includes(user.role) : false;
|
||||
const isSupervisor = user?.role === 'supervisor';
|
||||
|
||||
const checkModulePermission = (moduleKey: string) => {
|
||||
// Supervisor and Admin always have access to see active modules
|
||||
if (isSupervisor || isAdmin) return true;
|
||||
// Check allowed_modules list for other roles (manager, user, etc.)
|
||||
return user?.allowed_modules?.includes(moduleKey);
|
||||
};
|
||||
|
||||
const checkRole = (requiredRoles: string[]) => {
|
||||
if (!user) return false;
|
||||
@ -48,19 +39,26 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
<div className="layout-container">
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-header">
|
||||
<Link to="/home" className="brand" style={{ textDecoration: 'none' }}>
|
||||
<div className="brand">
|
||||
<Box className="brand-icon" size={24} />
|
||||
<span className="brand-text">Smart IMS</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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) */}
|
||||
{isAdmin && (
|
||||
<div className="module-group">
|
||||
<button
|
||||
className="module-header" // Removed active class based on expanded state
|
||||
className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`}
|
||||
onClick={() => toggleModule('sys_mgmt')}
|
||||
>
|
||||
<div className="module-title">
|
||||
@ -93,175 +91,115 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 1. Asset Management (자산관리) - Promoted to main section as requested */}
|
||||
{modulesList.filter(m => m.moduleName === 'asset-management').map((mod) => {
|
||||
{/* Dynamic Modules Injection */}
|
||||
{modulesList.map((mod) => {
|
||||
const moduleKey = mod.moduleName.split('-')[0];
|
||||
if (!isModuleActive(moduleKey) || !checkModulePermission(moduleKey)) return null;
|
||||
if (!isModuleActive(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);
|
||||
|
||||
// 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 (
|
||||
<div key={mod.moduleName} className="module-group">
|
||||
<button
|
||||
className={`module-header ${isExpanded ? 'bg-white/5' : ''}`}
|
||||
onClick={() => toggleModule(mod.moduleName)}
|
||||
>
|
||||
<div className="module-title">
|
||||
<Layers size={18} />
|
||||
<span>자산 관리</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
{hasSubMenu ? (
|
||||
<>
|
||||
<button
|
||||
className={`module-header ${isExpanded ? 'active' : ''}`}
|
||||
onClick={() => toggleModule(mod.moduleName)}
|
||||
>
|
||||
<div className="module-title">
|
||||
{mod.moduleName === 'cctv' ? <Video size={18} /> : <Layers size={18} />}
|
||||
<span>{mod.label || (mod.moduleName.split('-')[0].charAt(0).toUpperCase() + mod.moduleName.split('-')[0].slice(1) + ' 관리')}</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="module-items">
|
||||
{(() => {
|
||||
const groupedRoutes: Record<string, typeof mod.routes> = {};
|
||||
const ungroupedRoutes: typeof mod.routes = [];
|
||||
|
||||
{isExpanded && (
|
||||
<div className="module-items">
|
||||
{/* Direct links (e.g. Dashboard) */}
|
||||
{ungrouped.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>
|
||||
))}
|
||||
mod.routes.filter(r => {
|
||||
const isLabelVisible = !!r.label;
|
||||
const isSidebarPosition = !r.position || r.position === 'sidebar';
|
||||
const isSettingsRoute = r.path.includes('settings');
|
||||
const hasPermission = user?.role !== 'user' || !isSettingsRoute;
|
||||
|
||||
{/* Grouped links (기준정보, 설정) */}
|
||||
{Object.entries(groups).map(([groupName, routes]) => {
|
||||
const groupKey = `${mod.moduleName}-group-${groupName}`;
|
||||
const isGroupExpanded = expandedModules.includes(groupKey);
|
||||
return isLabelVisible && isSidebarPosition && hasPermission;
|
||||
}).forEach(route => {
|
||||
if (route.group) {
|
||||
if (!groupedRoutes[route.group]) groupedRoutes[route.group] = [];
|
||||
groupedRoutes[route.group].push(route);
|
||||
} else {
|
||||
ungroupedRoutes.push(route);
|
||||
}
|
||||
});
|
||||
|
||||
return (
|
||||
<div key={groupName} className="nested-module-group pl-2 mt-1">
|
||||
<button
|
||||
className={`nav-item w-full flex justify-between items-center ${isGroupExpanded ? 'bg-slate-800/40' : ''}`}
|
||||
onClick={() => toggleModule(groupKey)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{groupName}</span>
|
||||
</div>
|
||||
{isGroupExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
return (
|
||||
<>
|
||||
{ungroupedRoutes.map(route => (
|
||||
<Link
|
||||
key={`${mod.moduleName}-${route.path}`}
|
||||
to={`${mod.basePath}${route.path}`}
|
||||
className={`nav-item ${location.pathname.startsWith(`${mod.basePath}${route.path}`) ? 'active' : ''}`}
|
||||
>
|
||||
{route.icon || <Box size={18} />}
|
||||
<span>{route.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
|
||||
{isGroupExpanded && (
|
||||
<div className="pl-4 mt-1 border-l border-slate-700 ml-4 flex flex-col gap-1">
|
||||
{routes.map(route => {
|
||||
const isSettingsRoute = route.path.includes('settings');
|
||||
const hasPermission = user?.role !== 'user' || !isSettingsRoute;
|
||||
if (!hasPermission) return null;
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={`${mod.moduleName}-${route.path}`}
|
||||
to={`${mod.basePath}${route.path}`}
|
||||
className={`nav-item py-1.5 text-xs ${location.pathname === `${mod.basePath}${route.path}` ? 'active' : ''}`}
|
||||
>
|
||||
<span>{route.label}</span>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{Object.entries(groupedRoutes).map(([groupName, routes]) => (
|
||||
<div key={groupName} className="menu-group-section">
|
||||
<div className="menu-group-label text-xs font-bold text-slate-500 uppercase px-3 py-2 mt-2 mb-1">
|
||||
{groupName}
|
||||
</div>
|
||||
{routes.map(route => (
|
||||
<Link
|
||||
key={`${mod.moduleName}-${route.path}`}
|
||||
to={`${mod.basePath}${route.path}`}
|
||||
className={`nav-item ${location.pathname.startsWith(`${mod.basePath}${route.path}`) ? 'active' : ''}`}
|
||||
>
|
||||
{route.icon || <Box size={18} />}
|
||||
<span>{route.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</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 className="sidebar-divider my-2 border-t border-slate-100 opacity-10"></div>
|
||||
|
||||
{/* Other Modules (CCTV, Production, Material, etc.) - Promoted to top level */}
|
||||
{modulesList
|
||||
.filter(m => m.moduleName !== 'asset-management')
|
||||
.sort((a, b) => {
|
||||
const order: Record<string, number> = {
|
||||
'production-management': 1,
|
||||
'material-management': 2,
|
||||
'quality-management': 3,
|
||||
'cctv': 4
|
||||
};
|
||||
return (order[a.moduleName] || 99) - (order[b.moduleName] || 99);
|
||||
})
|
||||
.map((mod) => {
|
||||
const moduleKey = mod.moduleName.split('-')[0];
|
||||
const active = isModuleActive(moduleKey);
|
||||
const permitted = checkModulePermission(moduleKey);
|
||||
console.log(`[Sidebar] Module: ${mod.moduleName}, Key: ${moduleKey}, Active: ${active}, Permitted: ${permitted}`);
|
||||
if (!active || !permitted) return null;
|
||||
if (mod.requiredRoles && !checkRole(mod.requiredRoles)) return null;
|
||||
|
||||
const isExpanded = expandedModules.includes(mod.moduleName);
|
||||
let Icon = Layers;
|
||||
if (mod.moduleName === 'cctv') Icon = Video;
|
||||
if (mod.moduleName === 'material-management') Icon = Package;
|
||||
if (mod.moduleName === 'production-management') Icon = Box;
|
||||
if (mod.moduleName === 'quality-management') Icon = ClipboardCheck;
|
||||
|
||||
const label = mod.label || (
|
||||
mod.moduleName === 'cctv' ? 'CCTV 모듈' :
|
||||
mod.moduleName === 'production-management' ? '생산관리' :
|
||||
mod.moduleName === 'material-management' ? '자재/재고 관리' :
|
||||
mod.moduleName === 'quality-management' ? '품질 관리' : mod.moduleName
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={mod.moduleName} className="module-group">
|
||||
<button
|
||||
className={`module-header ${isExpanded ? 'bg-white/5' : ''}`}
|
||||
onClick={() => toggleModule(mod.moduleName)}
|
||||
>
|
||||
<div className="module-title">
|
||||
<Icon size={18} />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="module-items">
|
||||
{mod.routes.filter(r => r.label && (!r.position || r.position === 'sidebar')).map(route => (
|
||||
<Link
|
||||
key={`${mod.moduleName}-${route.path}`}
|
||||
to={`${mod.basePath}${route.path}`}
|
||||
className={`nav-item ${location.pathname === `${mod.basePath}${route.path}` ? 'active' : ''}`}
|
||||
>
|
||||
<span>{route.label}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
|
||||
|
||||
<div className="sidebar-divider my-4 border-t border-slate-100 opacity-50"></div>
|
||||
|
||||
{/* User Preferences - Accessible to all roles */}
|
||||
<div className="module-group">
|
||||
<Link to="/preferences" className={`nav-item ${location.pathname === '/preferences' ? 'active' : ''}`}>
|
||||
<UserCog size={18} />
|
||||
<span>사용자 설정</span>
|
||||
<span>기본 설정</span>
|
||||
</Link>
|
||||
</div>
|
||||
</nav >
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-footer">
|
||||
<div className="user-info">
|
||||
@ -277,7 +215,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
<LogOut size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</aside >
|
||||
</aside>
|
||||
|
||||
<main className="main-content">
|
||||
<header className="top-header">
|
||||
@ -330,6 +268,6 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div >
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -111,7 +111,7 @@ Usage: node tools/license_manager.cjs <command> [args]
|
||||
Commands:
|
||||
list Show active modules in Database
|
||||
generate <module> <type> Generate a new license key
|
||||
Modules: asset, production, material, quality, cctv
|
||||
Modules: asset, production, monitoring
|
||||
Types: dev, sub, demo
|
||||
Flags: --subscriber <id> (REQUIRED)
|
||||
[--activate]
|
||||
@ -134,7 +134,7 @@ async function listModules() {
|
||||
console.log('| Code | Active | Type | Expiry | Subscriber |');
|
||||
console.log('---------------------------------------------------------------------------------------');
|
||||
|
||||
const defaults = ['asset', 'production', 'cctv', 'material', 'quality'];
|
||||
const defaults = ['asset', 'production', 'monitoring'];
|
||||
const data = {};
|
||||
rows.forEach(r => data[r.code] = r);
|
||||
|
||||
@ -185,7 +185,7 @@ async function showCmd(identifier) {
|
||||
const conn = await mysql.createConnection(dbConfig);
|
||||
try {
|
||||
// Check if identifier is a known module code
|
||||
const validModules = ['asset', 'production', 'cctv', 'material', 'quality'];
|
||||
const validModules = ['asset', 'production', 'monitoring'];
|
||||
const isModule = validModules.includes(identifier) || validModules.includes(identifier.toLowerCase());
|
||||
|
||||
if (isModule) {
|
||||
@ -253,9 +253,10 @@ async function generateCmd(moduleCode, type) {
|
||||
}
|
||||
|
||||
// ... Validation (Same as before) ...
|
||||
const validModules = ['asset', 'production', 'cctv', 'material', 'quality'];
|
||||
const validModules = ['asset', 'production', 'monitoring'];
|
||||
const validTypes = ['dev', 'sub', 'demo'];
|
||||
const finalCode = moduleCode;
|
||||
const modMap = { 'cctv': 'monitoring' };
|
||||
const finalCode = modMap[moduleCode] || moduleCode;
|
||||
|
||||
if (!validModules.includes(finalCode)) {
|
||||
console.error(`Invalid module. Allowed: ${validModules.join(', ')}`);
|
||||
@ -421,13 +422,7 @@ async function activateCmd(key) {
|
||||
}
|
||||
|
||||
// 4. Activate New License
|
||||
const names = {
|
||||
'asset': '자산 관리',
|
||||
'production': '생산 관리',
|
||||
'cctv': 'CCTV',
|
||||
'material': '자재/재고 관리',
|
||||
'quality': '품질 관리'
|
||||
};
|
||||
const names = { 'asset': '자산 관리', 'production': '생산 관리', 'monitoring': 'CCTV' };
|
||||
const sql = `
|
||||
INSERT INTO system_modules (code, name, is_active, license_key, license_type, expiry_date, subscriber_id)
|
||||
VALUES (?, ?, true, ?, ?, ?, ?)
|
||||
@ -457,7 +452,7 @@ async function deleteCmd(moduleCode) {
|
||||
return;
|
||||
}
|
||||
|
||||
const validModules = ['asset', 'production', 'cctv', 'material', 'quality'];
|
||||
const validModules = ['asset', 'production', 'monitoring'];
|
||||
if (!validModules.includes(moduleCode)) {
|
||||
console.error(`Invalid module. Allowed: ${validModules.join(', ')}`);
|
||||
return;
|
||||
|
||||
@ -1,39 +0,0 @@
|
||||
|
||||
@echo off
|
||||
echo [Update] Starting update to v0.4.2.6...
|
||||
|
||||
REM Ensure backup directory
|
||||
set BACKUP_PATH=./backup
|
||||
if not exist "%BACKUP_PATH%" mkdir "%BACKUP_PATH%" 2>nul
|
||||
if not exist "%BACKUP_PATH%" (
|
||||
echo [Warning] Global backup failed, using local backup.
|
||||
set BACKUP_PATH=.\server\backups
|
||||
if not exist ".\server\backups" mkdir ".\server\backups"
|
||||
)
|
||||
|
||||
echo [Update] Backing up Config...
|
||||
if exist "server\.env" (
|
||||
copy /Y "server\.env" "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22"
|
||||
copy /Y "server\.env" "server\.env.tmp"
|
||||
)
|
||||
|
||||
echo [Update] Syncing Source Code...
|
||||
git fetch "https://gitea.qideun.com/SOKUREE/smart_ims.git" --tags --force --prune
|
||||
git checkout -f v0.4.2.6
|
||||
|
||||
echo [Update] Restoring Config...
|
||||
if exist "server\.env.tmp" (
|
||||
copy /Y "server\.env.tmp" "server\.env"
|
||||
del "server\.env.tmp"
|
||||
) else if exist "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22" (
|
||||
copy /Y "%BACKUP_PATH%\.env.backup.2026-01-26-00-58-22" "server\.env"
|
||||
)
|
||||
|
||||
echo [Update] Installing & Building...
|
||||
call npm install
|
||||
call npm run build
|
||||
cd server
|
||||
call npm install
|
||||
|
||||
echo [Update] Done.
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user