feat: 플랫폼 보안 강화, 권한 계층 시스템 도입 및 버전 관리 통합 (v0.2.5)
- 최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 및 인증 UI 적용 - 데이터베이스 인프라 및 암호화 마스터 키 자가 관리 기능 구축 - 권한 계층(Supervisor > Admin > User) 기반의 메뉴 노출 및 접근 제어 로직 강화 - 시스템 버전 정보 페이지 신규 추가 및 패키지 버전 자동 연동 (v0.2.5) - 사용자 관리 UI 디자인 개선 및 폰트/스타일 일원화
This commit is contained in:
parent
7573c18832
commit
8b2589b6fa
BIN
auth_head.js
Normal file
BIN
auth_head.js
Normal file
Binary file not shown.
BIN
auth_v0.js
Normal file
BIN
auth_v0.js
Normal file
Binary file not shown.
BIN
auth_v1.js
Normal file
BIN
auth_v1.js
Normal file
Binary file not shown.
71
docs/DIRECTORY_STRUCTURE.md
Normal file
71
docs/DIRECTORY_STRUCTURE.md
Normal file
@ -0,0 +1,71 @@
|
||||
# SmartIMS 프로젝트 디렉토리 구조 (Directory Structure)
|
||||
|
||||
본 문서는 SmartIMS 플랫폼의 전체 프로젝트 구조와 각 디렉토리의 역할을 설명합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 전체 구조 개요
|
||||
|
||||
```text
|
||||
smartims/
|
||||
├── docs/ # 설계서, 가이드, 매뉴얼 등 프로젝트 관련 문서
|
||||
├── public/ # 파비콘, 로고 등 정적 자원
|
||||
├── server/ # Node.js (Express) 기반 백엔드 서버
|
||||
├── src/ # React (Vite) 기반 프론트엔드 어플리케이션
|
||||
├── tools/ # 개발 및 관리에 필요한 유틸리티 도구
|
||||
├── package.json # 프론트엔드 의존성 및 스크립트
|
||||
├── tailwind.config.js # Tailwind CSS 설정
|
||||
└── vite.config.ts # Vite 빌드 설정
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 프론트엔드 구조 (`src/`)
|
||||
|
||||
프론트엔드는 **모듈형 아키텍처**를 따르며, 플랫폼 엔진과 독립적인 비즈니스 모듈로 구성됩니다.
|
||||
|
||||
### 2.1 Core & Infrastructure
|
||||
- `src/core/`: 플랫폼의 핵심 인터페이스 및 공통 타입 정의 (`types.ts`).
|
||||
- `src/platform/`: 시스템의 뼈대 구현.
|
||||
- `App.tsx`: 메인 엔트리 및 전체 레이아웃 구성.
|
||||
- `ModuleLoader.tsx`: 등록된 모듈을 동적으로 로드하고 라우팅을 구성.
|
||||
- `styles/`: 전역 스타일 및 디자인 토큰(`global.css`) 관리.
|
||||
- `src/app/`: Context Provider(인증, 시스템 설정 등)와 같은 전역 상태 관리.
|
||||
|
||||
### 2.2 Functional Components
|
||||
- `src/modules/`: 독립적인 비즈니스 영역. 각 폴더는 하나의 서비스 모듈을 의미함.
|
||||
- 예: `asset/`, `cctv/`, `license/` 등.
|
||||
- 각 모듈은 `module.tsx` (또는 `index.tsx`)를 진입점으로 가짐.
|
||||
- `src/widgets/`: 여러 페이지에서 재사용되는 복합 UI 블록 (예: `MainLayout`, `Sidebar`).
|
||||
- `src/shared/`: 프로젝트 전역에서 사용되는 공통 UI 컴포넌트, 유틸리티, API 클라이언트.
|
||||
|
||||
---
|
||||
|
||||
## 3. 백엔드 구조 (`server/`)
|
||||
|
||||
백엔드는 RESTful API 제공 및 데이터베이스 관리를 담당합니다.
|
||||
|
||||
- `server/index.js`: 서버 엔트리 포인트. 환경 설정, 미들웨어 등록, 라우트 연결 총괄.
|
||||
- `server/routes/`: API 엔드포인트 정의. (예: `auth.js`, `system.js`, `asset.js`).
|
||||
- `server/middleware/`: 요청 처리 과정에서 동작하는 공통 로직 (인증 필터, 에러 핸들러).
|
||||
- `server/configs/`: 데이터베이스 연결 (`db.js`) 및 외부 서비스 설정.
|
||||
- `server/utils/`: 서버 측 공통 유틸리티 (로그 관리, 파일 업로드 등).
|
||||
- `server/schema.sql`: 데이터베이스 테이블 명세 및 초기 데이터.
|
||||
|
||||
---
|
||||
|
||||
## 4. 기타 주요 디렉토리
|
||||
|
||||
- `docs/`:
|
||||
- `MODULAR_ARCHITECTURE_GUIDE.md`: 모듈화 상세 설계 원칙.
|
||||
- `DEPLOYMENT_GUIDE.md`: 서버 배포 방법.
|
||||
- `tools/`:
|
||||
- 라이선스 관리 도구, 데이터 마이그레이션 스크립트 등 개발 보조 도구 포함.
|
||||
|
||||
---
|
||||
|
||||
## 5. 아키텍처 원칙 요약
|
||||
|
||||
1. **느슨한 결합 (Loose Coupling)**: 플랫폼은 모듈 내부 로직을 모르며, 모듈은 정의된 인터페이스를 통해 플랫폼과 통신합니다.
|
||||
2. **높은 응집도 (High Cohesion)**: 특정 기능(예: 자산 관리)에 필요한 코드는 해당 모듈 폴더(`src/modules/asset/`) 내에 모여 있어야 합니다.
|
||||
3. **디자인 시스템 준수**: 모든 스타일은 플랫폼이 제공하는 CSS Variables를 사용하여 일관성을 유지합니다.
|
||||
45
docs/ENVIRONMENT_SETUP.md
Normal file
45
docs/ENVIRONMENT_SETUP.md
Normal file
@ -0,0 +1,45 @@
|
||||
# SmartIMS 환경 설정 가이드 (Environment Setup Guide)
|
||||
|
||||
본 문서는 SmartIMS의 개발 환경(Windows)과 운영 환경(Synology NAS) 설정을 관리하기 위한 지침을 제공합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 환경별 주요 설정 (.env)
|
||||
|
||||
`server/.env` 파일은 프로젝트의 핵심 설정을 담고 있습니다. 환경에 따라 아래 주석을 참고하여 값을 변경하세요.
|
||||
|
||||
### 1.1 데이터베이스 (MariaDB)
|
||||
| 변수명 | 개발 환경 (Windows) | 운영 환경 (Synology) | 설명 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| `DB_HOST` | `localhost` 또는 `sokuree.com` | `localhost` | DB 서버 주소 |
|
||||
| `DB_PORT` | `3307` (Docker/XAMPP 등) | `3306` (기본값) | MariaDB 접속 포트 |
|
||||
| `DB_NAME` | `sokuree_platform_dev` | `sokuree_platform_prod` | 사용할 DB 이름 |
|
||||
|
||||
### 1.2 시스템 및 보안
|
||||
- `PORT`: 백엔드 서버 포트 (기본값: `3005`). Synology 서비스와 충돌 시 변경 가능.
|
||||
- `SESSION_SECRET`: 세션 암호화 키. 운영 환경에서는 고유한 긴 문자열로 변경 권장.
|
||||
- `LICENSE_MANAGER_URL`: 라이선스 서버 주소.
|
||||
|
||||
---
|
||||
|
||||
## 2. 개발 및 운영 환경 차이점 정리
|
||||
|
||||
### 2.1 스트리밍 전송 모드 (`CCTV_TRANSPORT_OVERRIDE`)
|
||||
- **개발(Windows)**: `auto` 또는 `udp` 사용. (TCP 사용 시 권한 문제로 스트리밍이 끊길 수 있음)
|
||||
- **운영(NAS)**: `tcp` 권한이 안정적이므로 `tcp` 설정을 권장합니다.
|
||||
|
||||
### 2.2 API 프록시 (Vite)
|
||||
- 개발 시에는 `vite.config.ts`의 `proxy` 설정을 통해 프론트엔드가 백엔드(`localhost:3005`)와 통신합니다.
|
||||
- 운영 시에는 `npm run build`로 생성된 `dist` 폴더를 Node.js(Express)가 직접 서빙하므로 프록시 설정이 필요 없으며 동일 포트에서 동작합니다.
|
||||
|
||||
### 2.3 파일 경로 (Uploads)
|
||||
- 업로드된 이미지는 `server/uploads` 디렉토리에 저장됩니다.
|
||||
- 운영 환경으로 이전 시 이 디렉토리의 쓰기 권한이 Node.js 실행 계정에 있는지 확인해야 합니다.
|
||||
|
||||
---
|
||||
|
||||
## 3. 체크리스트: 자산 목록이 안 보일 때
|
||||
1. **서버-DB 연결 확인**: 서버 로그(`npm start`)에서 `✅ Connected to Database` 메시지가 나오는지 확인하세요.
|
||||
2. **모듈 활성화 확인**: [시스템 관리] > [모듈/라이선스 관리] 메뉴에서 '자산 관리' 모듈이 활성화 상태인지 확인하세요.
|
||||
3. **구독자 ID 일치**: 라이선스 키 등록 시 사용된 구독자 ID(`SKR-2024-...`)가 [기본 설정]의 구독자 ID와 일치해야 합니다.
|
||||
4. **브라우저 캐시**: 프론트엔드 빌드 후 변경 사항이 반영되지 않으면 강력 새로고침(`Ctrl + F5`)을 수행하세요.
|
||||
BIN
history.txt
Normal file
BIN
history.txt
Normal file
Binary file not shown.
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "smartims",
|
||||
"private": true,
|
||||
"version": "0.2.0",
|
||||
"version": "0.2.5",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
36
server/fix_admin.js
Normal file
36
server/fix_admin.js
Normal file
@ -0,0 +1,36 @@
|
||||
const db = require('./db');
|
||||
const crypto = require('crypto');
|
||||
|
||||
async function fixAdmin() {
|
||||
try {
|
||||
console.log('Altering table schema to include supervisor role...');
|
||||
// First ensure the ENUM includes supervisor
|
||||
await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'");
|
||||
|
||||
const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex');
|
||||
console.log('Updating admin user...');
|
||||
|
||||
// Update both password and role for admin
|
||||
const [result] = await db.query(
|
||||
'UPDATE users SET password = ?, role = "supervisor" WHERE id = "admin"',
|
||||
[hashedPass]
|
||||
);
|
||||
|
||||
if (result.affectedRows > 0) {
|
||||
console.log('✅ Admin user updated to Supervisor with password admin123');
|
||||
} else {
|
||||
console.log('⚠️ Admin user not found. Creating new admin...');
|
||||
await db.query(
|
||||
'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
['admin', hashedPass, '시스템 관리자', 'supervisor', 'IT팀', '관리자']
|
||||
);
|
||||
console.log('✅ Admin user created as Supervisor with password admin123');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to fix admin user:', err);
|
||||
} finally {
|
||||
process.exit();
|
||||
}
|
||||
}
|
||||
|
||||
fixAdmin();
|
||||
@ -66,12 +66,13 @@ app.use(session({
|
||||
key: 'smartims_sid',
|
||||
secret: process.env.SESSION_SECRET || 'smartims_session_secret_key',
|
||||
store: sessionStore,
|
||||
resave: true, // Force save to avoid session loss in some environments
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
rolling: false, // Do not automatic rolling (we control it in middleware)
|
||||
cookie: {
|
||||
httpOnly: true,
|
||||
secure: false, // Set true if using HTTPS
|
||||
maxAge: null, // Browser session by default
|
||||
secure: false, // HTTPS 사용 시 true로 변경 필요
|
||||
maxAge: 3600000, // 기본 1시간 (미들웨어에서 동적 조정)
|
||||
sameSite: 'lax'
|
||||
}
|
||||
}));
|
||||
@ -79,13 +80,24 @@ app.use(session({
|
||||
// Dynamic Session Timeout Middleware
|
||||
app.use(async (req, res, next) => {
|
||||
if (req.session && req.session.user) {
|
||||
// Skip session extension for background check requests
|
||||
// These requests are prefixed by /api from the client but might be handled differently in middleware
|
||||
// Checking both common forms for safety
|
||||
if (req.path === '/api/check' || req.path === '/check' || req.path.includes('/auth/check')) {
|
||||
return next();
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
|
||||
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
|
||||
req.session.cookie.maxAge = timeoutMinutes * 60 * 1000;
|
||||
|
||||
// Explicitly save session to ensure store sync
|
||||
req.session.save();
|
||||
// Explicitly save session before moving to next middleware
|
||||
req.session.save((err) => {
|
||||
if (err) console.error('Session save error:', err);
|
||||
next();
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
console.error('Session timeout fetch error:', err);
|
||||
}
|
||||
@ -98,7 +110,10 @@ app.use(csrfProtection);
|
||||
|
||||
// Request Logger
|
||||
app.use((req, res, next) => {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||
const now = new Date();
|
||||
// UTC 시간에 9시간을 더한 뒤 ISO 문자열로 변환하고 끝의 'Z'를 제거하여 한국 시간 형식 생성
|
||||
const kstDate = new Date(now.getTime() + (9 * 60 * 60 * 1000)).toISOString().replace('Z', '');
|
||||
console.log(`[${kstDate}] ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
@ -180,14 +195,21 @@ const initTables = async () => {
|
||||
department VARCHAR(100),
|
||||
position VARCHAR(100),
|
||||
phone VARCHAR(255),
|
||||
role ENUM('admin', 'user') DEFAULT 'user',
|
||||
role ENUM('supervisor', 'admin', 'user') DEFAULT 'user',
|
||||
last_login TIMESTAMP NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
`;
|
||||
await db.query(usersTableSQL);
|
||||
console.log('✅ Users Table Created');
|
||||
|
||||
// Update existing table if needed
|
||||
try {
|
||||
await db.query("ALTER TABLE users MODIFY COLUMN role ENUM('supervisor', 'admin', 'user') DEFAULT 'user'");
|
||||
} catch (e) {
|
||||
// Ignore if it fails (e.g. column doesn't exist yet handled by SQL above)
|
||||
}
|
||||
console.log('✅ Users Table Initialized with Supervisor role');
|
||||
|
||||
// Default Admin
|
||||
const adminId = 'admin';
|
||||
@ -197,9 +219,12 @@ const initTables = async () => {
|
||||
const hashedPass = crypto.createHash('sha256').update('admin123').digest('hex');
|
||||
await db.query(
|
||||
'INSERT INTO users (id, password, name, role, department, position) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[adminId, hashedPass, '시스템 관리자', 'admin', 'IT팀', '관리자']
|
||||
[adminId, hashedPass, '관리자', 'supervisor', 'IT팀', '관리자']
|
||||
);
|
||||
console.log('✅ Default Admin Created (admin / admin123)');
|
||||
console.log('✅ Default Admin Created as Supervisor');
|
||||
} else {
|
||||
// Ensure existing admin has supervisor role for this transition
|
||||
await db.query('UPDATE users SET role = "supervisor" WHERE id = ?', [adminId]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -333,8 +358,14 @@ const initTables = async () => {
|
||||
};
|
||||
initTables();
|
||||
|
||||
const packageJson = require('./package.json');
|
||||
|
||||
app.get('/api/health', (req, res) => {
|
||||
res.json({ status: 'ok', version: '1.2.0', timestamp: '2026-01-22 21:18' });
|
||||
res.json({
|
||||
status: 'ok',
|
||||
version: packageJson.version,
|
||||
timestamp: new Date().toISOString().replace('T', ' ').split('.')[0]
|
||||
});
|
||||
});
|
||||
|
||||
// Routes
|
||||
|
||||
@ -1,3 +1,15 @@
|
||||
const ROLES = {
|
||||
SUPERVISOR: 'supervisor',
|
||||
ADMIN: 'admin',
|
||||
USER: 'user'
|
||||
};
|
||||
|
||||
const HIERARCHY = {
|
||||
[ROLES.SUPERVISOR]: 100,
|
||||
[ROLES.ADMIN]: 50,
|
||||
[ROLES.USER]: 10
|
||||
};
|
||||
|
||||
const isAuthenticated = (req, res, next) => {
|
||||
if (req.session && req.session.user) {
|
||||
return next();
|
||||
@ -5,13 +17,17 @@ const isAuthenticated = (req, res, next) => {
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
};
|
||||
|
||||
const hasRole = (...roles) => {
|
||||
const hasRole = (requiredRole) => {
|
||||
return (req, res, next) => {
|
||||
if (!req.session || !req.session.user) {
|
||||
return res.status(401).json({ success: false, message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
if (roles.includes(req.session.user.role)) {
|
||||
const userRole = req.session.user.role;
|
||||
const userLevel = HIERARCHY[userRole] || 0;
|
||||
const requiredLevel = HIERARCHY[requiredRole] || 999;
|
||||
|
||||
if (userLevel >= requiredLevel) {
|
||||
return next();
|
||||
}
|
||||
|
||||
@ -19,4 +35,4 @@ const hasRole = (...roles) => {
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = { isAuthenticated, hasRole };
|
||||
module.exports = { isAuthenticated, hasRole, ROLES };
|
||||
|
||||
@ -28,6 +28,7 @@ const csrfProtection = (req, res, next) => {
|
||||
console.error(`- Path: ${req.path}`);
|
||||
console.error(`- Session ID: ${req.sessionID ? req.sessionID.substring(0, 8) + '...' : 'NONE'}`);
|
||||
console.error(`- Session User: ${req.session?.user?.id || 'GUEST'}`);
|
||||
console.error(`- Session MaxAge: ${req.session?.cookie?.maxAge / 1000 / 60} min`);
|
||||
console.error(`- Token in Session: ${tokenFromSession ? 'EXISTS (' + tokenFromSession.substring(0, 5) + '...)' : 'MISSING'}`);
|
||||
console.error(`- Token in Header: ${tokenFromHeader ? 'EXISTS (' + tokenFromHeader.substring(0, 5) + '...)' : 'MISSING'}`);
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.5",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
@ -28,4 +28,4 @@
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.1.11"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -5,54 +5,14 @@ const crypto = require('crypto');
|
||||
const { isAuthenticated, hasRole } = require('../middleware/authMiddleware');
|
||||
const { generateToken } = require('../middleware/csrfMiddleware');
|
||||
|
||||
// --- Crypto Utilities ---
|
||||
// Use a fixed key for MVP. In production, store this securely in .env
|
||||
// Key must be 32 bytes for aes-256-cbc
|
||||
// 'my_super_secret_key_manage_asset' is 32 chars?
|
||||
// let's use a simpler approach to ensure length on startup or fallback
|
||||
const SECRET_KEY = process.env.ENCRYPTION_KEY || 'smartims_secret_key_0123456789'; // 32 chars needed
|
||||
// Ideally use a buffer from hex, but string is okay if 32 chars.
|
||||
// Let's pad it to ensure stability if env is missing.
|
||||
const keyBuffer = crypto.scryptSync(SECRET_KEY, 'salt', 32);
|
||||
const cryptoUtil = require('../utils/cryptoUtil');
|
||||
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
|
||||
function encrypt(text) {
|
||||
if (!text) return text;
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
async function encrypt(text) {
|
||||
return await cryptoUtil.encrypt(text);
|
||||
}
|
||||
|
||||
function decrypt(text) {
|
||||
if (!text) return text;
|
||||
// Check if it looks like our encrypted format (hexIV:hexContent)
|
||||
if (!text.includes(':')) {
|
||||
return text; // Assume plain text if no separator
|
||||
}
|
||||
|
||||
try {
|
||||
const textParts = text.split(':');
|
||||
const ivHex = textParts.shift();
|
||||
|
||||
// IV for AES-256-CBC must be 16 bytes (32 hex characters)
|
||||
if (!ivHex || ivHex.length !== 32) {
|
||||
return text; // Invalid IV length, return original
|
||||
}
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const encryptedText = textParts.join(':');
|
||||
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
|
||||
let decrypted = decipher.update(encryptedText, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
} catch (e) {
|
||||
console.error('Decryption failed for:', text, e.message);
|
||||
return text; // Return original if fail
|
||||
}
|
||||
async function decrypt(text) {
|
||||
return await cryptoUtil.decrypt(text);
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
@ -77,7 +37,7 @@ router.post('/login', async (req, res) => {
|
||||
delete user.password;
|
||||
|
||||
// Should we decrypt phone? Maybe not needed for session, but let's decrypt just in case UI needs it
|
||||
if (user.phone) user.phone = decrypt(user.phone);
|
||||
if (user.phone) user.phone = await decrypt(user.phone);
|
||||
|
||||
// Save user to session
|
||||
req.session.user = user;
|
||||
@ -104,19 +64,30 @@ router.post('/login', async (req, res) => {
|
||||
});
|
||||
|
||||
// 1.5. Check Session (New)
|
||||
router.get('/check', (req, res) => {
|
||||
if (req.session.user) {
|
||||
// Ensure CSRF token exists, if not generate one (edge case)
|
||||
if (!req.session.csrfToken) {
|
||||
req.session.csrfToken = generateToken();
|
||||
router.get('/check', async (req, res) => {
|
||||
try {
|
||||
if (req.session.user) {
|
||||
// Ensure CSRF token exists, if not generate one (edge case)
|
||||
if (!req.session.csrfToken) {
|
||||
req.session.csrfToken = generateToken();
|
||||
}
|
||||
|
||||
// Fetch session timeout from settings
|
||||
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'session_timeout'");
|
||||
const timeoutMinutes = rows.length > 0 ? parseInt(rows[0].setting_value) : 60;
|
||||
|
||||
res.json({
|
||||
isAuthenticated: true,
|
||||
user: req.session.user,
|
||||
csrfToken: req.session.csrfToken,
|
||||
sessionTimeout: timeoutMinutes * 60 * 1000 // Convert to ms
|
||||
});
|
||||
} else {
|
||||
res.json({ isAuthenticated: false });
|
||||
}
|
||||
res.json({
|
||||
isAuthenticated: true,
|
||||
user: req.session.user,
|
||||
csrfToken: req.session.csrfToken
|
||||
});
|
||||
} else {
|
||||
res.json({ isAuthenticated: false });
|
||||
} catch (err) {
|
||||
console.error('Check session error:', err);
|
||||
res.status(500).json({ success: false, message: 'Server error' });
|
||||
}
|
||||
});
|
||||
|
||||
@ -132,21 +103,52 @@ router.post('/logout', (req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// 1.7 Verify Supervisor (For sensitive settings)
|
||||
router.post('/verify-supervisor', isAuthenticated, async (req, res) => {
|
||||
const { password } = req.body;
|
||||
if (!password) return res.status(400).json({ error: 'Password required' });
|
||||
|
||||
try {
|
||||
if (req.session.user.role !== 'supervisor') {
|
||||
return res.status(403).json({ error: '권한이 없습니다. 최고관리자만 접근 가능합니다.' });
|
||||
}
|
||||
|
||||
const hashedPassword = hashPassword(password);
|
||||
const [rows] = await db.query('SELECT 1 FROM users WHERE id = ? AND password = ?', [req.session.user.id, hashedPassword]);
|
||||
|
||||
if (rows.length > 0) {
|
||||
res.json({ success: true, message: 'Verification successful' });
|
||||
} else {
|
||||
res.status(401).json({ success: false, message: '비밀번호가 일치하지 않습니다.' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Verify supervisor error:', err);
|
||||
res.status(500).json({ error: '인증 처리 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
// 2. List Users (Admin Only)
|
||||
router.get('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
// ideally check req.user.role if we had middleware, for now assuming client logic protection + internal/local usage
|
||||
const [rows] = await db.query('SELECT id, name, department, position, phone, role, last_login, created_at, updated_at FROM users ORDER BY created_at DESC');
|
||||
|
||||
const users = rows.map(u => ({
|
||||
...u,
|
||||
phone: decrypt(u.phone) // Decrypt phone for admin view
|
||||
if (!rows || rows.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
// Use Promise.all for safe async decryption
|
||||
const users = await Promise.all(rows.map(async (u) => {
|
||||
const decryptedPhone = await decrypt(u.phone);
|
||||
return {
|
||||
...u,
|
||||
phone: decryptedPhone
|
||||
};
|
||||
}));
|
||||
|
||||
res.json(users);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
console.error('Failed to list users:', err);
|
||||
res.status(500).json({ error: '데이터를 불러오는 중 오류가 발생했습니다.' });
|
||||
}
|
||||
});
|
||||
|
||||
@ -166,7 +168,7 @@ router.post('/users', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
}
|
||||
|
||||
const hashedPassword = hashPassword(password);
|
||||
const encryptedPhone = encrypt(phone);
|
||||
const encryptedPhone = await encrypt(phone);
|
||||
|
||||
const sql = `
|
||||
INSERT INTO users (id, password, name, department, position, phone, role)
|
||||
@ -213,7 +215,7 @@ router.put('/users/:id', isAuthenticated, hasRole('admin'), async (req, res) =>
|
||||
}
|
||||
if (phone !== undefined) {
|
||||
updates.push('phone = ?');
|
||||
params.push(encrypt(phone));
|
||||
params.push(await encrypt(phone));
|
||||
}
|
||||
if (role) {
|
||||
updates.push('role = ?');
|
||||
|
||||
@ -21,16 +21,72 @@ try {
|
||||
console.error('❌ Error loading public key:', e);
|
||||
}
|
||||
|
||||
// Helper to check if a setting key is allowed for general get/post
|
||||
// This prevents modification of sensitive keys if any
|
||||
const ALLOWED_SETTING_KEYS = [
|
||||
'subscriber_id',
|
||||
'session_timeout',
|
||||
'encryption_key',
|
||||
'asset_id_rule',
|
||||
'asset_categories',
|
||||
'asset_locations',
|
||||
'asset_statuses',
|
||||
'asset_maintenance_types'
|
||||
];
|
||||
|
||||
// --- .env File Utilities ---
|
||||
const envPath = path.join(__dirname, '../.env');
|
||||
|
||||
const readEnv = () => {
|
||||
if (!fs.existsSync(envPath)) return {};
|
||||
const content = fs.readFileSync(envPath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const env = {};
|
||||
lines.forEach(line => {
|
||||
const match = line.match(/^\s*([\w.-]+)\s*=\s*(.*)?\s*$/);
|
||||
if (match) {
|
||||
env[match[1]] = match[2] ? match[2].trim() : '';
|
||||
}
|
||||
});
|
||||
return env;
|
||||
};
|
||||
|
||||
const writeEnv = (updates) => {
|
||||
let content = fs.readFileSync(envPath, 'utf8');
|
||||
Object.entries(updates).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`^${key}=.*`, 'm');
|
||||
if (regex.test(content)) {
|
||||
content = content.replace(regex, `${key}=${value}`);
|
||||
} else {
|
||||
content += `\n${key}=${value}`;
|
||||
}
|
||||
});
|
||||
fs.writeFileSync(envPath, content, 'utf8');
|
||||
};
|
||||
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
// 0. Server Configuration (Subscriber ID & Session Timeout)
|
||||
router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout')");
|
||||
const [rows] = await db.query("SELECT setting_key, setting_value FROM system_settings WHERE setting_key IN ('subscriber_id', 'session_timeout', 'encryption_key')");
|
||||
const settings = {};
|
||||
rows.forEach(r => settings[r.setting_key] = r.setting_value);
|
||||
|
||||
// Include .env DB settings
|
||||
const env = readEnv();
|
||||
|
||||
res.json({
|
||||
subscriber_id: settings.subscriber_id || '',
|
||||
session_timeout: parseInt(settings.session_timeout) || 60 // Default 60 min
|
||||
session_timeout: parseInt(settings.session_timeout) || 60,
|
||||
encryption_key: settings.encryption_key || '',
|
||||
db_config: {
|
||||
host: env.DB_HOST || '',
|
||||
user: env.DB_USER || '',
|
||||
password: env.DB_PASSWORD || '',
|
||||
database: env.DB_NAME || '',
|
||||
port: env.DB_PORT || '3306'
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@ -39,7 +95,7 @@ router.get('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { subscriber_id, session_timeout } = req.body;
|
||||
const { subscriber_id, session_timeout, encryption_key, db_config } = req.body;
|
||||
|
||||
try {
|
||||
if (subscriber_id !== undefined) {
|
||||
@ -48,7 +104,147 @@ router.post('/settings', isAuthenticated, hasRole('admin'), async (req, res) =>
|
||||
if (session_timeout !== undefined) {
|
||||
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('session_timeout', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [session_timeout.toString()]);
|
||||
}
|
||||
res.json({ message: 'Settings saved' });
|
||||
if (encryption_key !== undefined) {
|
||||
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(encryption_key);
|
||||
await db.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
|
||||
}
|
||||
|
||||
// Handle .env DB settings
|
||||
if (db_config) {
|
||||
writeEnv({
|
||||
DB_HOST: db_config.host,
|
||||
DB_USER: db_config.user,
|
||||
DB_PASSWORD: db_config.password,
|
||||
DB_NAME: db_config.database,
|
||||
DB_PORT: db_config.port
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: 'Settings saved. Server may restart to apply DB changes.' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
// --- Crypto & Key Rotation ---
|
||||
const cryptoUtil = require('../utils/cryptoUtil');
|
||||
|
||||
// 0.2 Test DB Connection
|
||||
router.post('/test-db', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { host, user, password, database, port } = req.body;
|
||||
|
||||
let conn;
|
||||
try {
|
||||
conn = await mysql.createConnection({
|
||||
host,
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
port: parseInt(port) || 3306,
|
||||
connectTimeout: 5000
|
||||
});
|
||||
await conn.query('SELECT 1');
|
||||
res.json({ success: true, message: '연결 성공: 데이터베이스에 성공적으로 접속되었습니다.' });
|
||||
} catch (err) {
|
||||
res.status(400).json({ success: false, error: err.message });
|
||||
} finally {
|
||||
if (conn) await conn.end();
|
||||
}
|
||||
});
|
||||
|
||||
// 0.3 Encryption Key Management & Rotation
|
||||
router.get('/encryption/status', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
try {
|
||||
const [userRows] = await db.query('SELECT COUNT(*) as count FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"');
|
||||
const currentKey = await cryptoUtil.getMasterKey();
|
||||
|
||||
res.json({
|
||||
current_key: currentKey,
|
||||
affected_records: {
|
||||
users: userRows[0].count
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch encryption status' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/encryption/rotate', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { new_key } = req.body;
|
||||
if (!new_key) return res.status(400).json({ error: 'New key is required' });
|
||||
|
||||
const conn = await db.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const oldKey = await cryptoUtil.getMasterKey();
|
||||
|
||||
// 1. Migrate Users Table (phone)
|
||||
const [users] = await conn.query('SELECT id, phone FROM users WHERE phone IS NOT NULL AND phone LIKE "%:%"');
|
||||
for (const user of users) {
|
||||
const decrypted = await cryptoUtil.decrypt(user.phone, oldKey);
|
||||
const reEncrypted = await cryptoUtil.encrypt(decrypted, new_key);
|
||||
await conn.query('UPDATE users SET phone = ? WHERE id = ?', [reEncrypted, user.id]);
|
||||
}
|
||||
|
||||
// 2. Update Master Key in settings (Encrypted for DB storage)
|
||||
const encryptedKeyForDb = cryptoUtil.encryptMasterKey(new_key);
|
||||
await conn.query(`INSERT INTO system_settings (setting_key, setting_value) VALUES ('encryption_key', ?) ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`, [encryptedKeyForDb]);
|
||||
|
||||
await conn.commit();
|
||||
cryptoUtil.clearCache(); // Force immediate reload of new key
|
||||
|
||||
res.json({ success: true, message: 'Encryption key rotated and data migrated successfully.' });
|
||||
} catch (err) {
|
||||
await conn.rollback();
|
||||
console.error('Rotation failed:', err);
|
||||
res.status(500).json({ error: 'Key rotation failed: ' + err.message });
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 0-1. Generic Setting Get/Set
|
||||
router.get('/settings/:key', isAuthenticated, async (req, res) => {
|
||||
const { key } = req.params;
|
||||
if (!ALLOWED_SETTING_KEYS.includes(key)) {
|
||||
return res.status(400).json({ error: 'Invalid setting key' });
|
||||
}
|
||||
|
||||
try {
|
||||
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = ?", [key]);
|
||||
if (rows.length === 0) return res.json({ value: null });
|
||||
res.json({ value: rows[0].setting_value });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/settings/:key', isAuthenticated, hasRole('admin'), async (req, res) => {
|
||||
const { key } = req.params;
|
||||
const { value } = req.body;
|
||||
|
||||
if (!ALLOWED_SETTING_KEYS.includes(key)) {
|
||||
return res.status(400).json({ error: 'Invalid setting key' });
|
||||
}
|
||||
|
||||
try {
|
||||
let stringValue = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
|
||||
// Special handling for encryption_key to protect it in DB
|
||||
if (key === 'encryption_key') {
|
||||
stringValue = cryptoUtil.encryptMasterKey(stringValue);
|
||||
}
|
||||
|
||||
await db.query(
|
||||
`INSERT INTO system_settings (setting_key, setting_value)
|
||||
VALUES (?, ?)
|
||||
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value)`,
|
||||
[key, stringValue]
|
||||
);
|
||||
res.json({ success: true, message: 'Setting saved' });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
res.status(500).json({ error: 'Database error' });
|
||||
|
||||
111
server/utils/cryptoUtil.js
Normal file
111
server/utils/cryptoUtil.js
Normal file
@ -0,0 +1,111 @@
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db');
|
||||
|
||||
const ALGORITHM = 'aes-256-cbc';
|
||||
const SYSTEM_INTERNAL_KEY = 'ims_system_l2_internal_protection_key_2026'; // 고정 시스템 키 (2단계 보안용)
|
||||
let cachedKey = null;
|
||||
|
||||
const cryptoUtil = {
|
||||
/**
|
||||
* 내부 보안용 암호화 (마스터 키 보호용)
|
||||
*/
|
||||
_internalProcess(text, isEncrypt = true) {
|
||||
if (!text) return text;
|
||||
try {
|
||||
const keyBuffer = crypto.scryptSync(SYSTEM_INTERNAL_KEY, 'ims_salt', 32);
|
||||
if (isEncrypt) {
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
} else {
|
||||
if (!text.includes(':')) return text; // 평문인 경우 그대로 반환
|
||||
const [ivHex, cipherText] = text.split(':');
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
|
||||
let decrypted = decipher.update(cipherText, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
}
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get key from DB or Cache
|
||||
*/
|
||||
async getMasterKey() {
|
||||
if (cachedKey) return cachedKey;
|
||||
try {
|
||||
const [rows] = await db.query("SELECT setting_value FROM system_settings WHERE setting_key = 'encryption_key'");
|
||||
if (rows.length > 0 && rows[0].setting_value) {
|
||||
const rawValue = rows[0].setting_value;
|
||||
// DB에 저장된 값이 암호화된 형태(iv 포함)라면 복호화하여 사용
|
||||
if (rawValue.includes(':')) {
|
||||
cachedKey = this._internalProcess(rawValue, false);
|
||||
} else {
|
||||
cachedKey = rawValue;
|
||||
}
|
||||
return cachedKey;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('CryptoUtil: Failed to fetch key', e);
|
||||
}
|
||||
return process.env.ENCRYPTION_KEY || 'smartasset_secret_key_0123456789';
|
||||
},
|
||||
|
||||
/**
|
||||
* 마스터 키를 DB에 저장하기 전 암호화하는 함수
|
||||
*/
|
||||
encryptMasterKey(plainKey) {
|
||||
return this._internalProcess(plainKey, true);
|
||||
},
|
||||
|
||||
clearCache() {
|
||||
cachedKey = null;
|
||||
},
|
||||
|
||||
/**
|
||||
* Encrypt with specific key (optional, defaults to master)
|
||||
*/
|
||||
async encrypt(text, customKey = null) {
|
||||
if (!text) return text;
|
||||
try {
|
||||
const secret = customKey || await this.getMasterKey();
|
||||
const keyBuffer = crypto.scryptSync(secret, 'salt', 32);
|
||||
const iv = crypto.randomBytes(16);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, keyBuffer, iv);
|
||||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||||
encrypted += cipher.final('hex');
|
||||
return iv.toString('hex') + ':' + encrypted;
|
||||
} catch (e) {
|
||||
console.error('Encryption failed:', e.message);
|
||||
return text;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Decrypt with specific key (optional, defaults to master)
|
||||
*/
|
||||
async decrypt(text, customKey = null) {
|
||||
if (!text || !text.includes(':')) return text;
|
||||
try {
|
||||
const secret = customKey || await this.getMasterKey();
|
||||
const keyBuffer = crypto.scryptSync(secret, 'salt', 32);
|
||||
const [ivHex, cipherText] = text.split(':');
|
||||
if (!ivHex || ivHex.length !== 32) return text;
|
||||
|
||||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, keyBuffer, iv);
|
||||
let decrypted = decipher.update(cipherText, 'hex', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
return decrypted;
|
||||
} catch (e) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = cryptoUtil;
|
||||
@ -6,24 +6,25 @@
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
.page-header-right {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
text-align: left;
|
||||
/* Explicitly set text align */
|
||||
text-align: right;
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.page-title-text {
|
||||
font-size: 1.5rem;
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--sokuree-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: 0.9rem;
|
||||
color: var(--sokuree-text-secondary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
|
||||
@ -23,6 +23,7 @@ export function AssetListPage() {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [assets, setAssets] = useState<Asset[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const itemsPerPage = 8;
|
||||
|
||||
// Fetch Assets
|
||||
@ -33,10 +34,17 @@ export function AssetListPage() {
|
||||
const loadAssets = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
const data = await assetApi.getAll();
|
||||
setAssets(data);
|
||||
if (Array.isArray(data)) {
|
||||
setAssets(data);
|
||||
} else {
|
||||
console.error("API returned non-array data:", data);
|
||||
setAssets([]);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("Failed to fetch assets:", error);
|
||||
setError("데이터를 불러오는 중 오류가 발생했습니다. 서버 연결을 확인해 주세요.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
@ -98,8 +106,10 @@ export function AssetListPage() {
|
||||
// Let's make it strict if possible, or robust for '설비' vs '설비 자산'.
|
||||
|
||||
if (asset.category !== currentCategory) {
|
||||
// Fallback for partial matches if needed, but let's try strict first based on user request.
|
||||
if (!asset.category.includes(currentCategory)) return false;
|
||||
// partial match fallback (e.g., '시설' in '시설 자산' or vice versa)
|
||||
const assetCat = asset.category || '';
|
||||
const targetCat = currentCategory || '';
|
||||
if (!assetCat.includes(targetCat) && !targetCat.includes(assetCat)) return false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -240,13 +250,11 @@ export function AssetListPage() {
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header" style={{ justifyContent: 'flex-start', textAlign: 'left' }}>
|
||||
<div>
|
||||
<h1 className="page-title-text">{getPageTitle()}</h1>
|
||||
<p className="page-subtitle">
|
||||
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="page-header-right">
|
||||
<h1 className="page-title-text">{getPageTitle()}</h1>
|
||||
<p className="page-subtitle">
|
||||
{currentCategory ? `${currentCategory} 카테고리에 등록된 자산 목록입니다.` : '전체 자산의 실시간 현황을 조회합니다.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card className="content-card">
|
||||
@ -395,9 +403,18 @@ export function AssetListPage() {
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td colSpan={10} className="empty-state text-red-500">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<span>⚠️ {error}</span>
|
||||
<Button size="sm" variant="secondary" onClick={loadAssets}>다시 시도</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={9} className="empty-state">
|
||||
<td colSpan={10} className="empty-state">
|
||||
데이터가 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
43
src/modules/asset/pages/AssetRegisterPage.css
Normal file
43
src/modules/asset/pages/AssetRegisterPage.css
Normal file
@ -0,0 +1,43 @@
|
||||
/* AssetRegisterPage.css */
|
||||
|
||||
|
||||
|
||||
/* Page Header - Right Aligned */
|
||||
.page-header-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.page-title-text {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--sokuree-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--sokuree-text-secondary);
|
||||
}
|
||||
|
||||
/* Bottom Actions Styling */
|
||||
.form-actions-footer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
margin-top: 3rem;
|
||||
margin-bottom: 2rem;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--sokuree-border-color);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-actions-footer .ui-btn {
|
||||
min-width: 120px;
|
||||
}
|
||||
@ -7,6 +7,7 @@ import { Select } from '../../../shared/ui/Select';
|
||||
import { ArrowLeft, Save, Upload } from 'lucide-react';
|
||||
import { getCategories, getLocations, getIDRule } from './AssetSettingsPage';
|
||||
import { assetApi, type Asset } from '../../../shared/api/assetApi';
|
||||
import './AssetRegisterPage.css';
|
||||
|
||||
export function AssetRegisterPage() {
|
||||
const navigate = useNavigate();
|
||||
@ -51,13 +52,11 @@ export function AssetRegisterPage() {
|
||||
if (part.type === 'separator') return part.value;
|
||||
if (part.type === 'year') return year;
|
||||
if (part.type === 'category') return category ? category.code : 'UNKNOWN';
|
||||
if (part.type === 'sequence') return part.value; // In real app, we fetch next seq. here we just show the format pattern e.g. 001
|
||||
if (part.type === 'sequence') return part.value;
|
||||
return '';
|
||||
}).join('');
|
||||
|
||||
// Ideally we would fetch the *actual* next sequence from API here.
|
||||
// For now we assume '001' as a placeholder or "Generating..."
|
||||
const finalId = generatedId.replace('001', '001'); // Just keeping the placeholder visible for user confirmation
|
||||
const finalId = generatedId.replace('001', '001');
|
||||
|
||||
setFormData(prev => ({ ...prev, id: finalId }));
|
||||
|
||||
@ -87,7 +86,7 @@ export function AssetRegisterPage() {
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (e) e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!formData.categoryId || !formData.name || !formData.locationId) {
|
||||
@ -96,11 +95,9 @@ export function AssetRegisterPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
// Map IDs to Names/Codes for Backend
|
||||
const selectedCategory = categories.find(c => c.id === formData.categoryId);
|
||||
const selectedLocation = locations.find(l => l.id === formData.locationId);
|
||||
|
||||
// Upload Image if exists
|
||||
let imageUrl = '';
|
||||
if (formData.image) {
|
||||
const uploadRes = await assetApi.uploadImage(formData.image);
|
||||
@ -110,7 +107,7 @@ export function AssetRegisterPage() {
|
||||
const payload: Partial<Asset> = {
|
||||
id: formData.id,
|
||||
name: formData.name,
|
||||
category: selectedCategory ? selectedCategory.name : '미지정', // Backend expects name
|
||||
category: selectedCategory ? selectedCategory.name : '미지정',
|
||||
model: formData.model,
|
||||
serialNumber: formData.serialNo,
|
||||
location: selectedLocation ? selectedLocation.name : '미지정',
|
||||
@ -134,196 +131,208 @@ export function AssetRegisterPage() {
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title-text">자산 등록</h1>
|
||||
<p className="page-subtitle">새로운 자산을 시스템에 등록합니다.</p>
|
||||
</div>
|
||||
<div className="header-actions-row">
|
||||
<Button variant="secondary" onClick={() => navigate(-1)} icon={<ArrowLeft size={16} />}>취소</Button>
|
||||
<Button onClick={handleSubmit} icon={<Save size={16} />}>저장</Button>
|
||||
</div>
|
||||
<div className="page-header-right">
|
||||
<h1 className="page-title-text">자산 등록</h1>
|
||||
<p className="page-subtitle">새로운 자산을 시스템에 등록합니다.</p>
|
||||
</div>
|
||||
|
||||
<Card className="w-full h-full shadow-sm border border-slate-200">
|
||||
<form onSubmit={handleSubmit} className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Basic Info */}
|
||||
<div className="col-span-full border-b border-slate-100 pb-2 mb-2">
|
||||
<h3 className="text-lg font-semibold text-slate-800">기본 정보</h3>
|
||||
</div>
|
||||
<Card className="w-full shadow-sm border border-slate-200 mb-8">
|
||||
<form onSubmit={handleSubmit} className="p-2 sm:p-4 lg:p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-6">
|
||||
{/* Basic Info Section */}
|
||||
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2">
|
||||
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
|
||||
<h3 className="text-xl font-bold text-slate-800">기본 정보</h3>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="카테고리 *"
|
||||
name="categoryId"
|
||||
value={formData.categoryId}
|
||||
onChange={handleChange}
|
||||
options={categories.map(c => ({ label: c.name, value: c.id }))}
|
||||
placeholder="카테고리 선택"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="카테고리 *"
|
||||
name="categoryId"
|
||||
value={formData.categoryId}
|
||||
onChange={handleChange}
|
||||
options={categories.map(c => ({ label: c.name, value: c.id }))}
|
||||
placeholder="카테고리 선택"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="자산 관리 번호 (자동 생성)"
|
||||
name="id"
|
||||
value={formData.id}
|
||||
disabled
|
||||
placeholder="카테고리 선택 시 자동 생성됨"
|
||||
className="bg-slate-50 font-mono text-slate-600"
|
||||
/>
|
||||
<Input
|
||||
label="자산 관리 번호 (자동 생성)"
|
||||
name="id"
|
||||
value={formData.id}
|
||||
disabled
|
||||
placeholder="카테고리 선택 시 자동 생성됨"
|
||||
className="bg-slate-50 font-mono text-slate-600"
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="자산명 *"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="예: CNC 머시닝 센터"
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="자산명 *"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleChange}
|
||||
placeholder="예: CNC 머시닝 센터"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="제작사"
|
||||
name="manufacturer"
|
||||
value={formData.manufacturer}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Input
|
||||
label="제작사"
|
||||
name="manufacturer"
|
||||
value={formData.manufacturer}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="모델명"
|
||||
name="model"
|
||||
value={formData.model}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Input
|
||||
label="모델명"
|
||||
name="model"
|
||||
value={formData.model}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="시리얼 번호 / 규격"
|
||||
name="serialNo"
|
||||
value={formData.serialNo}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<Input
|
||||
label="시리얼 번호 / 규격"
|
||||
name="serialNo"
|
||||
value={formData.serialNo}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
{/* Image Upload Field */}
|
||||
<div className="ui-field-container col-span-full">
|
||||
<label className="ui-label">자산 이미지</label>
|
||||
<div className="w-full bg-white border border-slate-200 rounded-md shadow-sm p-4 flex flex-col items-start gap-4" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '16px' }}>
|
||||
{/* Preview Area - Fixed size container */}
|
||||
<div
|
||||
className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
|
||||
style={{
|
||||
width: '400px',
|
||||
height: '350px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{formData.imagePreview ? (
|
||||
<img
|
||||
src={formData.imagePreview}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-contain"
|
||||
{/* Image Upload Field */}
|
||||
<div className="ui-field-container col-span-full">
|
||||
<label className="ui-label">자산 이미지</label>
|
||||
<div className="w-full bg-white border border-slate-200 rounded-md shadow-sm p-4 flex flex-col items-start gap-4" style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '16px' }}>
|
||||
<div
|
||||
className="shrink-0 bg-slate-50 border border-slate-200 rounded-md overflow-hidden"
|
||||
style={{
|
||||
width: '400px',
|
||||
height: '350px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
}}
|
||||
>
|
||||
{formData.imagePreview ? (
|
||||
<img
|
||||
src={formData.imagePreview}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="text-slate-300"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<Upload size={32} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-center flex-wrap w-full max-w-[600px]" style={{ gap: '20px' }}>
|
||||
<label htmlFor="image-upload" className="ui-btn ui-btn-sm ui-btn-secondary cursor-pointer shrink-0">
|
||||
파일 선택
|
||||
</label>
|
||||
|
||||
<span className="text-sm text-slate-600 font-medium truncate max-w-[200px]">
|
||||
{formData.image ? formData.image.name : '선택된 파일 없음'}
|
||||
</span>
|
||||
|
||||
<span className="text-sm text-slate-400 border-l border-slate-300 pl-4" style={{ paddingLeft: '20px' }}>
|
||||
지원 형식: JPG, PNG, GIF (최대 5MB)
|
||||
</span>
|
||||
|
||||
<input
|
||||
id="image-upload"
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className="text-slate-300"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: '100%'
|
||||
}}
|
||||
>
|
||||
<Upload size={32} strokeWidth={1.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Upload Controls - Moved below */}
|
||||
<div className="flex flex-row items-center flex-wrap w-full max-w-[600px]" style={{ gap: '20px' }}>
|
||||
<label htmlFor="image-upload" className="ui-btn ui-btn-sm ui-btn-secondary cursor-pointer shrink-0">
|
||||
파일 선택
|
||||
</label>
|
||||
|
||||
<span className="text-sm text-slate-600 font-medium truncate max-w-[200px]">
|
||||
{formData.image ? formData.image.name : '선택된 파일 없음'}
|
||||
</span>
|
||||
|
||||
<span className="text-sm text-slate-400 border-l border-slate-300 pl-4" style={{ paddingLeft: '20px' }}>
|
||||
지원 형식: JPG, PNG, GIF (최대 5MB)
|
||||
</span>
|
||||
|
||||
<input
|
||||
id="image-upload"
|
||||
type="file"
|
||||
style={{ display: 'none' }}
|
||||
accept="image/*"
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Management Info Section */}
|
||||
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2 mt-8">
|
||||
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
|
||||
<h3 className="text-xl font-bold text-slate-800">관리 정보</h3>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="설치 위치 / 보관 장소 *"
|
||||
name="locationId"
|
||||
value={formData.locationId}
|
||||
onChange={handleChange}
|
||||
options={locations.map(l => ({ label: l.name, value: l.id }))}
|
||||
placeholder="위치 선택"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="관리자 / 부서"
|
||||
name="manager"
|
||||
value={formData.manager}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="초기 상태"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ label: '정상 가동 (Active)', value: 'active' },
|
||||
{ label: '대기 (Idle)', value: 'idle' },
|
||||
{ label: '설치 중 (Installing)', value: 'installing' },
|
||||
{ label: '점검 중 (Maintenance)', value: 'maintain' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="col-span-1"></div>
|
||||
|
||||
{/* Purchasing Info Section */}
|
||||
<div className="col-span-full flex items-center gap-3 border-b border-slate-100 pb-3 mb-2 mt-8">
|
||||
<div className="w-1 h-6 bg-blue-500 rounded-full"></div>
|
||||
<h3 className="text-xl font-bold text-slate-800">도입 정보</h3>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="도입일"
|
||||
name="purchaseDate"
|
||||
type="date"
|
||||
value={formData.purchaseDate}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="구입 가격 (KRW)"
|
||||
name="purchasePrice"
|
||||
type="number"
|
||||
value={formData.purchasePrice}
|
||||
onChange={handleChange}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Management Info */}
|
||||
<div className="col-span-full border-b border-slate-100 pb-2 mb-2 mt-4">
|
||||
<h3 className="text-lg font-semibold text-slate-800">관리 정보</h3>
|
||||
{/* Bottom Action Buttons */}
|
||||
<div className="form-actions-footer">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => navigate(-1)}
|
||||
icon={<ArrowLeft size={16} />}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
icon={<Save size={16} />}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
label="설치 위치 / 보관 장소 *"
|
||||
name="locationId"
|
||||
value={formData.locationId}
|
||||
onChange={handleChange}
|
||||
options={locations.map(l => ({ label: l.name, value: l.id }))}
|
||||
placeholder="위치 선택"
|
||||
required
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="관리자 / 부서"
|
||||
name="manager"
|
||||
value={formData.manager}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Select
|
||||
label="초기 상태"
|
||||
name="status"
|
||||
value={formData.status}
|
||||
onChange={handleChange}
|
||||
options={[
|
||||
{ label: '정상 가동 (Active)', value: 'active' },
|
||||
{ label: '대기 (Idle)', value: 'idle' },
|
||||
{ label: '설치 중 (Installing)', value: 'installing' },
|
||||
{ label: '점검 중 (Maintenance)', value: 'maintain' }
|
||||
]}
|
||||
/>
|
||||
|
||||
<div className="col-span-1"></div>
|
||||
|
||||
{/* Purchasing Info */}
|
||||
<div className="col-span-full border-b border-slate-100 pb-2 mb-2 mt-4">
|
||||
<h3 className="text-lg font-semibold text-slate-800">도입 정보</h3>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="도입일"
|
||||
name="purchaseDate"
|
||||
type="date"
|
||||
value={formData.purchaseDate}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="구입 가격 (KRW)"
|
||||
name="purchasePrice"
|
||||
type="number"
|
||||
value={formData.purchasePrice}
|
||||
onChange={handleChange}
|
||||
placeholder="0"
|
||||
/>
|
||||
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@ -1,45 +1,45 @@
|
||||
/* Page Header adjustments for Settings */
|
||||
.page-header {
|
||||
/* Page Header - Right Aligned */
|
||||
.page-header-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding-bottom: 0 !important;
|
||||
/* Override default bottom padding */
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
align-items: flex-end;
|
||||
text-align: right;
|
||||
margin-bottom: 2rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
/* Settings Tabs */
|
||||
.settings-tabs {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: -1px;
|
||||
/* Overlap border */
|
||||
}
|
||||
|
||||
.settings-tab {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-secondary);
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-bottom: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.settings-tab:hover {
|
||||
color: var(--color-brand-primary);
|
||||
}
|
||||
|
||||
.settings-tab.active {
|
||||
color: var(--color-brand-primary);
|
||||
.page-title-text {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
border-bottom-color: var(--color-brand-primary);
|
||||
color: var(--sokuree-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
font-size: 1rem;
|
||||
color: var(--sokuree-text-secondary);
|
||||
}
|
||||
|
||||
/* Card Header Styles within Settings */
|
||||
.card-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--sokuree-text-primary);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 0.95rem;
|
||||
color: var(--sokuree-text-secondary);
|
||||
}
|
||||
|
||||
/* Local tabs are now hidden and moved to Global TopHeader */
|
||||
.settings-tabs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Common Layout */
|
||||
@ -66,8 +66,8 @@
|
||||
|
||||
/* Table Styles */
|
||||
.table-wrapper {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--sokuree-border-color);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@ -83,13 +83,13 @@
|
||||
font-weight: 600;
|
||||
text-align: left;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--sokuree-border-color);
|
||||
}
|
||||
|
||||
.settings-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: var(--color-text-primary);
|
||||
color: var(--sokuree-text-primary);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
@ -144,7 +144,7 @@
|
||||
.preview-box {
|
||||
background-color: #f1f5f9;
|
||||
padding: 1rem;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
@ -179,7 +179,7 @@
|
||||
min-height: 60px;
|
||||
padding: 0.5rem;
|
||||
border: 2px dashed #e2e8f0;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
background-color: #fcfcfc;
|
||||
}
|
||||
|
||||
@ -282,14 +282,16 @@
|
||||
height: 32px;
|
||||
/* Match button height (sm size) */
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--sokuree-border-color);
|
||||
border-radius: var(--sokuree-radius-sm);
|
||||
font-size: 0.875rem;
|
||||
width: 150px;
|
||||
outline: none;
|
||||
transition: border-color 0.2s;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.custom-text-input:focus {
|
||||
border-color: var(--color-brand-primary);
|
||||
border-color: var(--sokuree-brand-primary);
|
||||
box-shadow: 0 0 0 2px rgba(var(--sokuree-brand-primary-rgb), 0.1);
|
||||
}
|
||||
@ -3,7 +3,8 @@ import { useSearchParams } from 'react-router-dom';
|
||||
import { Card } from '../../../shared/ui/Card';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { Input } from '../../../shared/ui/Input';
|
||||
import { Plus, Trash2, Edit2, X, Check, GripVertical } from 'lucide-react';
|
||||
import { Plus, Trash2, Edit2, X, Check, GripVertical, Save, Loader2 } from 'lucide-react';
|
||||
import { apiClient } from '../../../shared/api/client';
|
||||
import { DndContext, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import { arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy, horizontalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
|
||||
@ -76,6 +77,7 @@ function SortableRuleItem({ id, children, className }: { id: string; children: R
|
||||
|
||||
// ---------------------
|
||||
|
||||
// Types
|
||||
// Types
|
||||
interface AssetCategory {
|
||||
id: string;
|
||||
@ -96,7 +98,7 @@ interface AssetStatus {
|
||||
color: string;
|
||||
}
|
||||
|
||||
type IDRuleComponentType = 'company' | 'category' | 'year' | 'sequence' | 'separator' | 'custom';
|
||||
type IDRuleComponentType = 'company' | 'category' | 'year' | 'month' | 'sequence' | 'separator' | 'custom';
|
||||
|
||||
interface IDRuleComponent {
|
||||
id: string;
|
||||
@ -105,7 +107,13 @@ interface IDRuleComponent {
|
||||
label: string;
|
||||
}
|
||||
|
||||
// Initial Mock Data
|
||||
export interface AssetMaintenanceType {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
// Internal values for initial fallback
|
||||
let GLOBAL_CATEGORIES: AssetCategory[] = [
|
||||
{ id: '1', name: '설비', code: 'FAC', menuLink: 'facilities' },
|
||||
{ id: '2', name: '공구', code: 'TOL', menuLink: 'tools' },
|
||||
@ -130,7 +138,7 @@ let GLOBAL_STATUSES: AssetStatus[] = [
|
||||
];
|
||||
|
||||
let GLOBAL_ID_RULE: IDRuleComponent[] = [
|
||||
{ id: 'r1', type: 'company', value: 'HK', label: '회사약어' },
|
||||
{ id: 'r1', type: 'company', value: 'SKR', label: '회사약어' },
|
||||
{ id: 'r2', type: 'separator', value: '-', label: '구분자' },
|
||||
{ id: 'r3', type: 'category', value: '', label: '카테고리' },
|
||||
{ id: 'r4', type: 'separator', value: '-', label: '구분자' },
|
||||
@ -139,12 +147,6 @@ let GLOBAL_ID_RULE: IDRuleComponent[] = [
|
||||
{ id: 'r7', type: 'sequence', value: '001', label: '일련번호(3자리)' },
|
||||
];
|
||||
|
||||
export interface AssetMaintenanceType {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
let GLOBAL_MAINTENANCE_TYPES: AssetMaintenanceType[] = [
|
||||
{ id: '1', name: '정기점검', color: 'success' },
|
||||
{ id: '2', name: '수리', color: 'danger' },
|
||||
@ -170,6 +172,68 @@ export function AssetSettingsPage() {
|
||||
const [maintenanceTypes, setMaintenanceTypes] = useState<AssetMaintenanceType[]>(GLOBAL_MAINTENANCE_TYPES);
|
||||
const [idRule, setIdRule] = useState<IDRuleComponent[]>(GLOBAL_ID_RULE);
|
||||
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Initial Load
|
||||
React.useEffect(() => {
|
||||
const fetchAllSettings = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const keys = ['asset_id_rule', 'asset_categories', 'asset_locations', 'asset_statuses', 'asset_maintenance_types'];
|
||||
const results = await Promise.all(keys.map(k => apiClient.get(`/system/settings/${k}`)));
|
||||
|
||||
results.forEach((res, idx) => {
|
||||
const key = keys[idx];
|
||||
const val = res.data.value;
|
||||
if (val) {
|
||||
const parsed = JSON.parse(val);
|
||||
if (key === 'asset_id_rule') {
|
||||
setIdRule(parsed);
|
||||
GLOBAL_ID_RULE = parsed;
|
||||
// Update company code input from the rule if available
|
||||
const comp = parsed.find((p: any) => p.type === 'company');
|
||||
if (comp) setCompanyCodeInput(comp.value);
|
||||
}
|
||||
if (key === 'asset_categories') { setCategories(parsed); GLOBAL_CATEGORIES = parsed; }
|
||||
if (key === 'asset_locations') { setLocations(parsed); GLOBAL_LOCATIONS = parsed; }
|
||||
if (key === 'asset_statuses') { setStatuses(parsed); GLOBAL_STATUSES = parsed; }
|
||||
if (key === 'asset_maintenance_types') { setMaintenanceTypes(parsed); GLOBAL_MAINTENANCE_TYPES = parsed; }
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load settings:', err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
fetchAllSettings();
|
||||
}, []);
|
||||
|
||||
const handleSaveSettings = async (currentTab: string) => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
let key = '';
|
||||
let value: any = null;
|
||||
|
||||
if (currentTab === 'basic') { key = 'asset_id_rule'; value = idRule; }
|
||||
else if (currentTab === 'category') { key = 'asset_categories'; value = categories; }
|
||||
else if (currentTab === 'location') { key = 'asset_locations'; value = locations; }
|
||||
else if (currentTab === 'status') { key = 'asset_statuses'; value = statuses; }
|
||||
else if (currentTab === 'maintenance') { key = 'asset_maintenance_types'; value = maintenanceTypes; }
|
||||
|
||||
if (key) {
|
||||
await apiClient.post(`/system/settings/${key}`, { value });
|
||||
alert('설정이 저장되었습니다.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Save failed:', err);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Form inputs
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [newCategoryCode, setNewCategoryCode] = useState('');
|
||||
@ -445,6 +509,12 @@ export function AssetSettingsPage() {
|
||||
setCustomRuleText('');
|
||||
};
|
||||
|
||||
const updateCompanyValue = (newVal: string) => {
|
||||
const updated = idRule.map(part => part.type === 'company' ? { ...part, value: newVal } : part);
|
||||
setIdRule(updated);
|
||||
GLOBAL_ID_RULE = updated;
|
||||
};
|
||||
|
||||
const removeRuleComponent = (id: string) => {
|
||||
const updated = idRule.filter(comp => comp.id !== id);
|
||||
setIdRule(updated);
|
||||
@ -453,10 +523,12 @@ export function AssetSettingsPage() {
|
||||
|
||||
|
||||
const getPreviewId = () => {
|
||||
const now = new Date();
|
||||
const mockData = {
|
||||
company: 'HK',
|
||||
company: 'HK', // Fallback
|
||||
category: 'FAC',
|
||||
year: new Date().getFullYear().toString(),
|
||||
year: now.getFullYear().toString(),
|
||||
month: String(now.getMonth() + 1).padStart(2, '0'),
|
||||
sequence: '001',
|
||||
separator: '-'
|
||||
};
|
||||
@ -466,22 +538,33 @@ export function AssetSettingsPage() {
|
||||
if (part.type === 'company') return part.value;
|
||||
if (part.type === 'category') return mockData.category;
|
||||
if (part.type === 'year') return mockData.year;
|
||||
if (part.type === 'month') return mockData.month;
|
||||
if (part.type === 'sequence') return part.value;
|
||||
if (part.type === 'separator') return part.value;
|
||||
return '';
|
||||
}).join('');
|
||||
};
|
||||
|
||||
if (isLoading) return <div className="p-12 text-center text-slate-500 flex flex-col items-center gap-4">
|
||||
<Loader2 size={32} className="animate-spin" />
|
||||
설정을 불러오는 중입니다...
|
||||
</div>;
|
||||
|
||||
return (
|
||||
<div className="page-container">
|
||||
<div className="settings-content mt-4">
|
||||
<div className="page-container h-full flex flex-col">
|
||||
<div className="page-header-right">
|
||||
<h1 className="page-title-text">자산 설정</h1>
|
||||
<p className="page-subtitle">자산 관리 모듈의 기준 정보를 설정합니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="settings-content mt-4 flex-1">
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
{activeTab === 'basic' && (
|
||||
<Card className="settings-card max-w-5xl">
|
||||
<Card className="settings-card w-full">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">자산 관리번호 생성 규칙</h2>
|
||||
<p className="card-desc">자산 등록 시 자동 생성될 관리번호의 포맷을 조합합니다.</p>
|
||||
@ -518,40 +601,57 @@ export function AssetSettingsPage() {
|
||||
<div className="rule-tools">
|
||||
<h4 className="tools-title">규칙 요소 추가</h4>
|
||||
<div className="tools-grid">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginRight: 'auto' }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="약어 (예: HK)"
|
||||
value={companyCodeInput}
|
||||
onChange={e => setCompanyCodeInput(e.target.value.toUpperCase())}
|
||||
className="custom-text-input"
|
||||
maxLength={5}
|
||||
style={{ width: '100px' }}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('company', companyCodeInput, `회사약어 (${companyCodeInput})`)}>회사약어 ({companyCodeInput})</Button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="약어 (예: SKR)"
|
||||
value={companyCodeInput}
|
||||
onChange={e => {
|
||||
const val = e.target.value.toUpperCase();
|
||||
setCompanyCodeInput(val);
|
||||
updateCompanyValue(val);
|
||||
}}
|
||||
className="custom-text-input"
|
||||
maxLength={5}
|
||||
style={{ width: '80px' }}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('company', companyCodeInput, '회사약어')}>회사약어 ({companyCodeInput})</Button>
|
||||
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('category', '', '카테고리')}>카테고리</Button>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('year', 'YYYY', '등록년도')}>년도</Button>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('month', 'MM', '월')}>월 (MM)</Button>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('sequence', '001', '일련번호')}>일련번호</Button>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('separator', '-', '구분자 (-)')}>기호 (-)</Button>
|
||||
<div className="custom-tool">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="사용자 정의 텍스트"
|
||||
value={customRuleText}
|
||||
onChange={e => setCustomRuleText(e.target.value)}
|
||||
className="custom-text-input"
|
||||
/>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('custom', '', '사용자 정의')}>추가</Button>
|
||||
</div>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('separator', '-', '구분자')}>구분자 (-)</Button>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
placeholder="사용자 정의 텍스트"
|
||||
value={customRuleText}
|
||||
onChange={e => setCustomRuleText(e.target.value)}
|
||||
className="custom-text-input"
|
||||
style={{ marginLeft: '0.5rem', width: '130px' }}
|
||||
/>
|
||||
<Button size="sm" variant="secondary" icon={<Plus size={14} />} onClick={() => addRuleComponent('custom', '', '사용자 정의')}>추가</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Action */}
|
||||
<div className="mt-12 flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||
disabled={isSaving}
|
||||
onClick={() => handleSaveSettings('basic')}
|
||||
className="w-24"
|
||||
>
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'category' && (
|
||||
<Card className="settings-card max-w-4xl">
|
||||
<Card className="settings-card w-full">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">카테고리 관리</h2>
|
||||
<p className="card-desc">자산 유형을 분류하는 카테고리를 관리합니다. (약어는 자산번호 생성 시 사용됩니다)</p>
|
||||
@ -666,12 +766,25 @@ export function AssetSettingsPage() {
|
||||
</SortableContext>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Bottom Action */}
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||
disabled={isSaving}
|
||||
onClick={() => handleSaveSettings('category')}
|
||||
className="w-32"
|
||||
>
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'location' && (
|
||||
<Card className="settings-card max-w-3xl">
|
||||
<Card className="settings-card w-full">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">설치 위치 / 보관 장소</h2>
|
||||
<p className="card-desc">자산이 위치할 수 있는 장소를 관리합니다.</p>
|
||||
@ -735,12 +848,25 @@ export function AssetSettingsPage() {
|
||||
</SortableContext>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Bottom Action */}
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||
disabled={isSaving}
|
||||
onClick={() => handleSaveSettings('location')}
|
||||
className="w-32"
|
||||
>
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'status' && (
|
||||
<Card className="settings-card max-w-5xl">
|
||||
<Card className="settings-card w-full">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">자산 상태 관리</h2>
|
||||
<p className="card-desc">자산의 상태(운용, 파손, 수리 등)를 정의하고 관리합니다.</p>
|
||||
@ -834,12 +960,25 @@ export function AssetSettingsPage() {
|
||||
</SortableContext>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Bottom Action */}
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||
disabled={isSaving}
|
||||
onClick={() => handleSaveSettings('status')}
|
||||
className="w-32"
|
||||
>
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{activeTab === 'maintenance' && (
|
||||
<Card className="settings-card max-w-4xl">
|
||||
<Card className="settings-card w-full">
|
||||
<div className="card-header">
|
||||
<h2 className="card-title">유지보수 구분 관리</h2>
|
||||
<p className="card-desc">유지보수 이력 등록 시 사용할 작업 구분을 관리합니다.</p>
|
||||
@ -923,6 +1062,19 @@ export function AssetSettingsPage() {
|
||||
</SortableContext>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Bottom Action */}
|
||||
<div className="mt-8 flex justify-end">
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={isSaving ? <Loader2 size={18} className="animate-spin" /> : <Save size={18} />}
|
||||
disabled={isSaving}
|
||||
onClick={() => handleSaveSettings('maintenance')}
|
||||
className="w-32"
|
||||
>
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@ -4,8 +4,8 @@
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
background-color: var(--color-bg-base);
|
||||
background: linear-gradient(135deg, var(--color-bg-sidebar) 0%, var(--color-brand-primary) 100%);
|
||||
background-color: var(--sokuree-bg-main);
|
||||
background: linear-gradient(135deg, var(--sokuree-bg-sidebar) 0%, var(--sokuree-brand-primary) 100%);
|
||||
}
|
||||
|
||||
.login-card {
|
||||
@ -14,8 +14,8 @@
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 3rem;
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: var(--sokuree-radius-lg);
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 8px 10px -6px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header {
|
||||
@ -26,22 +26,22 @@
|
||||
.brand-logo {
|
||||
display: inline-flex;
|
||||
padding: 1rem;
|
||||
background-color: var(--color-bg-sidebar);
|
||||
color: var(--color-text-inverse);
|
||||
border-radius: var(--radius-md);
|
||||
background-color: var(--sokuree-bg-sidebar);
|
||||
color: var(--sokuree-text-inverse);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: var(--shadow-md);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.login-header h1 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-text-primary);
|
||||
color: var(--sokuree-text-primary);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--sokuree-text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
@ -60,7 +60,7 @@
|
||||
.form-group label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-primary);
|
||||
color: var(--sokuree-text-primary);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
@ -72,33 +72,33 @@
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 1rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--sokuree-text-secondary);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.form-group input {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem 0.75rem 2.75rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--sokuree-border-color);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
background-color: var(--color-bg-surface);
|
||||
background-color: var(--sokuree-bg-card);
|
||||
}
|
||||
|
||||
.form-group input:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(82, 109, 130, 0.1);
|
||||
border-color: var(--sokuree-brand-primary);
|
||||
box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1);
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
margin-top: 1rem;
|
||||
padding: 0.875rem;
|
||||
background-color: var(--color-bg-sidebar);
|
||||
background-color: var(--sokuree-bg-sidebar);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border-radius: var(--radius-md);
|
||||
border-radius: var(--sokuree-radius-md);
|
||||
font-size: 1rem;
|
||||
letter-spacing: 0.025em;
|
||||
transition: all 0.2s;
|
||||
@ -107,7 +107,7 @@
|
||||
.login-btn:hover {
|
||||
background-color: #1e293b;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: var(--shadow-md);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
@ -116,12 +116,12 @@
|
||||
text-align: center;
|
||||
background-color: rgba(239, 68, 68, 0.1);
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--radius-sm);
|
||||
border-radius: var(--sokuree-radius-sm);
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 2rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-secondary);
|
||||
color: var(--sokuree-text-secondary);
|
||||
}
|
||||
@ -13,6 +13,13 @@ export function LoginPage() {
|
||||
const location = useLocation();
|
||||
|
||||
const from = location.state?.from?.pathname || '/asset/dashboard';
|
||||
const isExpired = new URLSearchParams(location.search).get('expired') === 'true';
|
||||
|
||||
useState(() => {
|
||||
if (isExpired) {
|
||||
setError('세션이 만료되었습니다. 보안을 위해 다시 로그인해주세요.');
|
||||
}
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@ -14,6 +14,7 @@ import ModuleLoader from './ModuleLoader';
|
||||
// Platform / System Pages
|
||||
import { UserManagementPage } from './pages/UserManagementPage';
|
||||
import { BasicSettingsPage } from './pages/BasicSettingsPage';
|
||||
import { VersionPage } from './pages/VersionPage';
|
||||
import { LicensePage } from '../system/pages/LicensePage';
|
||||
|
||||
import './styles/global.css';
|
||||
@ -45,6 +46,7 @@ export const App = () => {
|
||||
<Route path="/admin/users" element={<UserManagementPage />} />
|
||||
<Route path="/admin/settings" element={<BasicSettingsPage />} />
|
||||
<Route path="/admin/license" element={<LicensePage />} />
|
||||
<Route path="/admin/version" element={<VersionPage />} />
|
||||
<Route path="/admin" element={<Navigate to="/admin/settings" replace />} />
|
||||
</Route>
|
||||
|
||||
|
||||
@ -3,23 +3,75 @@ import { Card } from '../../shared/ui/Card';
|
||||
import { Button } from '../../shared/ui/Button';
|
||||
import { Input } from '../../shared/ui/Input';
|
||||
import { apiClient } from '../../shared/api/client';
|
||||
import { Save, Clock, Info } from 'lucide-react';
|
||||
import { useAuth } from '../../shared/auth/AuthContext';
|
||||
import { Save, Clock, Database, Server, CheckCircle2, AlertCircle, Key, RefreshCcw, ShieldAlert, Lock, Unlock } from 'lucide-react';
|
||||
|
||||
interface SystemSettings {
|
||||
session_timeout: number;
|
||||
encryption_key: string;
|
||||
subscriber_id: string;
|
||||
db_config: {
|
||||
host: string;
|
||||
user: string;
|
||||
password?: string;
|
||||
database: string;
|
||||
port: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function BasicSettingsPage() {
|
||||
const [settings, setSettings] = useState({
|
||||
session_timeout: 60
|
||||
const { user: currentUser } = useAuth();
|
||||
const [settings, setSettings] = useState<SystemSettings>({
|
||||
session_timeout: 60,
|
||||
encryption_key: '',
|
||||
subscriber_id: '',
|
||||
db_config: {
|
||||
host: '',
|
||||
user: '',
|
||||
password: '',
|
||||
database: '',
|
||||
port: '3306'
|
||||
}
|
||||
});
|
||||
|
||||
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
|
||||
const [isDbVerified, setIsDbVerified] = useState(false);
|
||||
const [saveResults, setSaveResults] = useState<{ [key: string]: { success: boolean; message: string } | null }>({
|
||||
security: null,
|
||||
encryption: null,
|
||||
database: null
|
||||
});
|
||||
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [rotationStatus, setRotationStatus] = useState<{ currentKey: string; affectedCount: number } | null>(null);
|
||||
const [rotating, setRotating] = useState(false);
|
||||
|
||||
// Supervisor Protection States
|
||||
const [isDbConfigUnlocked, setIsDbConfigUnlocked] = useState(false);
|
||||
const [isEncryptionUnlocked, setIsEncryptionUnlocked] = useState(false);
|
||||
const [verifyingTarget, setVerifyingTarget] = useState<'database' | 'encryption' | null>(null);
|
||||
|
||||
const [showVerifyModal, setShowVerifyModal] = useState(false);
|
||||
const [verifyPassword, setVerifyPassword] = useState('');
|
||||
const [verifying, setVerifying] = useState(false);
|
||||
const [verifyError, setVerifyError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchSettings();
|
||||
fetchEncryptionStatus();
|
||||
}, []);
|
||||
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/system/settings');
|
||||
setSettings(res.data);
|
||||
const data = res.data;
|
||||
if (!data.db_config) {
|
||||
data.db_config = { host: '', user: '', password: '', database: '', port: '3306' };
|
||||
}
|
||||
setSettings(data);
|
||||
setIsDbVerified(true);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch settings', error);
|
||||
} finally {
|
||||
@ -27,95 +79,416 @@ export function BasicSettingsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
const fetchEncryptionStatus = async () => {
|
||||
try {
|
||||
await apiClient.post('/system/settings', settings);
|
||||
alert('설정이 저장되었습니다.');
|
||||
const res = await apiClient.get('/system/encryption/status');
|
||||
setRotationStatus({
|
||||
currentKey: res.data.current_key,
|
||||
affectedCount: Object.values(res.data.affected_records as Record<string, number>).reduce((a, b) => a + b, 0)
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Save failed', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
console.error('Failed to fetch encryption status', error);
|
||||
}
|
||||
};
|
||||
|
||||
const generateNewKey = () => {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()';
|
||||
let key = 'smartims_';
|
||||
for (let i = 0; i < 24; i++) {
|
||||
key += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
setSettings({ ...settings, encryption_key: key });
|
||||
};
|
||||
|
||||
const handleRotateKey = async () => {
|
||||
if (!settings.encryption_key) {
|
||||
alert('새로운 암호화 키를 입력하거나 생성해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const confirmMsg = `암호화 키 변경 및 데이터 마이그레이션을 시작합니다.\n- 대상 레코드: 약 ${rotationStatus?.affectedCount || 0}건\n- 소요 시간: 데이터 양에 따라 수 초가 걸릴 수 있습니다.\n\n주의: 절차 진행 중에는 서버 연결이 일시적으로 끊길 수 있으며, 중간에 브라우저를 닫지 마십시오.\n진행하시겠습니까?`;
|
||||
|
||||
if (!confirm(confirmMsg)) return;
|
||||
|
||||
setRotating(true);
|
||||
setSaveResults(prev => ({ ...prev, encryption: null }));
|
||||
|
||||
try {
|
||||
await apiClient.post('/system/encryption/rotate', { new_key: settings.encryption_key });
|
||||
setSaveResults(prev => ({
|
||||
...prev,
|
||||
encryption: { success: true, message: '암호화 키 변경 및 데이터 마이그레이션 완료!' }
|
||||
}));
|
||||
fetchSettings();
|
||||
fetchEncryptionStatus();
|
||||
setTimeout(() => setSaveResults(prev => ({ ...prev, encryption: null })), 5000);
|
||||
} catch (error: any) {
|
||||
setSaveResults(prev => ({
|
||||
...prev,
|
||||
encryption: { success: false, message: error.response?.data?.error || '재암호화 작업 중 오류가 발생했습니다.' }
|
||||
}));
|
||||
} finally {
|
||||
setRotating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const res = await apiClient.post('/system/test-db', settings.db_config);
|
||||
setTestResult({ success: true, message: res.data.message });
|
||||
setIsDbVerified(true);
|
||||
} catch (error: any) {
|
||||
setTestResult({ success: false, message: error.response?.data?.error || '접속 테스트에 실패했습니다.' });
|
||||
setIsDbVerified(false);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenVerify = (target: 'database' | 'encryption') => {
|
||||
if (currentUser?.role !== 'supervisor') {
|
||||
alert('해당 권한이 없습니다. 최고관리자(Supervisor)만 접근 가능합니다.');
|
||||
return;
|
||||
}
|
||||
setVerifyingTarget(target);
|
||||
setShowVerifyModal(true);
|
||||
};
|
||||
|
||||
const handleVerifySupervisor = async () => {
|
||||
setVerifying(true);
|
||||
setVerifyError('');
|
||||
try {
|
||||
const res = await apiClient.post('/verify-supervisor', { password: verifyPassword });
|
||||
if (res.data.success) {
|
||||
if (verifyingTarget === 'database') setIsDbConfigUnlocked(true);
|
||||
else if (verifyingTarget === 'encryption') setIsEncryptionUnlocked(true);
|
||||
|
||||
setShowVerifyModal(false);
|
||||
setVerifyPassword('');
|
||||
setVerifyingTarget(null);
|
||||
}
|
||||
} catch (error: any) {
|
||||
setVerifyError(error.response?.data?.message || '인증에 실패했습니다. 비밀번호를 확인해주세요.');
|
||||
} finally {
|
||||
setVerifying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveSection = async (section: 'security' | 'encryption' | 'database') => {
|
||||
if (section === 'database' && !isDbVerified) {
|
||||
alert('DB 접속 정보가 변경되었습니다. 저장 전 반드시 [연결 테스트]를 수행하십시오.');
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: any = {};
|
||||
const successMessage = section === 'database' ? 'DB 설정이 저장되었습니다.' : '설정이 성공적으로 저장되었습니다.';
|
||||
|
||||
if (section === 'security') payload = { session_timeout: settings.session_timeout };
|
||||
else if (section === 'encryption') payload = { encryption_key: settings.encryption_key };
|
||||
else if (section === 'database') {
|
||||
if (!confirm('DB 설정을 저장하면 서버가 재시작되어 접속이 끊길 수 있습니다. 진행하시겠습니까?')) return;
|
||||
payload = { db_config: settings.db_config };
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setSaveResults(prev => ({ ...prev, [section]: null }));
|
||||
|
||||
try {
|
||||
await apiClient.post('/system/settings', payload);
|
||||
setSaveResults(prev => ({ ...prev, [section]: { success: true, message: successMessage } }));
|
||||
setTimeout(() => setSaveResults(prev => ({ ...prev, [section]: null })), 3000);
|
||||
if (section !== 'database') fetchSettings();
|
||||
} catch (error: any) {
|
||||
setSaveResults(prev => ({
|
||||
...prev,
|
||||
[section]: { success: false, message: error.response?.data?.error || '저장 중 오류 발생' }
|
||||
}));
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <div className="p-6">로딩 중...</div>;
|
||||
if (loading) return <div className="p-12 text-center text-slate-500">데이터를 불러오는 중...</div>;
|
||||
|
||||
return (
|
||||
<div className="page-container p-6 max-w-4xl mx-auto">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-slate-900">시스템 관리 - 기본 설정</h1>
|
||||
<p className="text-slate-500 mt-1">플랫폼의 기본 운영 환경을 설정합니다.</p>
|
||||
<div className="page-container p-6 max-w-5xl mx-auto">
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900">시스템 관리 - 기본 설정</h1>
|
||||
<p className="text-slate-500 mt-1">플랫폼의 보안 및 인프라 환경을 섹션별로 관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
|
||||
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4 flex items-center gap-2">
|
||||
<Clock size={20} className="text-amber-500" />
|
||||
보안 설정
|
||||
</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">세션 타임아웃</label>
|
||||
<div className="flex items-center gap-2 flex-wrap text-sm text-slate-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="24"
|
||||
className="!w-auto !mb-0"
|
||||
style={{ width: '70px', textAlign: 'center' }}
|
||||
value={Math.floor((settings.session_timeout || 10) / 60)}
|
||||
onChange={e => {
|
||||
const newHours = parseInt(e.target.value) || 0;
|
||||
const currentTotal = settings.session_timeout || 10;
|
||||
const currentMinutes = currentTotal % 60;
|
||||
setSettings({ ...settings, session_timeout: (newHours * 60) + currentMinutes });
|
||||
}}
|
||||
placeholder="0"
|
||||
/>
|
||||
<span className="mr-2 whitespace-nowrap text-slate-700 font-medium">시간</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
className="!w-auto !mb-0"
|
||||
style={{ width: '70px', textAlign: 'center' }}
|
||||
value={(settings.session_timeout || 10) % 60}
|
||||
onChange={e => {
|
||||
const newMinutes = parseInt(e.target.value) || 0;
|
||||
const currentTotal = settings.session_timeout || 10;
|
||||
const currentHours = Math.floor(currentTotal / 60);
|
||||
setSettings({ ...settings, session_timeout: (currentHours * 60) + newMinutes });
|
||||
}}
|
||||
placeholder="10"
|
||||
/>
|
||||
<span className="whitespace-nowrap text-slate-700 font-medium">분</span>
|
||||
</div>
|
||||
<span className="text-slate-500 whitespace-nowrap ml-1">동안 활동이 없으면 자동으로 로그아웃됩니다.</span>
|
||||
<div className="space-y-8">
|
||||
{/* Section 1: Security & Session */}
|
||||
<Card className="overflow-hidden border-slate-200 shadow-sm">
|
||||
<div className="p-6 border-b border-slate-50 bg-slate-50/50">
|
||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<Clock size={20} className="text-blue-600" />
|
||||
보안 및 세션
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-3">세션 자동 로그아웃 시간</label>
|
||||
<div className="flex items-center gap-3 text-sm text-slate-600">
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
className="!w-20 text-center"
|
||||
value={Math.floor((settings.session_timeout || 60) / 60)}
|
||||
onChange={e => {
|
||||
const h = parseInt(e.target.value) || 0;
|
||||
setSettings({ ...settings, session_timeout: (h * 60) + (settings.session_timeout % 60) });
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium text-slate-900 mx-1">시간</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-1 pl-1">
|
||||
( 총 {settings.session_timeout || 10}분 / 최소 5분 ~ 최대 24시간 )
|
||||
</p>
|
||||
<div className="flex items-center gap-1">
|
||||
<Input
|
||||
type="number"
|
||||
className="!w-20 text-center"
|
||||
value={(settings.session_timeout || 60) % 60}
|
||||
onChange={e => {
|
||||
const m = parseInt(e.target.value) || 0;
|
||||
setSettings({ ...settings, session_timeout: (Math.floor(settings.session_timeout / 60) * 60) + m });
|
||||
}}
|
||||
/>
|
||||
<span className="font-medium text-slate-900 mx-1">분</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-slate-50/30 border-t border-slate-50 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
{saveResults.security && (
|
||||
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.security.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{saveResults.security.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
|
||||
{saveResults.security.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={fetchSettings}>취소</Button>
|
||||
<Button size="sm" onClick={() => handleSaveSection('security')} disabled={saving} icon={<Save size={14} />}>설정 저장</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
icon={<Save size={18} />}
|
||||
>
|
||||
{saving ? '저장 중...' : '설정 저장'}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Section 2: Encryption Master Key & Rotation (Supervisor Protected) */}
|
||||
<Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
|
||||
<div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
|
||||
<h2 className="text-lg font-bold text-slate-800 flex items-center gap-2">
|
||||
<Key size={20} className="text-red-600" />
|
||||
데이터 암호화 마스터 키 관리
|
||||
</h2>
|
||||
{!isEncryptionUnlocked && (
|
||||
<Button size="sm" variant="secondary" onClick={() => handleOpenVerify('encryption')} icon={<Lock size={14} />}>
|
||||
조회 / 변경
|
||||
</Button>
|
||||
)}
|
||||
{isEncryptionUnlocked && (
|
||||
<div className="flex items-center gap-2 text-red-700 bg-red-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-red-200">
|
||||
<Unlock size={14} /> 검증 완료: 최고관리자 모드
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isEncryptionUnlocked ? (
|
||||
<div className="animate-in fade-in duration-500">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="bg-slate-50 rounded-lg p-4 border border-slate-100 grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-slate-500 font-bold block mb-1">현재 활성 키</span>
|
||||
<code className="bg-white p-2 rounded border border-slate-200 block truncate font-mono">{rotationStatus?.currentKey || '-'}</code>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-500 font-bold block mb-1">영향 레코드</span>
|
||||
<div className="text-blue-600 font-bold flex items-center gap-2 mt-1">
|
||||
<Database size={16} /> {rotationStatus?.affectedCount || 0} 건
|
||||
<Button variant="secondary" size="sm" className="ml-2 h-7 text-[10px]" onClick={fetchEncryptionStatus} icon={<RefreshCcw size={12} />}>조회</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 max-w-xl">
|
||||
<Input
|
||||
value={settings.encryption_key}
|
||||
onChange={e => setSettings({ ...settings, encryption_key: e.target.value })}
|
||||
placeholder="새로운 키 입력"
|
||||
className="font-mono flex-1"
|
||||
/>
|
||||
<Button variant="secondary" onClick={generateNewKey} icon={<RefreshCcw size={14} />}>자동 생성</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-slate-50/30 border-t border-slate-50 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
{saveResults.encryption && (
|
||||
<div className={`flex items-center gap-2 text-sm font-medium ${saveResults.encryption.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{saveResults.encryption.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />}
|
||||
{saveResults.encryption.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setIsEncryptionUnlocked(false)}>닫기</Button>
|
||||
<Button
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
size="sm"
|
||||
onClick={handleRotateKey}
|
||||
disabled={rotating || !settings.encryption_key}
|
||||
icon={<RefreshCcw size={14} className={rotating ? 'animate-spin' : ''} />}
|
||||
>
|
||||
{rotating ? '재암호화 진행 중...' : '변경 및 마이그레이션 저장'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-12 text-center text-slate-400 bg-slate-50/50">
|
||||
<Lock className="mx-auto mb-3 opacity-20" size={48} />
|
||||
<p className="text-sm font-medium">보안을 위해 암호화 설정은 최고관리자 인증 후 접근 가능합니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Section 3: Database Infrastructure (Supervisor Protected) */}
|
||||
<Card className="overflow-hidden border-slate-200 shadow-sm border-2 border-slate-100">
|
||||
<div className="p-6 border-b border-slate-50 bg-slate-100/50 flex justify-between items-center">
|
||||
<h2 className="text-lg font-bold text-slate-900 flex items-center gap-2">
|
||||
<ShieldAlert size={20} className="text-indigo-600" />
|
||||
데이터베이스 인프라 구성
|
||||
</h2>
|
||||
{!isDbConfigUnlocked && (
|
||||
<Button size="sm" variant="secondary" onClick={() => handleOpenVerify('database')} icon={<Lock size={14} />}>
|
||||
조회 / 변경
|
||||
</Button>
|
||||
)}
|
||||
{isDbConfigUnlocked && (
|
||||
<div className="flex items-center gap-2 text-indigo-700 bg-indigo-50 px-3 py-1 rounded-full text-xs font-bold ring-1 ring-indigo-200">
|
||||
<Unlock size={14} /> 검증 완료: 최고관리자 모드
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isDbConfigUnlocked ? (
|
||||
<div className="animate-in fade-in duration-500">
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="p-4 bg-indigo-50 border border-indigo-100 rounded-lg flex gap-4 items-start">
|
||||
<AlertCircle className="text-indigo-600 shrink-0" size={20} />
|
||||
<div>
|
||||
<p className="text-sm font-bold text-indigo-900">물리적 인프라 수동 관리 모드</p>
|
||||
<p className="text-xs text-indigo-700 leading-relaxed mt-1">
|
||||
이 섹션의 설정을 잘못 변경하면 플랫폼 전체 접속이 불가능해질 수 있습니다.<br />
|
||||
반드시 접속 테스트 성공을 확인한 후에 저장하십시오.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-8">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-slate-700">DB 호스트 주소</label>
|
||||
<Input value={settings.db_config.host} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, host: e.target.value } }); setIsDbVerified(false); }} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-slate-700">사용자 계정</label>
|
||||
<Input value={settings.db_config.user} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, user: e.target.value } }); setIsDbVerified(false); }} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-slate-700">비밀번호</label>
|
||||
<Input type="password" value={settings.db_config.password} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, password: e.target.value } }); setIsDbVerified(false); }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-slate-700">스키마 이름 (Database)</label>
|
||||
<Input value={settings.db_config.database} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, database: e.target.value } }); setIsDbVerified(false); }} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-sm font-medium text-slate-700">접속 포트 (Port)</label>
|
||||
<Input type="number" value={settings.db_config.port} onChange={e => { setSettings({ ...settings, db_config: { ...settings.db_config, port: e.target.value } }); setIsDbVerified(false); }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-slate-50 border border-slate-100 rounded-lg flex justify-between items-center">
|
||||
<div className="text-sm font-medium">
|
||||
{testResult && (
|
||||
<span className={`flex items-center gap-1 ${testResult.success ? 'text-green-700' : 'text-red-700'}`}>
|
||||
{testResult.success ? <CheckCircle2 size={16} /> : <AlertCircle size={16} />} {testResult.message}
|
||||
</span>
|
||||
)}
|
||||
{!testResult && <span className="text-slate-500 italic">변경 시 반드시 접속 테스트를 수행하십시오.</span>}
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={handleTestConnection} disabled={testing} icon={<Server size={14} />}>
|
||||
{testing ? '검증 중...' : '연결 테스트'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-6 py-4 bg-slate-100/30 border-t border-slate-100 flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
{testResult && !testResult.success && <span className="text-xs text-red-600 font-bold">⚠️ 연결 실패 시 변경된 정보를 저장할 수 없습니다.</span>}
|
||||
{saveResults.database && (
|
||||
<div className={`text-sm font-medium ${saveResults.database.success ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{saveResults.database.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => setIsDbConfigUnlocked(false)}>닫기</Button>
|
||||
<Button size="sm" className="bg-indigo-600" onClick={() => handleSaveSection('database')} disabled={saving || !isDbVerified} icon={<Save size={14} />}>정보 저장</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-12 text-center text-slate-400 bg-slate-50/50">
|
||||
<Lock className="mx-auto mb-3 opacity-20" size={48} />
|
||||
<p className="text-sm font-medium">보안을 위해 인프라 설정은 최고관리자 인증 후 접근 가능합니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Verification Modal */}
|
||||
{showVerifyModal && (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-slate-900/60 backdrop-blur-sm animate-in fade-in duration-300">
|
||||
<Card className="w-full max-w-md shadow-2xl border-indigo-200 overflow-hidden">
|
||||
<div className={`p-6 text-white ${verifyingTarget === 'encryption' ? 'bg-red-600' : 'bg-indigo-600'}`}>
|
||||
<h3 className="text-lg font-bold flex items-center gap-2">
|
||||
<ShieldAlert size={20} /> 최고관리자 권한 확인 ({verifyingTarget === 'encryption' ? '암호화 키' : '인프라 설정'})
|
||||
</h3>
|
||||
<p className="text-white/80 text-sm mt-1">민감 설정 접근을 위해 본인 확인이 필요합니다.</p>
|
||||
</div>
|
||||
<div className="p-6 space-y-4">
|
||||
<div className="p-3 bg-red-50 border border-red-100 rounded text-xs text-red-800 leading-relaxed font-bold">
|
||||
{verifyingTarget === 'encryption'
|
||||
? "※ 경고: 암호화 키 변경은 시스템 내 모든 민감 데이터를 다시 암호화하는 중대한 작업입니다. 성공 시 기존 데이터를 새 키로 대체하며, 실패 시 데이터 유실의 위험이 있습니다."
|
||||
: "※ 경고: 인프라 설정은 데이터베이스 물리 접속 정보를 직접 수정하는 매우 위험한 작업입니다. 잘못된 입력은 시스템 중단으로 이어질 수 있습니다."}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">최고관리자 비밀번호</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={verifyPassword}
|
||||
onChange={e => setVerifyPassword(e.target.value)}
|
||||
placeholder="비밀번호를 입력하세요"
|
||||
onKeyDown={e => e.key === 'Enter' && handleVerifySupervisor()}
|
||||
autoFocus
|
||||
/>
|
||||
{verifyError && <p className="text-xs text-red-600 mt-2 font-bold">{verifyError}</p>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-6 bg-slate-50 border-t border-slate-100 flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => { setShowVerifyModal(false); setVerifyError(''); setVerifyPassword(''); setVerifyingTarget(null); }}>취소</Button>
|
||||
<Button className={verifyingTarget === 'encryption' ? 'bg-red-600' : 'bg-indigo-600'} onClick={handleVerifySupervisor} disabled={verifying}>
|
||||
{verifying ? '인증 중...' : '인증 및 조회'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -3,9 +3,10 @@ import { Card } from '../../shared/ui/Card';
|
||||
import { Button } from '../../shared/ui/Button';
|
||||
import { Input } from '../../shared/ui/Input';
|
||||
import { apiClient } from '../../shared/api/client';
|
||||
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon } from 'lucide-react';
|
||||
import { Plus, Edit2, Trash2, X, Check, Shield, User as UserIcon, ShieldCheck, RefreshCcw } from 'lucide-react';
|
||||
import type { User } from '../../shared/auth/AuthContext';
|
||||
import './UserManagementPage.css'; // Will create CSS separately or inline styles initially. Let's assume global css or create specific.
|
||||
import { useAuth } from '../../shared/auth/AuthContext';
|
||||
import './UserManagementPage.css';
|
||||
|
||||
interface UserFormData {
|
||||
id: string;
|
||||
@ -14,10 +15,11 @@ interface UserFormData {
|
||||
department: string;
|
||||
position: string;
|
||||
phone: string;
|
||||
role: 'admin' | 'user';
|
||||
role: 'supervisor' | 'admin' | 'user';
|
||||
}
|
||||
|
||||
export function UserManagementPage() {
|
||||
const { user: currentUser } = useAuth();
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@ -68,7 +70,7 @@ export function UserManagementPage() {
|
||||
const handleOpenEdit = (user: User) => {
|
||||
setFormData({
|
||||
id: user.id,
|
||||
password: '', // Password empty by default on edit
|
||||
password: '',
|
||||
name: user.name,
|
||||
department: user.department || '',
|
||||
position: user.position || '',
|
||||
@ -92,28 +94,20 @@ export function UserManagementPage() {
|
||||
|
||||
const formatPhoneNumber = (value: string) => {
|
||||
const cleaned = value.replace(/\D/g, '');
|
||||
if (cleaned.length <= 3) {
|
||||
return cleaned;
|
||||
} else if (cleaned.length <= 7) {
|
||||
return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
|
||||
} else {
|
||||
return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
|
||||
}
|
||||
if (cleaned.length <= 3) return cleaned;
|
||||
else if (cleaned.length <= 7) return `${cleaned.slice(0, 3)}-${cleaned.slice(3)}`;
|
||||
else return `${cleaned.slice(0, 3)}-${cleaned.slice(3, 7)}-${cleaned.slice(7, 11)}`;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
if (isEditing) {
|
||||
// Update
|
||||
const payload: any = { ...formData };
|
||||
if (!payload.password) delete payload.password; // Don't send empty password
|
||||
|
||||
if (!payload.password) delete payload.password;
|
||||
await apiClient.put(`/users/${formData.id}`, payload);
|
||||
alert('수정되었습니다.');
|
||||
} else {
|
||||
// Create
|
||||
if (!formData.password) return alert('비밀번호를 입력하세요.');
|
||||
await apiClient.post('/users', formData);
|
||||
alert('등록되었습니다.');
|
||||
@ -121,9 +115,30 @@ export function UserManagementPage() {
|
||||
setIsModalOpen(false);
|
||||
fetchUsers();
|
||||
} catch (error: any) {
|
||||
console.error('Submit failed', error);
|
||||
const errorMsg = error.response?.data?.error || error.message || '저장 중 오류가 발생했습니다.';
|
||||
alert(`오류: ${errorMsg}`);
|
||||
alert(`오류: ${error.response?.data?.error || error.message}`);
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleBadge = (role: string) => {
|
||||
switch (role) {
|
||||
case 'supervisor':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-amber-100 text-amber-700 ring-1 ring-amber-200">
|
||||
<ShieldCheck size={10} className="mr-1" /> 최고관리자
|
||||
</span>
|
||||
);
|
||||
case 'admin':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-indigo-100 text-indigo-700 ring-1 ring-indigo-200">
|
||||
<Shield size={10} className="mr-1" /> 관리자
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-bold bg-slate-100 text-slate-600 ring-1 ring-slate-200">
|
||||
<UserIcon size={10} className="mr-1" /> 사용자
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@ -137,10 +152,10 @@ export function UserManagementPage() {
|
||||
<Button onClick={handleOpenAdd} icon={<Plus size={16} />}>사용자 등록</Button>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
<Card className="overflow-hidden shadow-sm border-slate-200">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm text-left text-slate-600">
|
||||
<thead className="text-xs text-slate-700 uppercase bg-slate-50 border-b border-slate-200">
|
||||
<table className="w-full text-sm text-left">
|
||||
<thead className="text-xs text-slate-500 uppercase bg-slate-50/80 border-b border-slate-200">
|
||||
<tr>
|
||||
<th className="px-6 py-4">아이디 / 권한</th>
|
||||
<th className="px-6 py-4">이름</th>
|
||||
@ -150,39 +165,44 @@ export function UserManagementPage() {
|
||||
<th className="px-6 py-4 text-center">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="font-medium text-slate-900">{user.id}</div>
|
||||
<span className={`inline-flex items-center px-2 py-0.5 rounded text-xs font-medium mt-1 ${user.role === 'admin' ? 'bg-indigo-100 text-indigo-700' : 'bg-slate-100 text-slate-600'
|
||||
}`}>
|
||||
{user.role === 'admin' ? <Shield size={10} className="mr-1" /> : <UserIcon size={10} className="mr-1" />}
|
||||
{user.role === 'admin' ? '관리자' : '사용자'}
|
||||
</span>
|
||||
<tbody className="divide-y divide-slate-100 bg-white">
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-slate-400">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<RefreshCcw size={24} className="animate-spin text-indigo-500" />
|
||||
<span>사용자 데이터를 불러오는 중...</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 font-medium">{user.name}</td>
|
||||
</tr>
|
||||
) : users.map((user) => (
|
||||
<tr key={user.id} className="hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-slate-900">{user.department || '-'}</div>
|
||||
<div className="text-slate-500 text-xs">{user.position}</div>
|
||||
<div className="font-bold text-slate-900">{user.id}</div>
|
||||
<div className="mt-1">{getRoleBadge(user.role)}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4">{user.phone || '-'}</td>
|
||||
<td className="px-6 py-4 text-slate-500">
|
||||
{user.last_login ? new Date(user.last_login).toLocaleString() : '접속 기록 없음'}
|
||||
<td className="px-6 py-4 font-semibold text-slate-800">{user.name}</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="text-slate-900 font-bold">{user.department || '-'}</div>
|
||||
<div className="text-slate-600 text-[11px] font-medium">{user.position}</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 text-slate-700 font-medium">{user.phone || '-'}</td>
|
||||
<td className="px-6 py-4 text-slate-400 text-[11px]">
|
||||
{user.last_login ? new Date(user.last_login).toLocaleString() : '미접속'}
|
||||
</td>
|
||||
<td className="px-6 py-4">
|
||||
<div className="flex justify-center gap-2">
|
||||
<button className="p-2 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded" onClick={() => handleOpenEdit(user)} title="수정">
|
||||
<div className="flex justify-center gap-1">
|
||||
<button className="p-2 text-slate-400 hover:text-indigo-600 hover:bg-indigo-50 rounded-lg transition-colors" onClick={() => handleOpenEdit(user)}>
|
||||
<Edit2 size={16} />
|
||||
</button>
|
||||
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded" onClick={() => handleDelete(user.id)} title="삭제">
|
||||
<button className="p-2 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" onClick={() => handleDelete(user.id)}>
|
||||
<Trash2 size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{users.length === 0 && !loading && (
|
||||
{!loading && users.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-6 py-12 text-center text-slate-400">등록된 사용자가 없습니다.</td>
|
||||
</tr>
|
||||
@ -194,46 +214,44 @@ export function UserManagementPage() {
|
||||
|
||||
{/* Modal */}
|
||||
{isModalOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<Card className="w-full max-w-md shadow-2xl animate-in fade-in zoom-in duration-200">
|
||||
<div className="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">
|
||||
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
|
||||
<h2 className="text-lg font-bold text-slate-800">
|
||||
{isEditing ? '사용자 정보 수정' : '새 사용자 등록'}
|
||||
</h2>
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<X size={20} />
|
||||
<button onClick={() => setIsModalOpen(false)} className="text-slate-400 hover:text-slate-600 bg-white border border-slate-200 rounded-lg p-1">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4" autoComplete="off">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">아이디 <span className="text-red-500">*</span></label>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">아이디 <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
value={formData.id}
|
||||
onChange={(e) => setFormData({ ...formData, id: e.target.value })}
|
||||
disabled={isEditing} // ID cannot be changed on edit
|
||||
placeholder="로그인 아이디"
|
||||
disabled={isEditing}
|
||||
placeholder="로그인 아이디 입력"
|
||||
required
|
||||
autoComplete="off"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">
|
||||
비밀번호 <span className="text-red-500">{!isEditing && '*'}</span>
|
||||
</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
placeholder={isEditing ? "(변경시에만 입력)" : "비밀번호"}
|
||||
placeholder={isEditing ? "(변경시에만 입력)" : "초기 비밀번호 입력"}
|
||||
required={!isEditing}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">이름 <span className="text-red-500">*</span></label>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">이름 <span className="text-red-500">*</span></label>
|
||||
<Input
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
@ -241,28 +259,41 @@ export function UserManagementPage() {
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">권한</label>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">권한</label>
|
||||
<select
|
||||
className="h-10 w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-slate-500 focus:border-transparent"
|
||||
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"
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as 'user' | 'admin' })}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
||||
disabled={
|
||||
// 1. 현재 관리자(admin)는 최고관리자(supervisor)의 권한을 바꿀 수 없음
|
||||
(isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor' && currentUser?.role !== 'supervisor') ||
|
||||
// 2. 현재 관리자(admin)는 자기 자신의 권한을 'supervisor'로 올릴 수 없음 (애초에 옵션에서 걸러지겠지만 안전 장치)
|
||||
(formData.role === 'supervisor' && currentUser?.role !== 'supervisor')
|
||||
}
|
||||
>
|
||||
<option value="user">일반 사용자</option>
|
||||
<option value="admin">시스템 관리자</option>
|
||||
<option value="admin">관리자</option>
|
||||
{/* 최고관리자(supervisor) 옵션은 오직 최고관리자만 부여 가능 */}
|
||||
{(currentUser?.role === 'supervisor' || (isEditing && users.find(u => u.id === formData.id)?.role === 'supervisor')) && (
|
||||
<option value="supervisor">최고 관리자 (Supervisor)</option>
|
||||
)}
|
||||
</select>
|
||||
{currentUser?.role !== 'supervisor' && (
|
||||
<p className="text-[10px] text-slate-400 mt-1">* 최고관리자 권한 부여는 최고관리자만 가능합니다.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">부서</label>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">부서</label>
|
||||
<Input
|
||||
value={formData.department}
|
||||
onChange={(e) => setFormData({ ...formData, department: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">직위</label>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">직위</label>
|
||||
<Input
|
||||
value={formData.position}
|
||||
onChange={(e) => setFormData({ ...formData, position: e.target.value })}
|
||||
@ -271,18 +302,18 @@ export function UserManagementPage() {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">핸드폰 번호</label>
|
||||
<label className="block text-xs font-bold text-slate-500 uppercase tracking-wider mb-1.5">핸드폰 번호</label>
|
||||
<Input
|
||||
value={formData.phone}
|
||||
onChange={(e) => setFormData({ ...formData, phone: formatPhoneNumber(e.target.value) })}
|
||||
placeholder="예: 010-1234-5678"
|
||||
placeholder="010-0000-0000"
|
||||
maxLength={13}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="pt-4 flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => setIsModalOpen(false)}>취소</Button>
|
||||
<Button type="submit" icon={<Check size={16} />}>{isEditing ? '저장' : '등록'}</Button>
|
||||
<Button type="submit" className="bg-indigo-600" icon={<Check size={16} />}>{isEditing ? '저장' : '전송'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
207
src/platform/pages/VersionPage.tsx
Normal file
207
src/platform/pages/VersionPage.tsx
Normal file
@ -0,0 +1,207 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card } from '../../shared/ui/Card';
|
||||
import { apiClient } from '../../shared/api/client';
|
||||
import { Info, Cpu, Database, Server, Hash, Calendar } from 'lucide-react';
|
||||
|
||||
interface VersionInfo {
|
||||
status: string;
|
||||
version: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export function VersionPage() {
|
||||
const [healthIcon, setHealthInfo] = useState<VersionInfo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchVersion = async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/health');
|
||||
setHealthInfo(res.data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch version info', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchVersion();
|
||||
}, []);
|
||||
|
||||
// Frontend version (aligned with package.json)
|
||||
const frontendVersion = '0.2.5';
|
||||
const buildDate = '2026-01-24';
|
||||
|
||||
return (
|
||||
<div className="page-container p-6 max-w-4xl mx-auto">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-slate-900">시스템 관리 - 버전 정보</h1>
|
||||
<p className="text-slate-500 mt-1">플랫폼 및 서버의 현재 릴리즈 버전을 확인합니다.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Frontend Platform Version */}
|
||||
<Card className="p-6 border-slate-200 shadow-sm relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Cpu size={120} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 bg-indigo-50 text-indigo-600 rounded-xl">
|
||||
<Hash size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-500 uppercase tracking-wider">Frontend Platform</h3>
|
||||
<p className="text-xl font-black text-slate-900">Smart IMS 클라이언트</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center py-2 border-b border-slate-50">
|
||||
<span className="text-slate-500 text-sm flex items-center gap-2"><Info size={14} /> 현재 버전</span>
|
||||
<span className="font-bold text-indigo-600 bg-indigo-50 px-3 py-1 rounded-full text-xs">v{frontendVersion}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-slate-50">
|
||||
<span className="text-slate-500 text-sm flex items-center gap-2"><Calendar size={14} /> 빌드 일자</span>
|
||||
<span className="font-medium text-slate-700 text-sm">{buildDate}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-slate-500 text-sm flex items-center gap-2"><Database size={14} /> 아키텍처</span>
|
||||
<span className="font-medium text-slate-700 text-sm">React Agentic Architecture</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Backend Server Version */}
|
||||
<Card className="p-6 border-slate-200 shadow-sm relative overflow-hidden group">
|
||||
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
|
||||
<Server size={120} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-xl">
|
||||
<Database size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-bold text-slate-500 uppercase tracking-wider">Backend Core</h3>
|
||||
<p className="text-xl font-black text-slate-900">IMS 서비스 엔진</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center py-2 border-b border-slate-50">
|
||||
<span className="text-slate-500 text-sm flex items-center gap-2"><Info size={14} /> API 버전</span>
|
||||
<span className="font-bold text-emerald-600 bg-emerald-50 px-3 py-1 rounded-full text-xs">
|
||||
{loading ? 'Checking...' : healthIcon?.version ? `v${healthIcon.version}` : 'N/A'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2 border-b border-slate-50">
|
||||
<span className="text-slate-500 text-sm flex items-center gap-2"><Calendar size={14} /> 서버 타임스탬프</span>
|
||||
<span className="font-medium text-slate-700 text-sm truncate max-w-[150px]">
|
||||
{loading ? '...' : healthIcon?.timestamp || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-slate-500 text-sm flex items-center gap-2"><Server size={14} /> 엔진 상태</span>
|
||||
<span className={`font-bold text-xs uppercase ${healthIcon?.status === 'ok' ? 'text-emerald-500' : 'text-red-500'}`}>
|
||||
{loading ? '...' : healthIcon?.status === 'ok' ? 'Running' : 'Offline'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
{/* Release History Section */}
|
||||
<div className="mt-12 space-y-6">
|
||||
<h2 className="text-xl font-bold text-slate-900 flex items-center gap-2 mb-4">
|
||||
<Calendar size={22} className="text-indigo-600" />
|
||||
업데이트 히스토리
|
||||
</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
version: '0.2.5',
|
||||
date: '2026-01-24',
|
||||
title: '플랫폼 보안 모듈 및 관리자 권한 체계 강화',
|
||||
changes: [
|
||||
'최고관리자(Supervisor) 전용 2중 보안 잠금 시스템 적용',
|
||||
'데이터베이스 및 암호화 마스터 키 자가 관리 엔진 구축',
|
||||
'사용자 관리 UI 디자인 및 권한 계층 로직 일원화',
|
||||
'시스템 버전 관리 통합 (v0.2.5)'
|
||||
],
|
||||
type: 'feature'
|
||||
},
|
||||
{
|
||||
version: '0.2.1',
|
||||
date: '2026-01-22',
|
||||
title: 'IMS 서비스 코어 성능 최적화',
|
||||
changes: [
|
||||
'API 서버 헬스체크 및 실시간 상태 모니터링 연동',
|
||||
'보안 세션 타임아웃 유동적 처리 미들웨어 도입',
|
||||
'자산 관리 모듈 초기 베타 릴리즈'
|
||||
],
|
||||
type: 'fix'
|
||||
}
|
||||
].map((entry, idx) => (
|
||||
<Card key={entry.version} className={`p-6 border-slate-200 shadow-sm transition-all hover:border-indigo-200 ${idx === 0 ? 'bg-indigo-50/20 border-indigo-100 ring-2 ring-indigo-50/50' : ''}`}>
|
||||
<div className="flex flex-col md:flex-row md:items-center gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-wider ${entry.type === 'feature' ? 'bg-indigo-600 text-white' : 'bg-slate-200 text-slate-700'}`}>
|
||||
{entry.type}
|
||||
</span>
|
||||
<span className="font-bold text-slate-900 font-mono text-base">v{entry.version}</span>
|
||||
</div>
|
||||
<div className="hidden md:block w-px h-4 bg-slate-200 mx-2"></div>
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-bold text-slate-800">{entry.title}</h4>
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 font-medium px-2 py-1 bg-slate-50 rounded italic">{entry.date}</div>
|
||||
</div>
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-2">
|
||||
{entry.changes.map((change, i) => (
|
||||
<li key={i} className="flex items-start gap-2 text-[13px] text-slate-600 leading-relaxed">
|
||||
<div className="mt-1.5 w-1.5 h-1.5 rounded-full bg-indigo-500/50 flex-shrink-0 animate-pulse"></div>
|
||||
<span>{change}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom Integrity Banner */}
|
||||
<Card className="mt-12 p-6 bg-slate-900 text-white border-none shadow-xl overflow-hidden relative">
|
||||
<div className="relative z-10">
|
||||
<h3 className="text-lg font-bold mb-2 flex items-center gap-2">
|
||||
<ShieldCheck size={20} className="text-amber-400" /> Platform Integrity
|
||||
</h3>
|
||||
<p className="text-slate-400 text-sm leading-relaxed max-w-2xl">
|
||||
Smart IMS 플랫폼은 데이터 보안과 시스템 안정성을 최우선으로 설계되었습니다.
|
||||
모든 모듈은 Sokuree 아키텍처 표준을 준수하며, 암호화 키 관리 시스템(L2 Protection)에 의해 보호되고 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
<div className="absolute -bottom-4 -right-4 opacity-10">
|
||||
<Box size={160} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Internal Local Components if not available in shared/ui
|
||||
function ShieldCheck({ size, className }: { size?: number, className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size || 24} height={size || 24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Box({ size, className }: { size?: number, className?: string }) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width={size || 24} height={size || 24} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className={className}>
|
||||
<path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" />
|
||||
<path d="m3.3 7 8.7 5 8.7-5" />
|
||||
<path d="M12 22V12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@ -60,6 +60,7 @@ body {
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button {
|
||||
|
||||
@ -69,8 +69,14 @@ export interface Manual {
|
||||
|
||||
export const assetApi = {
|
||||
getAll: async (): Promise<Asset[]> => {
|
||||
const response = await apiClient.get<DBAsset[]>('/assets');
|
||||
return response.data.map(mapDBToAsset);
|
||||
try {
|
||||
const response = await apiClient.get<DBAsset[]>('/assets');
|
||||
if (!response.data || !Array.isArray(response.data)) return [];
|
||||
return response.data.map(mapDBToAsset);
|
||||
} catch (err) {
|
||||
console.error('API Error in getAll:', err);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
getById: async (id: string): Promise<Asset> => {
|
||||
|
||||
@ -25,3 +25,18 @@ apiClient.interceptors.request.use((config) => {
|
||||
}
|
||||
return config;
|
||||
});
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
if (error.response && (error.response.status === 401 || error.response.status === 403)) {
|
||||
// Avoid infinite loops if we are already on login page or checking session
|
||||
const currentPath = window.location.pathname;
|
||||
if (currentPath !== '/login' && !error.config.url.includes('/check')) {
|
||||
console.warn('Session expired or security error. Redirecting to login.');
|
||||
// Brute force redirect for simplicity in MVP
|
||||
window.location.href = '/login?expired=true';
|
||||
}
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
@ -4,7 +4,7 @@ import { apiClient, setCsrfToken } from '../api/client';
|
||||
export interface User {
|
||||
id: string;
|
||||
name: string;
|
||||
role: 'admin' | 'user';
|
||||
role: 'supervisor' | 'admin' | 'user';
|
||||
department?: string;
|
||||
position?: string;
|
||||
phone?: string;
|
||||
@ -25,25 +25,82 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// Check for existing session on mount
|
||||
// Check for existing session on mount and periodically
|
||||
useEffect(() => {
|
||||
let lastActivity = Date.now();
|
||||
let timeoutMs = 3600000; // Default 1 hour
|
||||
|
||||
const checkSession = async () => {
|
||||
try {
|
||||
const response = await apiClient.get('/check');
|
||||
if (response.data.isAuthenticated) {
|
||||
setUser(response.data.user);
|
||||
if (response.data.csrfToken) {
|
||||
setCsrfToken(response.data.csrfToken);
|
||||
}
|
||||
if (response.data.csrfToken) setCsrfToken(response.data.csrfToken);
|
||||
if (response.data.sessionTimeout) timeoutMs = response.data.sessionTimeout;
|
||||
} else {
|
||||
// Safety: only redirect if we were previously logged in
|
||||
setUser(prev => {
|
||||
if (prev) {
|
||||
setCsrfToken(null);
|
||||
window.location.href = '/login?expired=true';
|
||||
return null;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Session check failed:', error);
|
||||
setUser(prev => {
|
||||
if (prev) {
|
||||
setCsrfToken(null);
|
||||
window.location.href = '/login?expired=true';
|
||||
return null;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const updateActivity = () => {
|
||||
lastActivity = Date.now();
|
||||
};
|
||||
|
||||
// Activity Listeners
|
||||
window.addEventListener('mousemove', updateActivity);
|
||||
window.addEventListener('keydown', updateActivity);
|
||||
window.addEventListener('scroll', updateActivity);
|
||||
window.addEventListener('click', updateActivity);
|
||||
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
// Check activity status every 30 seconds
|
||||
const activityInterval = setInterval(() => {
|
||||
// Functional check to avoid stale user closure
|
||||
setUser(current => {
|
||||
if (current) {
|
||||
const idleTime = Date.now() - lastActivity;
|
||||
if (idleTime >= timeoutMs) {
|
||||
console.log('Idle timeout reached. Checking session...');
|
||||
checkSession();
|
||||
}
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, 30000);
|
||||
|
||||
// Fallback polling every 5 minutes (for secondary tabs etc)
|
||||
const pollInterval = setInterval(checkSession, 300000);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('mousemove', updateActivity);
|
||||
window.removeEventListener('keydown', updateActivity);
|
||||
window.removeEventListener('scroll', updateActivity);
|
||||
window.removeEventListener('click', updateActivity);
|
||||
clearInterval(activityInterval);
|
||||
clearInterval(pollInterval);
|
||||
};
|
||||
}, []); // Removed [user] to prevent infinite loop
|
||||
|
||||
const login = async (id: string, password: string) => {
|
||||
try {
|
||||
|
||||
@ -103,6 +103,10 @@
|
||||
transition: var(--transition-base);
|
||||
font-weight: 500;
|
||||
font-size: 0.9rem;
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.nav-item:hover {
|
||||
@ -181,26 +185,39 @@
|
||||
background-color: var(--sokuree-bg-main);
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-5px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Top Header - White Theme */
|
||||
.top-header {
|
||||
height: 64px;
|
||||
background-color: var(--sokuree-bg-card);
|
||||
/* White Header */
|
||||
border-bottom: 1px solid var(--sokuree-border-color);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
/* Tabs at bottom */
|
||||
padding: 0 2rem;
|
||||
padding: 0 2.5rem;
|
||||
color: var(--sokuree-text-primary);
|
||||
justify-content: flex-end;
|
||||
/* Align to right */
|
||||
}
|
||||
|
||||
.header-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
gap: 1.5rem;
|
||||
/* Better gap between tabs */
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
padding: 0.75rem 1.5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-weight: 500;
|
||||
color: var(--sokuree-text-secondary);
|
||||
border-bottom: 3px solid transparent;
|
||||
@ -211,20 +228,17 @@
|
||||
position: relative;
|
||||
top: 1px;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tab-item:hover {
|
||||
color: var(--sokuree-brand-primary);
|
||||
background-color: rgba(0, 0, 0, 0.02);
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: var(--sokuree-brand-primary);
|
||||
font-weight: 700;
|
||||
border-bottom-color: var(--sokuree-brand-primary);
|
||||
/* Blue underline */
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
|
||||
@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import { Outlet, Link, useLocation } from 'react-router-dom';
|
||||
import { useAuth } from '../../shared/auth/AuthContext';
|
||||
import { useSystem } from '../../shared/context/SystemContext';
|
||||
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield } from 'lucide-react';
|
||||
import { Settings, LogOut, Box, ChevronDown, ChevronRight, Layers, User as UserIcon, Video, Shield, Info } from 'lucide-react';
|
||||
import type { IModuleDefinition } from '../../core/types';
|
||||
import './MainLayout.css';
|
||||
|
||||
@ -16,6 +16,15 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
const { modules } = useSystem();
|
||||
const [expandedModules, setExpandedModules] = useState<string[]>(['asset-management']);
|
||||
|
||||
const isAdmin = user ? (['admin', 'supervisor'] as string[]).includes(user.role) : false;
|
||||
|
||||
const checkRole = (requiredRoles: string[]) => {
|
||||
if (!user) return false;
|
||||
// Supervisor possesses all rights
|
||||
if (user.role === 'supervisor') return true;
|
||||
return requiredRoles.includes(user.role);
|
||||
};
|
||||
|
||||
const toggleModule = (moduleId: string) => {
|
||||
setExpandedModules(prev =>
|
||||
prev.includes(moduleId)
|
||||
@ -38,7 +47,7 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
{/* Module: System Management (Platform Core) */}
|
||||
{user?.role === 'admin' && (
|
||||
{isAdmin && (
|
||||
<div className="module-group">
|
||||
<button
|
||||
className={`module-header ${expandedModules.includes('sys_mgmt') ? 'active' : ''}`}
|
||||
@ -65,6 +74,10 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
<Shield size={18} />
|
||||
<span>모듈/라이선스 관리</span>
|
||||
</Link>
|
||||
<Link to="/admin/version" className={`nav-item ${location.pathname.includes('/admin/version') ? 'active' : ''}`}>
|
||||
<Info size={18} />
|
||||
<span>버전 정보</span>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -75,8 +88,8 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
const moduleKey = mod.moduleName.split('-')[0];
|
||||
if (!isModuleActive(moduleKey)) return null;
|
||||
|
||||
// Check roles
|
||||
if (mod.requiredRoles && user && !mod.requiredRoles.includes(user.role)) 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);
|
||||
@ -188,7 +201,9 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
const activeModule = modulesList.find(m => location.pathname.startsWith(m.basePath));
|
||||
if (!activeModule) return null;
|
||||
|
||||
const topRoutes = activeModule.routes.filter(r => r.position === 'top');
|
||||
const topRoutes = location.pathname.includes('/settings')
|
||||
? []
|
||||
: activeModule.routes.filter(r => r.position === 'top');
|
||||
|
||||
return topRoutes.map(route => (
|
||||
<Link
|
||||
@ -201,14 +216,25 @@ export function MainLayout({ modulesList }: MainLayoutProps) {
|
||||
));
|
||||
})()}
|
||||
|
||||
{/* Legacy manual override check (can be removed if Asset Settings is fully migrated to route config) */}
|
||||
{location.pathname.includes('/asset/settings') && (
|
||||
<>
|
||||
{/* Keeping this just in case, but ideally should be managed via route config now?
|
||||
Actually settings tabs are usually sub-pages of settings, not top-level module nav.
|
||||
Leaving for safety.
|
||||
*/}
|
||||
</>
|
||||
{/* Asset Settings Specific Tabs */}
|
||||
{location.pathname.startsWith('/asset/settings') && (
|
||||
<div className="header-tabs">
|
||||
{[
|
||||
{ id: 'basic', label: '기본 설정' },
|
||||
{ id: 'category', label: '카테고리 관리' },
|
||||
{ id: 'location', label: '설치 위치' },
|
||||
{ id: 'status', label: '자산 상태' },
|
||||
{ id: 'maintenance', label: '정비 구분' }
|
||||
].map(tab => (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to={`/asset/settings?tab=${tab.id}`}
|
||||
className={`tab-item ${(new URLSearchParams(location.search).get('tab') || 'basic') === tab.id ? 'active' : ''}`}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
BIN
temp_auth.js
Normal file
BIN
temp_auth.js
Normal file
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user