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