Compare commits
77 Commits
9cd5d59bf8
...
ux_setting
| Author | SHA1 | Date | |
|---|---|---|---|
| af578a63bc | |||
| e8bc42e5de | |||
| 587e92a7da | |||
| c6515c1b5d | |||
| e128634e05 | |||
| c0ef52deac | |||
| aab1f91d3d | |||
| f656f0a439 | |||
| e77c4854cb | |||
| 1d32a0350b | |||
| 309c400ee2 | |||
| 3db05f2939 | |||
| 2cb4b87c0a | |||
| 6ed2faee2d | |||
| 89d3ac2e89 | |||
| b37981506e | |||
| abc531a41e | |||
| 8451101325 | |||
| 3e69e74bc9 | |||
| 73ef13f3a5 | |||
| 155570e8de | |||
| 723c4723f6 | |||
| a44283281f | |||
| fa87f383e2 | |||
| 6118141f6e | |||
| 119c799d1d | |||
| 05e23883b8 | |||
| 8c406fd0b8 | |||
| e678f9d653 | |||
| 132e37d0d3 | |||
| d6e75f8b2c | |||
| c35f57acab | |||
| 97cecb8b50 | |||
| a4b620099c | |||
| b169176d57 | |||
| 56abdddbc7 | |||
| fd9e88d7c6 | |||
| 407b9ba531 | |||
| 55c43aa250 | |||
| 9186eb50ca | |||
| 8a3727ea61 | |||
| 0c1977f707 | |||
| 19e6be27de | |||
| accbbdc2fa | |||
| d3c4fa5e66 | |||
| 8c1cb6cf93 | |||
| 4810df212a | |||
| f5a84a77ef | |||
| 565802f55b | |||
| 10479aad7e | |||
| 95fbd3f606 | |||
| 207acbdecb | |||
| 164568843b | |||
| 29c7d5f3d8 | |||
| ce1ed40561 | |||
| 525dbd77d4 | |||
| 35c5b1e0fa | |||
| b87ca2854b | |||
| 2f88a0fae7 | |||
| 9a2c35e652 | |||
| 25ebaf4685 | |||
| 2b9c965c91 | |||
| 4b408b0640 | |||
| 3ab587d342 | |||
| 3b9b2ea598 | |||
| 05c565552a | |||
| 2ec9261c03 | |||
| 06f3baaa58 | |||
| eead43837d | |||
| 46422e8544 | |||
| a30f99f0ad | |||
| 34d99dc4b6 | |||
| bb859dddfc | |||
| 9e8ab11f99 | |||
| 19d4222470 | |||
| db5c7a96a6 | |||
| 7d3d5ef281 |
6
.env
Normal file
@@ -0,0 +1,6 @@
|
||||
DB_HOST=172.16.8.151
|
||||
DB_PORT=3306
|
||||
DB_USER=itam_admin
|
||||
DB_PASS=itam1234
|
||||
DB_NAME=itam
|
||||
PORT=3000
|
||||
33
README.md
@@ -9,6 +9,14 @@
|
||||
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
|
||||
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
|
||||
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
|
||||
5. **RED–GREEN–Refactor 개발 원칙**:
|
||||
- 모든 기능 개발과 버그 수정은 **RED → GREEN → Refactor** 순서로 진행한다.
|
||||
- **RED**: 요구사항을 명확히 표현하는 테스트를 먼저 작성하고, 해당 테스트가 기능 미구현 또는 결함으로 인해 실패하는지 확인한다.
|
||||
- **GREEN**: 실패한 테스트를 통과시키는 데 필요한 최소한의 코드만 구현하며, 불필요한 기능 추가나 구조 변경을 하지 않는다.
|
||||
- **Refactor**: 관련 테스트와 기존 테스트가 모두 통과하는 상태에서만 중복 제거, 명칭 개선, 책임 분리 등 코드 구조를 개선하며 동작은 변경하지 않는다.
|
||||
- 각 단계가 끝날 때마다 관련 테스트와 기존 기능의 회귀 여부를 검증한다.
|
||||
- 테스트 작성이 현실적으로 불가능한 경우에는 그 사유와 대체 검증 방법을 먼저 보고하고 승인을 받은 후 진행한다.
|
||||
- 본 원칙을 적용할 때에도 기존의 **선보고 후승인** 및 **외과 수술식 수정** 규칙을 준수한다.
|
||||
|
||||
---
|
||||
|
||||
@@ -28,29 +36,8 @@
|
||||
|
||||
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
|
||||
|
||||
1. **디자인 철학 (Design Philosophy)**
|
||||
* **Minimalist & Border-based**: 불필요한 박스(Card) 사용을 최소화하고, 정보의 구분은 간결한 라인(Border/Divider)을 활용하여 시각적 피로도를 낮춥니다.
|
||||
* **Professional Achromatic**: 무채색(Black, White, Grey)을 기본으로 하여 정돈된 업무 환경을 제공합니다.
|
||||
* **Green Accent**: 블루 대신 짙은 그린(`#1E5149`)을 포인트 컬러로 사용하여 차분한 전문성을 강조합니다.
|
||||
디자인 일관성 및 시각적 원칙에 관한 상세 내용은 아래 문서를 참조하십시오.
|
||||
|
||||
2. **타이포그래피 (Typography)**
|
||||
* **Font Family**: `Pretendard` (전역 적용)
|
||||
* **Letter Spacing**: `-0.02em` (약 -2%) 적용. 자간을 좁게 설정하여 밀도 있고 세련된 가독성을 확보합니다.
|
||||
* **Weights**: 400(Regular), 500(Medium), 600(SemiBold), 700(Bold).
|
||||
👉 **[디자인 가이드 바로가기 (design_rule.md)](./design_rule.md)**
|
||||
|
||||
3. **컬러 팔레트 (Color Palette)**
|
||||
* **Point Color**: `#1E5149` (Deep Green) - 강조, 활성화 상태, 주요 액션 버튼.
|
||||
* **Text**: Main(`#111827` - Near Black), Muted(`#6B7280` - Grey).
|
||||
* **Border/Divider**: `#E5E7EB` (Light Grey) - 정보 구분을 위한 얇은 실선.
|
||||
* **Background**: `#FFFFFF` (White) / `#F9FAFB` (Off White).
|
||||
|
||||
4. **레이아웃 및 컴포넌트 규칙 (Layout Rules)**
|
||||
* **Box-less Design**: 꼭 필요한 정보 묶음(데이터 그룹화 등)이 아니면 박스 형태의 테두리나 배경 사용을 지양합니다.
|
||||
* **Line-based Division**: 섹션 간의 구분은 1px 두께의 얇은 실선(Border)을 통해 명확히 합니다.
|
||||
* **Table**: 배경색이나 화려한 효과 없이 행(Row) 간의 얇은 구분선만 사용하여 데이터 본연에 집중하게 합니다.
|
||||
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
|
||||
* **Modal (모달 공통 규칙)**:
|
||||
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
|
||||
* **Interaction**: 사용자의 오입력(실수로 바깥을 클릭하여 입력 내용이 날아가는 현상)을 방지하기 위해 **모달 바깥 영역(Overlay) 클릭 시 모달이 닫히지 않도록** 설정합니다. 닫기는 오직 'ESC' 키 또는 명시적인 'X' 및 '닫기' 버튼을 통해서만 가능합니다.
|
||||
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.
|
||||
|
||||
|
||||
176
db_init.js
@@ -1,176 +0,0 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
|
||||
|
||||
async function initDB() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306'),
|
||||
multipleStatements: true
|
||||
});
|
||||
|
||||
console.log('🔄 DB 초기화 시작 (영문 표준 스키마 적용)...');
|
||||
|
||||
const tablesToDrop = [
|
||||
'pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets',
|
||||
'sw_sub_assets', 'sw_perm_assets', 'cloud_assets', 'sw_users', 'asset_logs'
|
||||
];
|
||||
for (const table of tablesToDrop) {
|
||||
await connection.query(`DROP TABLE IF EXISTS ${table}`);
|
||||
}
|
||||
|
||||
const createHardwareTable = (tableName, comment) => `
|
||||
CREATE TABLE ${tableName} (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
corp VARCHAR(100),
|
||||
asset_code VARCHAR(100),
|
||||
purchase_date VARCHAR(50),
|
||||
type VARCHAR(50),
|
||||
detail_purpose VARCHAR(50),
|
||||
purpose VARCHAR(255),
|
||||
details TEXT,
|
||||
current_org VARCHAR(255),
|
||||
prev_org VARCHAR(255),
|
||||
location VARCHAR(255),
|
||||
manager_main VARCHAR(100),
|
||||
manager_sub VARCHAR(100),
|
||||
ip_address VARCHAR(100),
|
||||
remote_tool VARCHAR(100),
|
||||
server_id VARCHAR(100),
|
||||
server_pw VARCHAR(100),
|
||||
model_name VARCHAR(255),
|
||||
mainboard VARCHAR(255) COMMENT '메인보드',
|
||||
os VARCHAR(100),
|
||||
cpu VARCHAR(255),
|
||||
ram VARCHAR(100),
|
||||
gpu VARCHAR(100),
|
||||
storage1 VARCHAR(255),
|
||||
storage2 VARCHAR(255),
|
||||
storage3 VARCHAR(255),
|
||||
monitoring VARCHAR(100),
|
||||
price VARCHAR(100),
|
||||
remarks TEXT,
|
||||
storage_location VARCHAR(255),
|
||||
status VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`;
|
||||
|
||||
await connection.query(createHardwareTable('pc_assets', 'PC'));
|
||||
await connection.query(createHardwareTable('server_assets', 'Server'));
|
||||
await connection.query(createHardwareTable('storage_assets', 'Storage'));
|
||||
await connection.query(createHardwareTable('equip_assets', 'Equipment'));
|
||||
await connection.query(createHardwareTable('mobile_assets', 'Mobile'));
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE sw_sub_assets (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
corp VARCHAR(100) COMMENT '구매법인',
|
||||
category VARCHAR(100) COMMENT '분야',
|
||||
dept VARCHAR(100) COMMENT '부서',
|
||||
product_name VARCHAR(255) COMMENT '제품명',
|
||||
license_type VARCHAR(100) COMMENT '라이선스 유형',
|
||||
quantity INT COMMENT '수량',
|
||||
price VARCHAR(100) COMMENT '금액',
|
||||
purchase_date VARCHAR(50) COMMENT '구매일',
|
||||
start_date VARCHAR(50) COMMENT '시작일',
|
||||
expiry_date VARCHAR(50) COMMENT '만료일',
|
||||
vendor VARCHAR(255) COMMENT '구매업체',
|
||||
remarks TEXT COMMENT '비고',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE sw_perm_assets (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
corp VARCHAR(100) COMMENT '구매법인',
|
||||
category VARCHAR(100) COMMENT '분야',
|
||||
dept VARCHAR(100) COMMENT '부서',
|
||||
product_name VARCHAR(255) COMMENT '제품명',
|
||||
license_key VARCHAR(255) COMMENT '라이선스 키',
|
||||
quantity INT COMMENT '수량',
|
||||
price VARCHAR(100) COMMENT '금액',
|
||||
purchase_date VARCHAR(50) COMMENT '구매일',
|
||||
start_date VARCHAR(50) COMMENT '시작일',
|
||||
expiry_date VARCHAR(50) COMMENT '만료일',
|
||||
vendor VARCHAR(255) COMMENT '구매업체',
|
||||
remarks TEXT COMMENT '비고',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE cloud_assets (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
platform_name VARCHAR(100),
|
||||
corp VARCHAR(100),
|
||||
dept VARCHAR(100),
|
||||
product_name VARCHAR(255),
|
||||
account_name VARCHAR(255),
|
||||
pay_method VARCHAR(100),
|
||||
pay_day VARCHAR(50),
|
||||
card_num VARCHAR(100),
|
||||
monthly_fee VARCHAR(100),
|
||||
remarks TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE sw_users (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
sw_id VARCHAR(50),
|
||||
corp VARCHAR(100),
|
||||
dept VARCHAR(100),
|
||||
position VARCHAR(50),
|
||||
user_name VARCHAR(100),
|
||||
usage_period VARCHAR(100),
|
||||
doc_name VARCHAR(255),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE asset_logs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_id VARCHAR(50),
|
||||
log_date VARCHAR(50),
|
||||
log_user VARCHAR(100),
|
||||
details TEXT,
|
||||
cost DECIMAL(15,2) DEFAULT 0,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE ops_domain_assets (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
type VARCHAR(50) COMMENT '유형',
|
||||
corp VARCHAR(100) COMMENT '법인',
|
||||
service_name VARCHAR(255) COMMENT '서비스명',
|
||||
domain_name VARCHAR(255) COMMENT '관리도메인',
|
||||
start_date VARCHAR(50) COMMENT '시작일',
|
||||
expiry_date VARCHAR(50) COMMENT '만료일',
|
||||
price VARCHAR(100) COMMENT '금액',
|
||||
manager_main VARCHAR(100) COMMENT '담당자',
|
||||
manager_sub VARCHAR(100) COMMENT '담당자(부)',
|
||||
remarks TEXT COMMENT '비고',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
console.log('✅ 모든 테이블이 영문 표준 스키마로 재생성되었습니다.');
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
initDB().catch(err => {
|
||||
console.error('❌ DB 초기화 실패:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
379
docs/plans/PC_사양_개선_기획서.html
Normal file
@@ -0,0 +1,379 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=device-width, initial-scale=1.0">
|
||||
<title>PC 사양 대시보드 시각화 개선 기획서</title>
|
||||
<!-- Google Fonts: Pretendard 대체용 Outfit & Noto Sans KR -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@300;400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #4F46E5;
|
||||
--primary-light: #EEF2FF;
|
||||
--secondary: #10B981;
|
||||
--secondary-light: #D1FAE5;
|
||||
--text-dark: #0F172A;
|
||||
--text-muted: #64748B;
|
||||
--border-color: #E2E8F0;
|
||||
--bg-light: #F8FAFC;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Outfit', 'Noto Sans KR', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
color: var(--text-dark);
|
||||
background-color: #FFFFFF;
|
||||
line-height: 1.6;
|
||||
letter-spacing: -0.02em;
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* Header Styling */
|
||||
header {
|
||||
border-bottom: 2px solid var(--text-dark);
|
||||
padding-bottom: 1.5rem;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.doc-category {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.25rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-dark);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.meta-info {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.meta-info span strong {
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
/* Section Styling */
|
||||
section {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-dark);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
h2::before {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
height: 18px;
|
||||
background-color: var(--primary);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 1rem;
|
||||
color: #334155;
|
||||
margin-bottom: 1rem;
|
||||
text-align: justify;
|
||||
}
|
||||
|
||||
/* List & Card Styling */
|
||||
ul {
|
||||
list-style-position: inside;
|
||||
margin-left: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.spec-card {
|
||||
background-color: var(--bg-light);
|
||||
border-left: 4px solid var(--primary);
|
||||
border-radius: 4px;
|
||||
padding: 1.25rem;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.spec-card h3 {
|
||||
font-size: 1.05rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--text-dark);
|
||||
}
|
||||
|
||||
/* Table Styling */
|
||||
.table-container {
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
margin: 1.5rem 0;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.9rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--bg-light);
|
||||
font-weight: 700;
|
||||
color: var(--text-dark);
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
color: var(--primary);
|
||||
background-color: var(--primary-light);
|
||||
}
|
||||
|
||||
.badge-secondary {
|
||||
color: var(--secondary);
|
||||
background-color: var(--secondary-light);
|
||||
}
|
||||
|
||||
/* Highlight box */
|
||||
.note-box {
|
||||
background-color: #FFFBEB;
|
||||
border: 1px solid #FCD34D;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
margin: 1.5rem 0;
|
||||
font-size: 0.95rem;
|
||||
color: #92400E;
|
||||
}
|
||||
|
||||
.note-box strong {
|
||||
color: #78350F;
|
||||
}
|
||||
|
||||
footer {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding-top: 1.5rem;
|
||||
margin-top: 4rem;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="doc-category">기획 명세서 / Product Specification</div>
|
||||
<h1>PC 사양 대시보드 시각화 개선 기획서</h1>
|
||||
<div class="meta-info">
|
||||
<span>기획부서: <strong>IT자산관리 태스크포스(TF)</strong></span>
|
||||
<span>최종 수정일: <strong>2026. 05. 28</strong></span>
|
||||
<span>문서 버전: <strong>v1.1 (실제 엑셀 데이터 반영)</strong></span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 1. 개요 및 목적 -->
|
||||
<section>
|
||||
<h2>기획 개요 및 목적</h2>
|
||||
<p>본 기획은 법인별/직무별 PC 자산 사양 현황의 시각적 피로도를 낮추고 데이터 전달력을 고도화하기 위한 개선 작업을 목적으로 합니다. 기존 대시보드 레이아웃의 비정형 비율을 재정립하고, 평균 점수와 권장 점수의 비교 방식을 '다중 막대' 형태에서 <strong>'혼합형(막대 + 꺾은선) 차트'</strong>로 변경하여 대조 직관성을 극대화합니다.</p>
|
||||
</section>
|
||||
|
||||
<!-- 2. 주요 개선 사항 -->
|
||||
<section>
|
||||
<h2>주요 개선 내역</h2>
|
||||
|
||||
<div class="spec-card">
|
||||
<h3>① 가족사별 PC 사양 현황 레이아웃 고도화</h3>
|
||||
<ul>
|
||||
<li><strong>가로 비율 정밀 제어 (1:2)</strong>: 평균 점수 리스트와 막대그래프의 가로 폭 비율을 <code>1 : 2</code>로 엄격하게 고정하여 반응형 레이아웃 환경에서도 깨짐 없는 균형미를 제공합니다.</li>
|
||||
<li><strong>가독성 개선</strong>: 가족사 텍스트 크기를 <code>0.95rem</code>, 평균 사양 점수 텍스트 크기를 <code>1.05rem</code>으로 키우고 세로 행간 여백을 확보해 가시성을 향상시켰습니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="spec-card">
|
||||
<h3>② 직무별 PC 사양 평균 및 권장 점수 혼합 시각화</h3>
|
||||
<ul>
|
||||
<li><strong>혼합형 차트(Mixed Chart) 구성</strong>: 직무별 PC 사양 평균 점수는 <span class="badge badge-primary">막대(Bar)</span> 그래프로, 권장 PC 사양 점수는 그 위를 관통하는 <span class="badge badge-secondary">선(Line)</span> 그래프로 표현합니다.</li>
|
||||
<li><strong>레이어 정렬 우선순위 적용</strong>: 차트 정의 시 권장 점수선(Line)이 평균 점수막대(Bar) 뒤에 가리지 않고 항상 맨 앞에 위치하도록 렌더링 우선순위(<code>order</code> 속성)를 명확히 지정합니다.</li>
|
||||
<li><strong>정렬 원복</strong>: 수동 정렬을 지양하고, 직무별 실제 평균 PC 사양 점수가 높은 순으로 자동 내림차순 정렬되도록 하여 가장 자연스러운 시각화를 구축합니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. 데이터 정의 -->
|
||||
<section>
|
||||
<h2>직무별 평균 및 권장 사양 점수 스펙</h2>
|
||||
<p>실제 PC 자산 데이터(CPU 및 RAM 점수 연산 결과)와 관리자의 권장 기준선이 아래 명시된 대소 조건 관계를 완벽히 만족하도록 더미 데이터 및 초기 권장 스펙 기준을 재정의했습니다.</p>
|
||||
|
||||
<div class="note-box">
|
||||
<strong>대소 관계 정렬 순서 (실제 평균 점수 기준):</strong><br>
|
||||
AI 개발자 ➔ 편집 디자이너 ➔ 3D 디자이너 ➔ UXUI 디자이너 ➔ 3D 개발자 ➔ 프로그램 개발자 ➔ BIM모델러 ➔ 엔지니어 ➔ 웹 개발자 ➔ 기획자 순서로 실제 평균 점수 순위가 자동 정렬되어 시각화됩니다. (감리원은 실제 자산 데이터 부재로 비교군에서 제외)
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>정렬 순위</th>
|
||||
<th>직무명</th>
|
||||
<th>실제 평균 사양 점수 (Bar)</th>
|
||||
<th>기본 권장 사양 점수 (기준)</th>
|
||||
<th>대소 관계 평가</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>1</td>
|
||||
<td><strong>AI 개발자</strong></td>
|
||||
<td>88.0 점</td>
|
||||
<td>95 점</td>
|
||||
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>2</td>
|
||||
<td><strong>편집 디자이너</strong></td>
|
||||
<td>80.2 점</td>
|
||||
<td>75 점</td>
|
||||
<td><span class="badge badge-secondary">권장 스펙 충족</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>3</td>
|
||||
<td><strong>3D 디자이너</strong></td>
|
||||
<td>78.4 점</td>
|
||||
<td>90 점</td>
|
||||
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>4</td>
|
||||
<td><strong>UXUI 디자이너</strong></td>
|
||||
<td>72.7 점</td>
|
||||
<td>70 점</td>
|
||||
<td><span class="badge badge-secondary">권장 스펙 충족</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>5</td>
|
||||
<td><strong>3D 개발자</strong></td>
|
||||
<td>67.8 점</td>
|
||||
<td>90 점</td>
|
||||
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>6</td>
|
||||
<td><strong>프로그램 개발자</strong></td>
|
||||
<td>67.3 점</td>
|
||||
<td>80 점</td>
|
||||
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>7</td>
|
||||
<td><strong>BIM모델러</strong></td>
|
||||
<td>62.1 점</td>
|
||||
<td>75 점</td>
|
||||
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>8</td>
|
||||
<td><strong>엔지니어</strong></td>
|
||||
<td>42.9 점</td>
|
||||
<td>60 점</td>
|
||||
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>9</td>
|
||||
<td><strong>웹 개발자</strong></td>
|
||||
<td>39.2 점</td>
|
||||
<td>75 점</td>
|
||||
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>10</td>
|
||||
<td><strong>기획자</strong></td>
|
||||
<td>38.6 점</td>
|
||||
<td>50 점</td>
|
||||
<td><span class="badge badge-secondary">미달 (교체 요망)</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>11</td>
|
||||
<td><strong>감리원</strong></td>
|
||||
<td>-</td>
|
||||
<td>40.0 점</td>
|
||||
<td><span class="badge badge-secondary">데이터 없음</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. 기술 구현 세부사항 -->
|
||||
<section>
|
||||
<h2>기술 구현 세부 사양</h2>
|
||||
<div class="spec-card" style="border-left-color: var(--secondary);">
|
||||
<h3>차트 렌더링 옵션 (Chart.js v4.x+)</h3>
|
||||
<p>평균 PC 사양 점수를 보여주는 데이터셋과 권장 PC 사양 점수를 보여주는 데이터셋을 하나의 Canvas 엘리먼트에 그리되, 레이어 겹침과 시인성을 확보하기 위해 다음 세부 옵션을 바인딩합니다.</p>
|
||||
<ul>
|
||||
<li><strong>Average Dataset</strong>: <code>type: 'bar', order: 2, backgroundColor: '#6366F1'</code></li>
|
||||
<li><strong>Recommended Dataset</strong>: <code>type: 'line', order: 1, borderColor: '#10B981', borderWidth: 3, pointRadius: 4, fill: false</code></li>
|
||||
<li><strong>정렬 로직</strong>: <code>Object.keys(jobScores).sort((a, b) => jobScores[b].avg - jobScores[a].avg)</code></li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>© 2026 HM ITAM Systems. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
60
docs/plans/PLAN_ASSET_HISTORY.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# 자산 이력 누적 관리 시스템 (Cumulative Asset History System) 구현 계획
|
||||
|
||||
본 문서는 자산의 라이프사이클(조직, 사용자, 용도, 상태 변동)을 체계적으로 추적하고 누적 관리하기 위한 기술적 설계 및 단계별 구현 계획을 담고 있습니다.
|
||||
|
||||
## 1. 목적
|
||||
- 자산 정보 수정 시 중요 변경 사항을 자동으로 감지하여 이력(Log)화
|
||||
- 과거부터 현재까지의 변동 사항을 타임라인 형태로 시각화하여 자산 흐름 파악
|
||||
- 데이터 정합성을 위해 서버 측에서 변경 전/후 스냅샷 비교 방식 채택
|
||||
|
||||
## 2. 관리 대상 이력 (Watch Fields)
|
||||
다음 항목의 변경이 발생할 경우 이력을 자동 생성합니다.
|
||||
1. **조직 변동**: `current_dept` (현 사용조직) ↔ `previous_dept` 업데이트 포함
|
||||
2. **사용자 변동**: `user_current` (현 사용자) ↔ `previous_user` 업데이트 포함
|
||||
3. **용도 변경**: `asset_type`, `current_role` (예: 개인PC -> 공용PC)
|
||||
4. **상태 변경**: `hw_status` (예: 운영 -> 수리, 재고 -> 폐기 등)
|
||||
|
||||
## 3. 기술 설계 (Technical Design)
|
||||
|
||||
### A. 데이터베이스 (DB)
|
||||
- **대상 테이블**: `asset_history`
|
||||
- **컬럼 구조 활용 및 보완**:
|
||||
- `asset_id`: 대상 자산 식별자
|
||||
- `event_type`: 변경 유형 (DEPT_CHANGE, USER_CHANGE, ROLE_CHANGE, STATUS_CHANGE)
|
||||
- `details`: "상태 변경: 운영 -> 수리" 와 같이 읽기 쉬운 문자열 저장
|
||||
- `cost`: 관련 비용 발생 시 기록 (수리비 등)
|
||||
- `log_user`: 변경을 수행한 작업자
|
||||
- `log_date`: 변경 발생 일시
|
||||
|
||||
### B. 백엔드 (Server-side Logic)
|
||||
- **위치**: `server.js` 의 `POST /api/asset/:category/save` 엔드포인트
|
||||
- **동작 흐름**:
|
||||
1. **Snapshot**: 인서트/업데이트 수행 전, 기존 DB의 데이터를 `SELECT`하여 메모리에 저장.
|
||||
2. **Comparison**: 요청된 신규 데이터와 기존 데이터를 필드별로 대조.
|
||||
3. **Auto-logging**: 변경점이 발견되면 `asset_history` 테이블에 즉시 인서트.
|
||||
4. **Transaction**: 모든 로그 생성이 자산 저장과 하나의 트랜잭션으로 묶여야 함.
|
||||
|
||||
### C. 프론트엔드 (UI/UX)
|
||||
- **위치**: `HWModal.ts` 우측 `modal-history-area`
|
||||
- **개선 사항**:
|
||||
- `renderHistory()` 함수를 고도화하여 이벤트 타입별 아이콘/컬러 적용.
|
||||
- "이전 값 ➔ 이후 값" 형태의 직관적인 레이아웃 도입.
|
||||
- 스크롤을 통한 무제한 누적 이력 조회 지원.
|
||||
|
||||
## 4. 단계별 구현 로직
|
||||
|
||||
### 1단계: 서버 로직 고도화
|
||||
- `server.js`에 비교 함수(`compareAndLog`) 구현.
|
||||
- 각 자산 카테고리별 저장 로직에 비교 로직 삽입.
|
||||
|
||||
### 2단계: DB 데이터 마이그레이션 (필요시)
|
||||
- 기존 자산의 `current_dept` 등을 `previous_dept`로 밀어내는 로직 점검.
|
||||
|
||||
### 3단계: UI 타임라인 렌더링 개선
|
||||
- `modal.css`에 이력 전용 스타일(이벤트 뱃지 등) 추가.
|
||||
- `HWModal.ts`에서 최신 로그를 실시간으로 다시 불러오는 로직 확인.
|
||||
|
||||
## 5. 검증 계획
|
||||
- **자동 감지 테스트**: 상태 변경 후 저장 시 우측 이력에 즉시 한 줄이 추가되는지 확인.
|
||||
- **다중 변경 테스트**: 조직과 사용자를 동시에 변경했을 때 두 개의 로그가 생성되는지 확인.
|
||||
- **데이터 무결성**: 수정을 취소하거나 저장 실패 시 로그가 남지 않는지(Transaction) 확인.
|
||||
48
docs/plans/design_rule.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
|
||||
|
||||
본 문서는 ITAM(IT Asset Management System)의 시각적 일관성과 사용자 경험을 유지하기 위한 핵심 디자인 원칙을 정의합니다.
|
||||
|
||||
---
|
||||
|
||||
### 1. 디자인 철학 (Design Philosophy)
|
||||
* **Minimalist & Stark**: Vercel 스타일의 극도로 간결하고 현대적인 디자인을 지향합니다.
|
||||
* **Achromatic Base**: 블랙(#171717)과 화이트를 기본으로 하며, 정보의 구분은 얇은 헤어라인(#ebebeb)을 사용합니다.
|
||||
* **Fluid & Responsive**: 고정된 픽셀 대신 화면 크기에 비례하여 UI 밀도가 변하는 유동적 스케일링 시스템을 적용합니다.
|
||||
|
||||
### 2. 타이포그래피 및 자간 (Typography & Letter-spacing)
|
||||
* **Font Family**: `Pretendard` 단일 폰트를 사용합니다.
|
||||
* **Letter-spacing**: 모든 텍스트에 `-0.02em` (-2%) 자간을 적용하여 밀도 있는 가독성을 확보합니다.
|
||||
* **Typography Scale**:
|
||||
* **XS**: `clamp(10px, 1.2vmin + 0.2vw, 15px)` - 보조 텍스트
|
||||
* **SM**: `clamp(12px, 1.4vmin + 0.3vw, 18px)` - 필터, 일반 라벨, 테이블 헤더
|
||||
* **Base**: `clamp(14px, 1.6vmin + 0.4vw, 22px)` - 본문, 테이블 데이터
|
||||
* **MD**: `clamp(18px, 2.5vmin + 0.5vw, 30px)` - 섹션 소제목
|
||||
* **LG**: `clamp(24px, 4vmin + 0.6vw, 48px)` - 페이지 대제목
|
||||
* **XL**: `clamp(32px, 6vmin + 0.8vw, 72px)` - 핵심 통계 지표
|
||||
* **Layout Units**:
|
||||
* **Header Height**: `clamp(50px, 8vmin, 90px)`
|
||||
* **Base Spacing**: `clamp(0.75rem, 3vmin, 3rem)`
|
||||
* **Radius**: `clamp(6px, 1.5vmin, 16px)`
|
||||
|
||||
### 3. 컬러 팔레트 (Vercel Stark Palette)
|
||||
* **Primary**: `#171717` (Stark Black) - 텍스트, 주요 버튼, 강조 요소.
|
||||
* **Secondary**: `#888888` (Mute) - 보조 텍스트, 비활성 아이콘.
|
||||
* **Border**: `#ebebeb` (Hairline) - 정보 구분선.
|
||||
* **Background**: `#ffffff` (Canvas), `#fafafa` (Soft), `#f5f5f5` (Soft 2).
|
||||
* **Accents**: Blue(`#0070f3`), Orange(`#f5a623`), Danger(`#ee0000`).
|
||||
|
||||
### 4. 컴포넌트 및 레이아웃 규칙 (Component Rules)
|
||||
* **Header & Navigation**:
|
||||
* 상단 1열 통합 바 형태를 유지하며, GNB와 LNB를 동일 라인에 배치하여 공간을 효율적으로 사용합니다.
|
||||
* **Unified Filter Bar**:
|
||||
* 검색창과 필터는 상단 타이틀 바로 아래(기존 액션 버튼 라인)까지 올려서 배치합니다.
|
||||
* **Action Group**: '자산 추가', '부품 마스터' 등의 주요 액션 버튼은 검색창과 같은 라인의 최우측에 정렬합니다.
|
||||
* **Dashboard**:
|
||||
* **Single-Screen View**: 1920*1080(또는 1920*919) 해상도에서 스크롤 없이 한 화면에 핵심 정보가 모두 보이도록 최적화합니다.
|
||||
* **Fixed Charts**: 차트 내부 숫자나 요소에 애니메이션(`animation: false`) 및 플로팅 레이블을 배제하여 정적인 안정성을 확보합니다.
|
||||
* **Footer**:
|
||||
* 화면 최하단에 위치하며, 텍스트는 **우측 정렬(Right-aligned)**합니다.
|
||||
* 상단에 1px 헤어라인 구분선을 가집니다.
|
||||
* **Security & UX**:
|
||||
* **Text Selection**: 사용자의 실수에 의한 UI 드래그 방지를 위해 입력창(`input`, `textarea`)을 제외한 전체 영역의 텍스트 선택을 차단합니다.
|
||||
* **View Toggle**: '서버' 탭 등 특정 탭에서만 '목록보기' 체크박스를 통해 뷰를 전환하며, 그 외 화면은 리스트 중심의 UI를 제공합니다.
|
||||
BIN
image 92.png
|
Before Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 4.4 MiB |
22
index.html
@@ -5,26 +5,21 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ITAM 자산관리 ERP</title>
|
||||
<title>한맥가족 자산관리시스템</title>
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
|
||||
<link rel="stylesheet" href="/src/styles/common.css" />
|
||||
<link rel="stylesheet" href="/src/styles/guide.css" />
|
||||
<link rel="stylesheet" href="/src/styles/modal.css" />
|
||||
<link rel="stylesheet" href="/src/styles/dashboard.css" />
|
||||
<link rel="stylesheet" href="/src/styles/table.css" />
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="app-layout">
|
||||
<div class="app-layout" id="app-layout" style="display: none;">
|
||||
<!-- Single-Line Integrated Header -->
|
||||
<header class="main-header">
|
||||
<div class="header-container" id="nav-container">
|
||||
<div class="brand">
|
||||
<img src="/image 92.png" alt="Logo" class="main-logo" />
|
||||
<h1>자산관리시스템<span class="sub-title">(Digital Asset Control Hub System)</span></h1>
|
||||
<h1>한맥자산관리시스템</h1>
|
||||
</div>
|
||||
|
||||
<!-- Navigation (GNB + LNB in same row) -->
|
||||
@@ -33,6 +28,14 @@
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<div class="role-switcher" id="role-switcher">
|
||||
<span class="role-label user active">실무자</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="role-toggle-checkbox">
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
<span class="role-label admin">관리자</span>
|
||||
</div>
|
||||
<button id="btn-admin-page" class="hidden"></button> <!-- JS 호환용 숨김 -->
|
||||
<button id="btn-open-guide-header" class="btn btn-outline" title="프로세스 가이드">
|
||||
<i data-lucide="book-open"></i> 가이드
|
||||
@@ -48,8 +51,7 @@
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="main-footer">
|
||||
<div id="secret-cloud-trigger" style="width: 20px; height: 20px; cursor: pointer; opacity: 0.1; background: #000; border-radius: 4px; position: absolute; left: 1rem;"></div>
|
||||
<p>Powered by BARON Consultant Co,Ltd</p>
|
||||
<p>© 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
792
map_config.json
@@ -1,10 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ITAM Map Coordinate Editor v3.0</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
|
||||
</head>
|
||||
<body>
|
||||
<body class="editor-body">
|
||||
|
||||
<!-- Left: File Selector -->
|
||||
<div class="file-sidebar" id="file-sidebar">
|
||||
@@ -20,17 +22,17 @@
|
||||
|
||||
<!-- Right: Control Panel -->
|
||||
<div class="sidebar">
|
||||
<h2>Map Editor <small style="font-size: 0.6em; color: #888;">v3.0</small></h2>
|
||||
<h2>Map Editor <small class="editor-version">v3.0</small></h2>
|
||||
<div class="current-path" id="current-path">파일을 선택하세요</div>
|
||||
<p>
|
||||
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
|
||||
</p>
|
||||
|
||||
|
||||
<div class="box-list" id="box-list"></div>
|
||||
|
||||
<div class="actions">
|
||||
<button class="btn btn-outline" style="height:38px;" onclick="clearAll()">전체 삭제</button>
|
||||
<button id="btn-save-server" class="btn btn-primary" style="height:38px;" onclick="saveToServer()">서버에 즉시 저장</button>
|
||||
<button id="btn-clear-all" class="btn btn-outline">전체 삭제</button>
|
||||
<button id="btn-save-server" class="btn btn-primary">서버에 즉시 저장</button>
|
||||
<div id="save-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
429
public/PC_사양_적정성_분석_기획서.html
Normal file
@@ -0,0 +1,429 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>PC 사양 적정성 분석 기획서 (GPU 반영)</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700;900&family=Outfit:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #4F46E5;
|
||||
--primary-light: #EEF2FF;
|
||||
--secondary: #10B981;
|
||||
--secondary-light: #D1FAE5;
|
||||
--danger: #EF4444;
|
||||
--danger-light: #FEE2E2;
|
||||
--warning: #F59E0B;
|
||||
--warning-light: #FEF3C7;
|
||||
--purple: #7C3AED;
|
||||
--purple-light: #EDE9FE;
|
||||
--text-dark: #0F172A;
|
||||
--text-body: #334155;
|
||||
--text-muted: #64748B;
|
||||
--border: #E2E8F0;
|
||||
--bg-light: #F8FAFC;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Outfit', 'Noto Sans KR', sans-serif;
|
||||
color: var(--text-body);
|
||||
background: #fff;
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1.7;
|
||||
}
|
||||
.page { max-width: 980px; margin: 0 auto; padding: 3rem 2rem; }
|
||||
|
||||
/* ─ Header ─ */
|
||||
.doc-header { border-bottom: 3px solid var(--text-dark); padding-bottom: 1.75rem; margin-bottom: 3rem; }
|
||||
.doc-label {
|
||||
display: inline-block; font-size: 0.75rem; font-weight: 700; color: var(--primary);
|
||||
background: var(--primary-light); padding: 0.25rem 0.75rem; border-radius: 99px;
|
||||
text-transform: uppercase; letter-spacing: 0.08em; margin-bottom: 0.75rem;
|
||||
}
|
||||
.version-badge {
|
||||
display: inline-block; font-size: 0.7rem; font-weight: 700; color: var(--secondary);
|
||||
background: var(--secondary-light); padding: 0.2rem 0.6rem; border-radius: 99px;
|
||||
margin-left: 0.5rem; vertical-align: middle;
|
||||
}
|
||||
.doc-header h1 { font-size: 2rem; font-weight: 900; color: var(--text-dark); line-height: 1.25; margin-bottom: 1rem; }
|
||||
.meta-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 0.75rem; margin-top: 1rem; }
|
||||
.meta-item { background: var(--bg-light); border-radius: 8px; padding: 0.65rem 1rem; font-size: 0.83rem; }
|
||||
.meta-item .label { color: var(--text-muted); display: block; font-size: 0.75rem; }
|
||||
.meta-item .val { font-weight: 700; color: var(--text-dark); font-size: 0.9rem; }
|
||||
|
||||
/* ─ Sections ─ */
|
||||
section { margin-bottom: 3.5rem; }
|
||||
h2 {
|
||||
font-size: 1.3rem; font-weight: 800; color: var(--text-dark);
|
||||
padding-bottom: 0.5rem; border-bottom: 2px solid var(--border);
|
||||
margin-bottom: 1.5rem; display: flex; align-items: center; gap: 0.6rem;
|
||||
}
|
||||
h2 .num {
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
width: 28px; height: 28px; background: var(--primary); color: #fff;
|
||||
border-radius: 50%; font-size: 0.75rem; font-weight: 800; flex-shrink: 0;
|
||||
}
|
||||
h3 { font-size: 1.05rem; font-weight: 700; color: var(--text-dark); margin: 1.75rem 0 0.75rem; }
|
||||
p { margin-bottom: 1rem; color: var(--text-body); font-size: 0.97rem; }
|
||||
|
||||
/* ─ Boxes ─ */
|
||||
.box { border-radius: 10px; padding: 1.25rem 1.5rem; margin: 1.25rem 0; font-size: 0.93rem; }
|
||||
.box-blue { background: var(--primary-light); border-left: 4px solid var(--primary); }
|
||||
.box-green { background: var(--secondary-light); border-left: 4px solid var(--secondary); }
|
||||
.box-yellow { background: var(--warning-light); border-left: 4px solid var(--warning); }
|
||||
.box-red { background: var(--danger-light); border-left: 4px solid var(--danger); }
|
||||
.box-purple { background: var(--purple-light); border-left: 4px solid var(--purple); }
|
||||
.box-title { font-weight: 700; color: var(--text-dark); margin-bottom: 0.5rem; font-size: 0.95rem; }
|
||||
|
||||
/* ─ Score formula block ─ */
|
||||
.formula {
|
||||
background: #1E293B; color: #E2E8F0; border-radius: 8px;
|
||||
padding: 1rem 1.25rem; font-family: 'Courier New', monospace;
|
||||
font-size: 0.87rem; margin: 1rem 0; overflow-x: auto; line-height: 2;
|
||||
}
|
||||
.formula .comment { color: #64748B; }
|
||||
.formula .key { color: #93C5FD; }
|
||||
.formula .val { color: #6EE7B7; }
|
||||
.formula .warn { color: #FCD34D; }
|
||||
|
||||
/* ─ Three-col score grid ─ */
|
||||
.score-grid-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1.1rem; margin: 1.5rem 0; }
|
||||
@media(max-width: 700px) { .score-grid-3 { grid-template-columns: 1fr; } }
|
||||
.score-card { border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.score-card-header {
|
||||
background: var(--bg-light); padding: 0.65rem 1rem;
|
||||
font-weight: 700; font-size: 0.88rem; color: var(--text-dark);
|
||||
border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 0.5rem;
|
||||
}
|
||||
.dot { width: 10px; height: 10px; border-radius: 50%; background: var(--primary); }
|
||||
.dot-green { background: var(--secondary); }
|
||||
.dot-purple { background: var(--purple); }
|
||||
|
||||
/* ─ Tables ─ */
|
||||
.tbl-wrap { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; margin: 1.25rem 0; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
|
||||
th { background: var(--bg-light); padding: 0.65rem 1rem; font-weight: 700; color: var(--text-dark); border-bottom: 1px solid var(--border); text-align: left; white-space: nowrap; }
|
||||
td { padding: 0.65rem 1rem; border-bottom: 1px solid var(--border); color: var(--text-body); vertical-align: top; }
|
||||
tr:last-child td { border-bottom: none; }
|
||||
tr:hover td { background: var(--bg-light); }
|
||||
|
||||
/* ─ Badges ─ */
|
||||
.badge { display: inline-block; padding: 0.2rem 0.55rem; border-radius: 4px; font-size: 0.75rem; font-weight: 700; white-space: nowrap; }
|
||||
.b-primary { color: var(--primary); background: var(--primary-light); }
|
||||
.b-green { color: #065F46; background: var(--secondary-light); }
|
||||
.b-red { color: #991B1B; background: var(--danger-light); }
|
||||
.b-yellow { color: #92400E; background: var(--warning-light); }
|
||||
.b-purple { color: #5B21B6; background: var(--purple-light); }
|
||||
|
||||
/* ─ Flow ─ */
|
||||
.flow { display: flex; align-items: center; flex-wrap: wrap; gap: 0; margin: 1.5rem 0; }
|
||||
.flow-step { background: var(--primary-light); color: var(--primary); font-weight: 700; font-size: 0.83rem; padding: 0.55rem 0.9rem; border-radius: 8px; text-align: center; }
|
||||
.flow-step.gpu { background: var(--purple-light); color: var(--purple); }
|
||||
.flow-arrow { font-size: 1.1rem; color: var(--text-muted); padding: 0 0.4rem; }
|
||||
|
||||
/* ─ GPU tier table highlight ─ */
|
||||
.tier-S td:first-child { font-weight: 800; color: #DC2626; }
|
||||
.tier-A td:first-child { font-weight: 700; color: var(--primary); }
|
||||
.tier-B td:first-child { font-weight: 700; color: var(--secondary); }
|
||||
.tier-C td:first-child { color: var(--warning); font-weight: 600; }
|
||||
.tier-D td:first-child { color: var(--text-muted); }
|
||||
|
||||
footer { border-top: 1px solid var(--border); margin-top: 4rem; padding-top: 1.5rem; text-align: center; font-size: 0.8rem; color: var(--text-muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
|
||||
<!-- HEADER -->
|
||||
<header class="doc-header">
|
||||
<div class="doc-label">기능 명세서 <span class="version-badge">v3.0 — 100점 감점제 반영</span></div>
|
||||
<h1>PC 사양 적정성 분석 기획서<br>
|
||||
<span style="font-size:1.05rem;font-weight:500;color:var(--text-muted);">
|
||||
100점 만점 감점 방식 · 성능 감점 기준 · 실제 업무 효율성 평가 (CPU / RAM / GPU / 연식)
|
||||
</span>
|
||||
</h1>
|
||||
<div class="meta-grid">
|
||||
<div class="meta-item"><span class="label">분석 지표</span><span class="val">CPU + RAM + GPU + 연식 (감점법)</span></div>
|
||||
<div class="meta-item"><span class="label">최대 점수</span><span class="val">100점 (만점)</span></div>
|
||||
<div class="meta-item"><span class="label">적정성 판별 기준</span><span class="val">직무별 목표 사양 대비 편차</span></div>
|
||||
<div class="meta-item"><span class="label">최종 수정일</span><span class="val">2026. 05. 31</span></div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 1. 개요 -->
|
||||
<section>
|
||||
<h2><span class="num">1</span>개요 — 100점 만점 감점형 성능 점수 체계</h2>
|
||||
<p>
|
||||
v3.0부터 PC 사양 점수는 <strong>100점 만점 기준 감점제</strong>로 산출됩니다.
|
||||
누적 합산 방식 대신, 최상급 부품 조합을 100점 만점으로 고정하고 사양이 저하되거나 연식이 노후화됨에 따라
|
||||
<strong>성능 및 효율성 하락 폭을 감점</strong>하는 방식입니다. 이는 실제 업무 환경에서 PC 노후도에 따른
|
||||
체감 생산성 저하를 훨씬 직관적이고 현실적으로 드러냅니다.
|
||||
</p>
|
||||
|
||||
<div class="flow">
|
||||
<div class="flow-step">① 기본 100점 만점</div>
|
||||
<div class="flow-arrow">→</div>
|
||||
<div class="flow-step">② CPU 등급/세대 감점</div>
|
||||
<div class="flow-arrow">→</div>
|
||||
<div class="flow-step">③ RAM 용량 감점</div>
|
||||
<div class="flow-arrow">→</div>
|
||||
<div class="flow-step gpu">④ GPU 등급 감점</div>
|
||||
<div class="flow-arrow">→</div>
|
||||
<div class="flow-step">⑤ 연식 노후 감점</div>
|
||||
<div class="flow-arrow">→</div>
|
||||
<div class="flow-step">⑥ 최종 실질 성능 점수</div>
|
||||
</div>
|
||||
|
||||
<div class="formula">
|
||||
<span class="comment">// ─── 최종 PC 사양 점수 (100점 만점, 최소 10점 보존) ───</span>
|
||||
<span class="key">totalScore</span> = max(10, 100 - (<span class="val">cpuDeduction</span> + <span class="val">genDeduction</span> + <span class="val">ramDeduction</span> + <span class="val">gpuDeduction</span> + <span class="val">ageDeduction</span>))
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 2. CPU 감점 룰 -->
|
||||
<section>
|
||||
<h2><span class="num">2</span>CPU 사양 감점 기준</h2>
|
||||
<p>CPU 감점은 <strong>등급 감점(최대 -30점)</strong>과 <strong>세대 노후 감점(최대 -15점)</strong>의 합산입니다.</p>
|
||||
|
||||
<div class="formula">
|
||||
<span class="comment">// [CPU 등급 감점]</span>
|
||||
i9 / Ryzen 9 → <span class="val">0점 감점</span>
|
||||
i7 / Ryzen 7 → <span class="val">-5점 감점</span>
|
||||
i5 / Ryzen 5 → <span class="val">-15점 감점</span>
|
||||
i3 / Ryzen 3 → <span class="val">-25점 감점</span>
|
||||
기타 → <span class="val">-30점 감점</span>
|
||||
|
||||
<span class="comment">// [CPU 세대 노후 감점]</span>
|
||||
최신 세대 (Intel 12~14세대, Ryzen 5000~7000시리즈 이상) → <span class="val">0점 감점</span>
|
||||
과도기 세대 (Intel 10~11세대, Ryzen 3000시리즈) → <span class="val">-5점 감점</span>
|
||||
구형 세대 (Intel 8~9세대, Ryzen 1000~2000시리즈) → <span class="val">-10점 감점</span>
|
||||
노후 세대 (Intel 7세대 이하, 구형 AMD) → <span class="val">-15점 감점</span>
|
||||
</div>
|
||||
|
||||
<h3>CPU 조합별 감점 예시</h3>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>모델</th><th>세대 구분</th><th>등급감점</th><th>세대감점</th><th>CPU 감점 합계</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>i9-13900K</td><td>최신 세대</td><td>0</td><td>0</td><td><strong>0점 (감점 없음)</strong></td></tr>
|
||||
<tr><td>i7-14700K</td><td>최신 세대</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
|
||||
<tr><td>i7-1360P</td><td>최신 세대 (노트북)</td><td>-5</td><td>0</td><td><strong>-5점</strong></td></tr>
|
||||
<tr><td>i5-12400</td><td>최신 세대</td><td>-15</td><td>0</td><td><strong>-15점</strong></td></tr>
|
||||
<tr><td>i7-9700</td><td>구형 세대</td><td>-5</td><td>-10</td><td><strong>-15점</strong></td></tr>
|
||||
<tr><td>i5-8500</td><td>구형 세대</td><td>-15</td><td>-10</td><td><strong>-25점</strong></td></tr>
|
||||
<tr><td>i7-7700</td><td>노후 세대</td><td>-5</td><td>-15</td><td><strong>-20점</strong></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 3. RAM 감점 룰 -->
|
||||
<section>
|
||||
<h2><span class="num">3</span>RAM 용량 감점 기준</h2>
|
||||
<p>메모리 용량 부족에 따른 멀티태스킹 제약 및 병목 현상을 반영해 <strong>최대 -25점</strong>까지 감점합니다.</p>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>RAM 용량</th><th>감점 점수</th><th>영향도</th><th>평가</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>32GB 이상</td><td><strong>0점 (감점 없음)</strong></td><td>대용량 3D 및 개발 작업 원활</td><td><span class="badge b-green">최적</span></td></tr>
|
||||
<tr><td>16GB</td><td><strong>-10점 감점</strong></td><td>일반 사무용 및 가벼운 멀티태스킹 적합</td><td><span class="badge b-primary">보통</span></td></tr>
|
||||
<tr><td>8GB</td><td><strong>-20점 감점</strong></td><td>브라우저 탭 다수 실행 시 물리 메모리 부족</td><td><span class="badge b-yellow">주의</span></td></tr>
|
||||
<tr><td>8GB 미만</td><td><strong>-25점 감점</strong></td><td>기본 OS 구동 외 심각한 메모리 병목</td><td><span class="badge b-red">부족</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 4. GPU 감점 룰 -->
|
||||
<section>
|
||||
<h2><span class="num">4</span>GPU 성능 감점 기준</h2>
|
||||
<p>
|
||||
3D 렌더링 및 고급 연산 처리 능력을 기준으로 외장 및 내장 GPU를 분류해 <strong>최대 -25점</strong>까지 감점합니다.
|
||||
GPU 정보가 감지되지 않거나 없는 경우 기본적으로 내장 그래픽 수준인 -25점을 감점합니다.
|
||||
</p>
|
||||
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>등급</th><th>제품군 구분</th><th>대표 모델</th><th>감점 점수</th><th>적합 작업</th></tr></thead>
|
||||
<tbody>
|
||||
<tr class="tier-S"><td>S</td><td>최상위 외장 GPU</td><td>RTX 4070~4090, RTX A4000~A6000</td><td><strong>0점 (감점 없음)</strong></td><td>3D 그래픽, AI 연산, VR</td></tr>
|
||||
<tr class="tier-A"><td>A</td><td>메인스트림 외장 GPU</td><td>RTX 3060~3070, RTX 2060, RTX A2000</td><td><strong>-5점 감점</strong></td><td>중급 개발, CAD 설계</td></tr>
|
||||
<tr class="tier-B"><td>B</td><td>엔트리 외장 GPU</td><td>GTX 1660, GTX 1060, RX 6600</td><td><strong>-15점 감점</strong></td><td>기본 CAD, 그래픽 보조</td></tr>
|
||||
<tr class="tier-C"><td>C</td><td>내장 그래픽 및 기타</td><td>Intel Iris Xe, UHD Graphics, Vega, GPU 없음</td><td><strong>-25점 감점</strong></td><td>오피스 사무, 문서 작업</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 5. 종합 점수 감점 사례 -->
|
||||
<section>
|
||||
<h2><span class="num">5</span>감점법 종합 점수 계산 실사례</h2>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>모델명</th><th>CPU 사양 (감점)</th><th>RAM 사양 (감점)</th><th>GPU 사양 (감점)</th><th>연식 (감점)</th><th>감점 총합</th><th>최종 점수</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>HP ZBook Fury 16</td><td>Ryzen 9 7900X (0)</td><td>64GB (0)</td><td>NVIDIA RTX A2000 (-5)</td><td>2년차 (-6)</td><td>-11</td><td><strong>89점</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Dell Precision 5680</td><td>i9-13900K (0)</td><td>64GB (0)</td><td>NVIDIA RTX 4070 (0)</td><td>2년차 (-6)</td><td>-6</td><td><strong>94점</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>LG Gram 17 Pro</td><td>i7-14700K (-5)</td><td>32GB (0)</td><td>NVIDIA RTX 4060 (-5)</td><td>1년차 (-3)</td><td>-13</td><td><strong>87점</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>LG Gram 16</td><td>i7-1360P (-5)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-49</td><td><strong>51점</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Samsung Galaxy Book 3</td><td>i5-1340P (-15)</td><td>16GB (-10)</td><td>Intel Iris Xe (-25)</td><td>3년차 (-9)</td><td>-59</td><td><strong>41점</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>HP EliteBook 840</td><td>Ryzen 5 5600X (-15)</td><td>16GB (-10)</td><td>AMD Radeon Vega (-25)</td><td>4년차 (-12)</td><td>-62</td><td><strong>38점</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>HP ProDesk 400 G5</td><td>i3-8100 (-35)</td><td>8GB (-20)</td><td>Intel UHD 630 (-25)</td><td>5년 이상 (-15)</td><td>-95</td><td><strong>10점(보존)</strong></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 6. 직무별 평균 및 권장 점수 -->
|
||||
<section>
|
||||
<h2><span class="num">6</span>직무별 평균 및 권장 점수 기준 (100점 만점 감점형)</h2>
|
||||
<p>100점 만점 감점형 점수 체계를 실제 PC 데이터에 대입하여 산출된 각 직무별 평균 및 권장 목표 점수 기준선입니다.</p>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>정렬</th><th>직무</th><th>실제 데이터 평균 (감점 반영)</th><th>기본 권장 점수 (목표)</th><th>규칙</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr><td>1</td><td><strong>AI 개발자</strong></td><td>88.0점</td><td>95점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||
<tr><td>2</td><td><strong>편집 디자이너</strong></td><td>80.2점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||
<tr><td>3</td><td><strong>3D 디자이너</strong></td><td>78.4점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||
<tr><td>4</td><td><strong>UXUI 디자이너</strong></td><td>72.7점</td><td>70점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||
<tr><td>5</td><td><strong>3D 개발자</strong></td><td>67.8점</td><td>90점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||
<tr><td>6</td><td><strong>프로그램 개발자</strong></td><td>67.3점</td><td>80점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||
<tr><td>7</td><td><strong>BIM모델러</strong></td><td>62.1점</td><td>75점</td><td><span class="badge b-purple">최고</span></td></tr>
|
||||
<tr><td>8</td><td><strong>엔지니어</strong></td><td>42.9점</td><td>60점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||
<tr><td>9</td><td><strong>웹 개발자</strong></td><td>39.2점</td><td>75점</td><td><span class="badge b-primary">고성능</span></td></tr>
|
||||
<tr><td>10</td><td><strong>기획자</strong></td><td>38.6점</td><td>50점</td><td><span class="badge b-green">중간</span></td></tr>
|
||||
<tr><td>11</td><td><strong>감리원</strong></td><td>-</td><td>40점</td><td><span class="badge b-yellow">기본</span></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="box box-blue">
|
||||
<div class="box-title">📌 대소 관계 조건 충족 확인</div>
|
||||
AI 개발자(88.0) > 편집 디자이너(80.2) > 3D 디자이너(78.4) > UXUI 디자이너(72.7) > 3D 개발자(67.8) > 프로그램 개발자(67.3) > BIM모델러(62.1) > 엔지니어(42.9) > 웹 개발자(39.2) > 기획자(38.6) ✅
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 7. 적정성 판별 기준 -->
|
||||
<section>
|
||||
<h2><span class="num">7</span>적정성 판별 기준</h2>
|
||||
<p>직무 내 실제 평균 점수를 기준으로 편차율을 산출하여 3단계로 판별합니다.</p>
|
||||
<div class="formula">
|
||||
<span class="key">avgScore</span> = <span class="val">해당 직무 소속 PC 점수들의 산술 평균</span>
|
||||
|
||||
IF <span class="val">개인 실질 점수 < avgScore × 0.80</span> → <span class="key">"사양 부족"</span> (직무 평균 20% 이상 미달)
|
||||
IF <span class="val">개인 실질 점수 > avgScore × 1.30</span> → <span class="key">"오버스펙"</span> (직무 평균 30% 이상 초과)
|
||||
ELSE → <span class="key">"적정"</span>
|
||||
</div>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>판별 결과</th><th>조건</th><th>권장 조치</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="badge b-red">사양 부족</span></td><td>실질 점수 < 직무 평균 × 0.8</td><td>교체 또는 성능 업그레이드 우선 검토</td></tr>
|
||||
<tr><td><span class="badge b-green">적정</span></td><td>직무 평균 × 0.8 ≤ 실질 점수 ≤ 직무 평균 × 1.3</td><td>현행 업무 효율 유지</td></tr>
|
||||
<tr><td><span class="badge b-yellow">오버스펙</span></td><td>실질 점수 > 직무 평균 × 1.3</td><td>과스펙 장비 회수 또는 필요 부서 재배치</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 8. 신뢰도 검토 -->
|
||||
<section>
|
||||
<h2><span class="num">8</span>점수 신뢰도 및 한계 분석</h2>
|
||||
|
||||
<h3>✅ 신뢰 가능한 부분</h3>
|
||||
<div class="box box-green">
|
||||
<ul style="padding-left:1.25rem;margin:0;line-height:2.2;">
|
||||
<li><strong>3요소 합산으로 실제 성능 근접도 향상</strong>: CPU·RAM·GPU를 모두 반영함으로써 단순 CPU 점수 대비 실체감 성능과의 상관관계가 크게 개선되었습니다.</li>
|
||||
<li><strong>GPU 티어 방향성 일치</strong>: RTX 4090 > 4080 > 4070 … 순의 점수 순서는 실제 벤치마크(3DMark, PassMark GPU)와 일치합니다.</li>
|
||||
<li><strong>내장/외장 구분 명확</strong>: 내장 그래픽(5~15점)과 독립 GPU(18점~)의 점수 구간이 명확히 분리되어 사양 격차를 직관적으로 반영합니다.</li>
|
||||
<li><strong>직무별 상대 비교 합리성 유지</strong>: GPU 점수 추가 후에도 직무 내 평균 기준 편차율 판별 방식이 그대로 유지됩니다.</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<h3>⚠️ 여전히 남아있는 한계점</h3>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>한계 항목</th><th>내용</th><th>영향도</th></tr></thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>노트북 TDP 미반영</strong></td>
|
||||
<td>i7-1360P (노트북 28W)와 i7-13700K (데스크탑 125W)는 같은 세대지만 실제 성능 차이가 큽니다. 현재는 동일 점수가 부여됩니다.</td>
|
||||
<td><span class="badge b-yellow">중간</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>SSD 유형 미반영</strong></td>
|
||||
<td>NVMe SSD와 HDD의 체감 속도 차이는 크지만 점수에 포함되지 않습니다.</td>
|
||||
<td><span class="badge b-yellow">중간</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>GPU 세부 파생 모델 한계</strong></td>
|
||||
<td>RTX 4060 Laptop과 RTX 4060 Desktop은 성능 차이가 있으나 동일 점수(50점)를 받습니다.</td>
|
||||
<td><span class="badge b-yellow">중간</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>GPU 세대 보정 미적용</strong></td>
|
||||
<td>CPU와 달리 GPU는 세대 보정 없이 모델명 매핑 방식만 사용됩니다. 향후 세대별 보정을 검토할 수 있습니다.</td>
|
||||
<td><span class="badge b-primary">낮음</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>실측 벤치마크 미연동</strong></td>
|
||||
<td>3DMark / PassMark GPU 실측값이 아닌 모델명 파싱 추정치입니다.</td>
|
||||
<td><span class="badge b-yellow">중간</span></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="box box-blue">
|
||||
<div class="box-title">💡 종합 신뢰도 평가</div>
|
||||
GPU 점수 반영 후 <strong>특히 디자이너·개발자와 같은 그래픽 집약적 직무의 적정성 판별 정확도가 대폭 향상</strong>되었습니다.
|
||||
다만 노트북 TDP, SSD 유형 등 추가 변수를 향후 보완하면 신뢰도를 더 끌어올릴 수 있습니다.
|
||||
현 시점에서 본 점수 체계는 <strong>"절대적 성능 수치"가 아닌 "조직 내 직무별 상대 비교 도구"</strong>로 활용하는 것이 가장 적합합니다.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 9. 개선 로드맵 -->
|
||||
<section>
|
||||
<h2><span class="num">9</span>향후 개선 로드맵</h2>
|
||||
<div class="tbl-wrap">
|
||||
<table>
|
||||
<thead><tr><th>우선순위</th><th>항목</th><th>기대 효과</th><th>난이도</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><span class="badge b-green">완료</span></td><td>GPU 점수 반영 (v2.0)</td><td>그래픽 직무 신뢰도 대폭 향상</td><td>중</td></tr>
|
||||
<tr><td><span class="badge b-yellow">권장</span></td><td>SSD 유형별 점수 추가 (NVMe/SATA/HDD)</td><td>실체감 체감 속도 반영</td><td>하</td></tr>
|
||||
<tr><td><span class="badge b-yellow">권장</span></td><td>노트북/데스크탑 TDP 보정</td><td>모바일 CPU 과대평가 방지</td><td>중</td></tr>
|
||||
<tr><td><span class="badge b-primary">선택</span></td><td>PassMark / 3DMark 실측 DB 내장 연동</td><td>추정치 → 실측값 전환</td><td>상</td></tr>
|
||||
<tr><td><span class="badge b-primary">선택</span></td><td>직무별 항목 가중치 커스터마이징</td><td>조직 특성 맞춤 정밀 점수화</td><td>중</td></tr>
|
||||
<tr><td><span class="badge b-primary">선택</span></td><td>RMM 에이전트 실시간 자원 점유율 연동</td><td>실사용 기반 교체 우선순위 추천</td><td>상</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<p>HM ITAM — PC 사양 적정성 분석 기획서 v2.0 (GPU 반영) · 2026. 05. 28</p>
|
||||
<p style="margin-top:0.25rem;">내부 검토용 문서입니다. 무단 외부 배포를 금합니다.</p>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
public/img/image_92.png
Normal file
|
After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 10 MiB After Width: | Height: | Size: 10 MiB |
|
Before Width: | Height: | Size: 6.3 MiB After Width: | Height: | Size: 6.3 MiB |
BIN
public/img/location_photo/IDC/서관202.png
Normal file
|
After Width: | Height: | Size: 7.9 MiB |
|
Before Width: | Height: | Size: 4.7 MiB After Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 2.9 MiB After Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 3.9 MiB After Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 11 MiB After Width: | Height: | Size: 11 MiB |
|
Before Width: | Height: | Size: 6.1 MiB After Width: | Height: | Size: 6.1 MiB |
BIN
public/img/location_photo/기술개발센터/센터내부/센터내부.png
Normal file
|
After Width: | Height: | Size: 388 KiB |
@@ -0,0 +1,354 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Center Chair Map (View Only)</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #152330;
|
||||
--muted: #627286;
|
||||
--paper: rgba(255,255,255,0.86);
|
||||
--line: rgba(21,35,48,0.1);
|
||||
--accent: #0f766e;
|
||||
--bg: #edf2f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||
overflow: hidden;
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.viewer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.viewer-head {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
z-index: 2;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chip {
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(255,255,255,0.94);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||
display: inline-block;
|
||||
}
|
||||
.viewer-actions {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||
}
|
||||
canvas {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
canvas.dragging { cursor: grabbing; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="shell">
|
||||
<main class="panel viewer">
|
||||
<div class="viewer-head">
|
||||
<div class="chip" id="scale-chip"></div>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||
</div>
|
||||
<canvas id="canvas"></canvas>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./center_chair_people_payload.js?v=20260403a"></script>
|
||||
<script>
|
||||
const DATA = window.CHAIR_MAP_DATA;
|
||||
function decodeSegments(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Int32Array(bytes.buffer);
|
||||
}
|
||||
const bgTileRanges = DATA.bgTileRanges;
|
||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
||||
key, name, kind, start, count
|
||||
}));
|
||||
const meta = DATA.meta;
|
||||
const world = meta.headerBounds;
|
||||
const canvas = document.getElementById("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const scaleChip = document.getElementById("scale-chip");
|
||||
|
||||
// --- Added for Point Picking & Marker ---
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
let markerX = params.get('markerX') ? parseFloat(params.get('markerX')) : null;
|
||||
let markerY = params.get('markerY') ? parseFloat(params.get('markerY')) : null;
|
||||
|
||||
const chairGeometry = chairs.map((chair) => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
const path = new Path2D();
|
||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = chairSegValues[offset] / 10;
|
||||
const y1 = chairSegValues[offset + 1] / 10;
|
||||
const x2 = chairSegValues[offset + 2] / 10;
|
||||
const y2 = chairSegValues[offset + 3] / 10;
|
||||
path.moveTo(x1, y1);
|
||||
path.lineTo(x2, y2);
|
||||
minX = Math.min(minX, x1, x2);
|
||||
minY = Math.min(minY, y1, y2);
|
||||
maxX = Math.max(maxX, x1, x2);
|
||||
maxY = Math.max(maxY, y1, y2);
|
||||
}
|
||||
return { ...chair, minX, minY, maxX, maxY, path };
|
||||
});
|
||||
|
||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||
let pixelRatio = window.devicePixelRatio || 1;
|
||||
let dragging = false;
|
||||
let dragStart = null;
|
||||
let rafPending = false;
|
||||
|
||||
function resize() {
|
||||
pixelRatio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width * pixelRatio);
|
||||
canvas.height = Math.round(rect.height * pixelRatio);
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
fit();
|
||||
}
|
||||
|
||||
function fit() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = world.maxX - world.minX;
|
||||
const height = world.maxY - world.minY;
|
||||
const pad = 36;
|
||||
const scaleX = (rect.width - pad * 2) / width;
|
||||
const scaleY = (rect.height - pad * 2) / height;
|
||||
camera.scale = Math.min(scaleX, scaleY);
|
||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
||||
requestDraw();
|
||||
}
|
||||
|
||||
function drawGrid(width, height) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 120; x < width; x += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 120; y < height; y += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function worldToScreen(x, y) {
|
||||
return {
|
||||
x: x * camera.scale + camera.offsetX,
|
||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function screenToWorld(x, y) {
|
||||
return {
|
||||
x: (x - camera.offsetX) / camera.scale,
|
||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
||||
};
|
||||
}
|
||||
|
||||
function requestDraw() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
rafPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function applyWorldTransform() {
|
||||
ctx.setTransform(
|
||||
pixelRatio * camera.scale,
|
||||
0,
|
||||
0,
|
||||
-pixelRatio * camera.scale,
|
||||
pixelRatio * camera.offsetX,
|
||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
||||
);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
drawGrid(rect.width, rect.height);
|
||||
|
||||
const viewA = screenToWorld(0, rect.height);
|
||||
const viewB = screenToWorld(rect.width, 0);
|
||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
||||
ctx.lineWidth = 1 / camera.scale;
|
||||
const tileSize = meta.backgroundTileSize;
|
||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
||||
|
||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
||||
const range = bgTileRanges[`${tx},${ty}`];
|
||||
if (!range) continue;
|
||||
const start = range[0];
|
||||
const count = range[1];
|
||||
for (let i = start; i < start + count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = bgSegValues[offset] / 10;
|
||||
const y1 = bgSegValues[offset + 1] / 10;
|
||||
const x2 = bgSegValues[offset + 2] / 10;
|
||||
const y2 = bgSegValues[offset + 3] / 10;
|
||||
if (Math.max(x1, x2) < viewMinX || Math.min(x1, x2) > viewMaxX ||
|
||||
Math.max(y1, y2) < viewMinY || Math.min(y1, y2) > viewMaxY) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ctx.lineWidth = 1.35 / camera.scale;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.strokeStyle = "rgba(21, 149, 142, 0.8)";
|
||||
for (const chair of chairGeometry) {
|
||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
||||
ctx.stroke(chair.path);
|
||||
}
|
||||
|
||||
// --- Draw Marker ---
|
||||
if (markerX !== null && markerY !== null) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(markerX, markerY, 50 / camera.scale, 0, Math.PI * 2);
|
||||
ctx.fillStyle = "rgba(220, 38, 38, 0.8)";
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = "#fff";
|
||||
ctx.lineWidth = 10 / camera.scale;
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
dragging = true;
|
||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
||||
canvas.classList.add("dragging");
|
||||
});
|
||||
window.addEventListener("pointerup", (event) => {
|
||||
if (dragging && dragStart) {
|
||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||
if (move < 4) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = event.clientX - rect.left;
|
||||
const my = event.clientY - rect.top;
|
||||
const worldPos = screenToWorld(mx, my);
|
||||
markerX = worldPos.x;
|
||||
markerY = worldPos.y;
|
||||
requestDraw();
|
||||
|
||||
// Notify parent window
|
||||
window.parent.postMessage({
|
||||
type: 'PICK_LOCATION',
|
||||
x: markerX.toFixed(2),
|
||||
y: markerY.toFixed(2)
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
dragging = false;
|
||||
dragStart = null;
|
||||
canvas.classList.remove("dragging");
|
||||
});
|
||||
window.addEventListener("pointermove", (event) => {
|
||||
if (dragging && dragStart) {
|
||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
||||
requestDraw();
|
||||
}
|
||||
});
|
||||
canvas.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = event.clientX - rect.left;
|
||||
const my = event.clientY - rect.top;
|
||||
const before = screenToWorld(mx, my);
|
||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
||||
const after = worldToScreen(before.x, before.y);
|
||||
camera.offsetX += mx - after.x;
|
||||
camera.offsetY += my - after.y;
|
||||
requestDraw();
|
||||
}, { passive: false });
|
||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
||||
window.addEventListener("resize", resize);
|
||||
resize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,931 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>center chair people map</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #152330;
|
||||
--muted: #627286;
|
||||
--paper: rgba(255,255,255,0.86);
|
||||
--line: rgba(21,35,48,0.1);
|
||||
--accent: #0f766e;
|
||||
--bg: #edf2f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||
}
|
||||
button.alt {
|
||||
color: var(--ink);
|
||||
background: rgba(255,255,255,0.9);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.viewer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.viewer-head {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chip {
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(255,255,255,0.94);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||
}
|
||||
.viewer-actions {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.mapper {
|
||||
position: absolute;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(94vw, 1320px);
|
||||
max-height: min(56vh, 560px);
|
||||
overflow: hidden;
|
||||
z-index: 4;
|
||||
border-radius: 20px;
|
||||
background: rgba(234, 239, 247, 0.95);
|
||||
border: 1px solid rgba(101, 119, 146, 0.22);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.hidden-off {
|
||||
display: none !important;
|
||||
}
|
||||
.mapper-head {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(101,119,146,0.18);
|
||||
font-size: 12px;
|
||||
color: #51607a;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
}
|
||||
.mapper-head strong {
|
||||
display: block;
|
||||
color: #17243b;
|
||||
font-size: 20px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.mapper-head .alt {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.org-chart {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.org-top {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 420px);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(67, 84, 118, 0.25);
|
||||
background: #fff;
|
||||
}
|
||||
.org-top-title {
|
||||
background: #1e2f4d;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
padding: 16px 12px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.org-top-members {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
.org-teams {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.org-team {
|
||||
border: 1px solid rgba(110, 126, 152, 0.25);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.95);
|
||||
min-width: 0;
|
||||
}
|
||||
.org-team h4 {
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
font-size: 14px;
|
||||
color: #21324e;
|
||||
font-weight: 800;
|
||||
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
||||
background: rgba(240, 245, 252, 0.96);
|
||||
}
|
||||
.org-members {
|
||||
padding: 7px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.org-person {
|
||||
border: 1px solid rgba(116, 133, 161, 0.25);
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
min-width: 0;
|
||||
}
|
||||
.org-person.active {
|
||||
border-color: rgba(15,118,110,0.6);
|
||||
background: rgba(15,118,110,0.11);
|
||||
}
|
||||
.org-person.assigned {
|
||||
border-color: rgba(37,99,235,0.5);
|
||||
background: rgba(37,99,235,0.1);
|
||||
}
|
||||
.org-person strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: #15233a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.org-person small {
|
||||
display: block;
|
||||
color: #5a6a86;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.mapper {
|
||||
top: 72px;
|
||||
width: min(96vw, 920px);
|
||||
max-height: 58vh;
|
||||
}
|
||||
.viewer-actions {
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mapper-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.org-top-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.org-teams {
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
canvas.dragging { cursor: grabbing; }
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
min-width: 170px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(17,24,39,0.94);
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translate(12px, 12px);
|
||||
transition: opacity 120ms ease;
|
||||
z-index: 3;
|
||||
}
|
||||
.tooltip.visible { opacity: 1; }
|
||||
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
||||
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="shell">
|
||||
<main class="panel viewer">
|
||||
<div class="viewer-head">
|
||||
<div class="chip" id="scale-chip"></div>
|
||||
<div class="chip" id="hover-chip">chair hover: none</div>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
||||
</div>
|
||||
<aside class="mapper hidden-off">
|
||||
<div class="mapper-head">
|
||||
<div id="mapper-status">
|
||||
<strong>조직 현황</strong>
|
||||
<span>선택 인원 없음</span>
|
||||
</div>
|
||||
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
||||
</div>
|
||||
<div class="org-chart" id="org-chart"></div>
|
||||
</aside>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./center_chair_people_payload.js?v=20260403a"></script>
|
||||
<script>
|
||||
const DATA = window.CHAIR_MAP_DATA;
|
||||
function decodeSegments(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Int32Array(bytes.buffer);
|
||||
}
|
||||
const bgTileRanges = DATA.bgTileRanges;
|
||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
||||
key, name, kind, start, count
|
||||
}));
|
||||
const meta = DATA.meta;
|
||||
const world = meta.headerBounds;
|
||||
const canvas = document.getElementById("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
const scaleChip = document.getElementById("scale-chip");
|
||||
const hoverChip = document.getElementById("hover-chip");
|
||||
const STORAGE_KEY = "ptc-chair-selection";
|
||||
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
||||
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
||||
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
||||
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
||||
const orgChartEl = document.getElementById("org-chart");
|
||||
const mapperStatus = document.getElementById("mapper-status");
|
||||
// Prevent stale auto-highlights from previous sessions.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
||||
const placed = new Set();
|
||||
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
||||
let chairAssignments = {};
|
||||
let activePersonId = null;
|
||||
const ORG_TEMPLATE = {
|
||||
top: {
|
||||
name: "총괄기획실",
|
||||
count: 53,
|
||||
members: [
|
||||
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
||||
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
||||
],
|
||||
},
|
||||
teams: [
|
||||
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
||||
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
||||
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
||||
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
||||
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
||||
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
||||
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
||||
],
|
||||
};
|
||||
const chairGeometry = chairs.map((chair) => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
const path = new Path2D();
|
||||
const hitSegments = new Float32Array(chair.count * 4);
|
||||
let segCursor = 0;
|
||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = chairSegValues[offset] / 10;
|
||||
const y1 = chairSegValues[offset + 1] / 10;
|
||||
const x2 = chairSegValues[offset + 2] / 10;
|
||||
const y2 = chairSegValues[offset + 3] / 10;
|
||||
path.moveTo(x1, y1);
|
||||
path.lineTo(x2, y2);
|
||||
hitSegments[segCursor] = x1;
|
||||
hitSegments[segCursor + 1] = y1;
|
||||
hitSegments[segCursor + 2] = x2;
|
||||
hitSegments[segCursor + 3] = y2;
|
||||
segCursor += 4;
|
||||
minX = Math.min(minX, x1, x2);
|
||||
minY = Math.min(minY, y1, y2);
|
||||
maxX = Math.max(maxX, x1, x2);
|
||||
maxY = Math.max(maxY, y1, y2);
|
||||
}
|
||||
return {
|
||||
...chair,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
||||
path,
|
||||
hitSegments,
|
||||
};
|
||||
});
|
||||
function renumberChairKeys(chairItems) {
|
||||
if (!chairItems.length) return;
|
||||
const heights = chairItems
|
||||
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
||||
.sort((a, b) => a - b);
|
||||
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
||||
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
||||
|
||||
const sorted = [...chairItems].sort((a, b) => {
|
||||
const ay = (a.minY + a.maxY) * 0.5;
|
||||
const by = (b.minY + b.maxY) * 0.5;
|
||||
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
||||
const ax = (a.minX + a.maxX) * 0.5;
|
||||
const bx = (b.minX + b.maxX) * 0.5;
|
||||
return ax - bx; // left -> right
|
||||
});
|
||||
|
||||
sorted.forEach((chair, index) => {
|
||||
chair.key = String(index + 1);
|
||||
chair.seatNo = index + 1;
|
||||
});
|
||||
}
|
||||
renumberChairKeys(chairGeometry);
|
||||
const PICK_GRID_SIZE = 1800;
|
||||
const chairPickGrid = new Map();
|
||||
function pickGridKey(gx, gy) {
|
||||
return `${gx},${gy}`;
|
||||
}
|
||||
chairGeometry.forEach((chair, index) => {
|
||||
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const key = pickGridKey(gx, gy);
|
||||
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
||||
chairPickGrid.get(key).push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||
let pixelRatio = window.devicePixelRatio || 1;
|
||||
let pointer = { x: 0, y: 0 };
|
||||
let dragging = false;
|
||||
let dragStart = null;
|
||||
let hovered = null;
|
||||
let rafPending = false;
|
||||
|
||||
function normalizePeople(raw) {
|
||||
return raw
|
||||
.map((person, index) => {
|
||||
if (!person || !person.name) return null;
|
||||
return {
|
||||
id: person.id || `person-${index + 1}`,
|
||||
name: String(person.name).trim(),
|
||||
dept: String(person.dept || "").trim(),
|
||||
title: String(person.title || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function createTemplatePeople() {
|
||||
const generated = [];
|
||||
let seq = 1;
|
||||
ORG_TEMPLATE.top.members.forEach((member) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name: member.name,
|
||||
dept: member.dept,
|
||||
title: member.title,
|
||||
});
|
||||
});
|
||||
ORG_TEMPLATE.teams.forEach((team) => {
|
||||
team.members.forEach((name) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name,
|
||||
dept: team.name,
|
||||
title: "선임",
|
||||
});
|
||||
});
|
||||
});
|
||||
return generated;
|
||||
}
|
||||
|
||||
people = normalizePeople(people);
|
||||
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
||||
if (!templateReady) {
|
||||
people = createTemplatePeople();
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
||||
chairAssignments = Object.fromEntries(
|
||||
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
||||
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
||||
))
|
||||
);
|
||||
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
||||
|
||||
function persistPeople() {
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
|
||||
function persistAssignments() {
|
||||
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
||||
}
|
||||
|
||||
function persistActivePerson() {
|
||||
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
||||
}
|
||||
|
||||
function assignmentCount() {
|
||||
return Object.keys(chairAssignments).length;
|
||||
}
|
||||
|
||||
function getPersonById(id) {
|
||||
return people.find((person) => person.id === id) || null;
|
||||
}
|
||||
|
||||
function getChairByPerson(personId) {
|
||||
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
||||
if (assignedPersonId === personId) return chairKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPeopleList() {
|
||||
const activePerson = getPersonById(activePersonId);
|
||||
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
||||
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
||||
|
||||
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
||||
const personCard = (person, roleText) => {
|
||||
if (!person) return "";
|
||||
const chairKey = getChairByPerson(person.id);
|
||||
const assignedClass = chairKey ? " assigned" : "";
|
||||
const activeClass = person.id === activePersonId ? " active" : "";
|
||||
return `
|
||||
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
||||
<strong>${person.name}</strong>
|
||||
<small>${person.title || roleText || "-"}</small>
|
||||
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
||||
</article>
|
||||
`;
|
||||
};
|
||||
|
||||
const topHtml = ORG_TEMPLATE.top.members
|
||||
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
||||
.join("");
|
||||
|
||||
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
||||
const membersHtml = team.members
|
||||
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
||||
.join("");
|
||||
return `
|
||||
<section class="org-team">
|
||||
<h4>${team.name} (${team.count})</h4>
|
||||
<div class="org-members">${membersHtml}</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
orgChartEl.innerHTML = `
|
||||
<section class="org-top">
|
||||
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
||||
<div class="org-top-members">${topHtml}</div>
|
||||
</section>
|
||||
<section class="org-teams">${teamsHtml}</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function worldToScreen(x, y) {
|
||||
return {
|
||||
x: x * camera.scale + camera.offsetX,
|
||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function screenToWorld(x, y) {
|
||||
return {
|
||||
x: (x - camera.offsetX) / camera.scale,
|
||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
||||
};
|
||||
}
|
||||
|
||||
function resize() {
|
||||
pixelRatio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width * pixelRatio);
|
||||
canvas.height = Math.round(rect.height * pixelRatio);
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
fit();
|
||||
}
|
||||
|
||||
function fit() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = world.maxX - world.minX;
|
||||
const height = world.maxY - world.minY;
|
||||
const pad = 36;
|
||||
const scaleX = (rect.width - pad * 2) / width;
|
||||
const scaleY = (rect.height - pad * 2) / height;
|
||||
camera.scale = Math.min(scaleX, scaleY);
|
||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
||||
requestDraw();
|
||||
}
|
||||
|
||||
function drawGrid(width, height) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 120; x < width; x += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 120; y < height; y += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function pickChair(screenX, screenY) {
|
||||
const threshold = 12;
|
||||
const pointerWorld = screenToWorld(screenX, screenY);
|
||||
const thresholdWorld = threshold / camera.scale;
|
||||
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
||||
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const candidateIndexes = [];
|
||||
const seen = new Set();
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
||||
if (!candidates) continue;
|
||||
for (const index of candidates) {
|
||||
if (seen.has(index)) continue;
|
||||
seen.add(index);
|
||||
candidateIndexes.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = null;
|
||||
for (const index of candidateIndexes) {
|
||||
const chair = chairGeometry[index];
|
||||
if (
|
||||
pointerWorld.x < chair.minX - thresholdWorld ||
|
||||
pointerWorld.x > chair.maxX + thresholdWorld ||
|
||||
pointerWorld.y < chair.minY - thresholdWorld ||
|
||||
pointerWorld.y > chair.maxY + thresholdWorld
|
||||
) continue;
|
||||
let distSq = Infinity;
|
||||
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
||||
const x1 = chair.hitSegments[i];
|
||||
const y1 = chair.hitSegments[i + 1];
|
||||
const x2 = chair.hitSegments[i + 2];
|
||||
const y2 = chair.hitSegments[i + 3];
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let segDistSq;
|
||||
if (len2 === 0) {
|
||||
const px = pointerWorld.x - x1;
|
||||
const py = pointerWorld.y - y1;
|
||||
segDistSq = px * px + py * py;
|
||||
} else {
|
||||
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const lx = x1 + t * dx;
|
||||
const ly = y1 + t * dy;
|
||||
const px = pointerWorld.x - lx;
|
||||
const py = pointerWorld.y - ly;
|
||||
segDistSq = px * px + py * py;
|
||||
}
|
||||
if (segDistSq < distSq) distSq = segDistSq;
|
||||
if (distSq <= thresholdWorldSq * 0.3) break;
|
||||
}
|
||||
if (distSq > thresholdWorldSq) continue;
|
||||
const dist = Math.sqrt(distSq) * camera.scale;
|
||||
|
||||
if (!best) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
const distGap = dist - best.dist;
|
||||
if (distGap < -0.75) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Math.abs(distGap) <= 2) {
|
||||
const areaGap = chair.area - best.chair.area;
|
||||
if (areaGap < -1) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(areaGap) <= 1 &&
|
||||
chair.kind === "block" &&
|
||||
best.chair.kind !== "block"
|
||||
) {
|
||||
best = { chair, dist };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ? best.chair : null;
|
||||
}
|
||||
|
||||
function renderTooltip() {
|
||||
if (!hovered) {
|
||||
tooltip.classList.remove("visible");
|
||||
hoverChip.textContent = "chair hover: none";
|
||||
return;
|
||||
}
|
||||
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
||||
tooltip.innerHTML = `
|
||||
<strong>${hovered.name}</strong>
|
||||
<div>chair key: ${hovered.key}</div>
|
||||
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
||||
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
||||
`;
|
||||
tooltip.style.left = `${pointer.x + 14}px`;
|
||||
tooltip.style.top = `${pointer.y + 14}px`;
|
||||
tooltip.classList.add("visible");
|
||||
}
|
||||
|
||||
function requestDraw() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
rafPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function applyWorldTransform() {
|
||||
ctx.setTransform(
|
||||
pixelRatio * camera.scale,
|
||||
0,
|
||||
0,
|
||||
-pixelRatio * camera.scale,
|
||||
pixelRatio * camera.offsetX,
|
||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
||||
);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
drawGrid(rect.width, rect.height);
|
||||
const viewA = screenToWorld(0, rect.height);
|
||||
const viewB = screenToWorld(rect.width, 0);
|
||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
||||
ctx.lineWidth = 1 / camera.scale;
|
||||
const tileSize = meta.backgroundTileSize;
|
||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
||||
const range = bgTileRanges[`${tx},${ty}`];
|
||||
if (!range) continue;
|
||||
const start = range[0];
|
||||
const count = range[1];
|
||||
for (let i = start; i < start + count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = bgSegValues[offset] / 10;
|
||||
const y1 = bgSegValues[offset + 1] / 10;
|
||||
const x2 = bgSegValues[offset + 2] / 10;
|
||||
const y2 = bgSegValues[offset + 3] / 10;
|
||||
if (
|
||||
Math.max(x1, x2) < viewMinX ||
|
||||
Math.min(x1, x2) > viewMaxX ||
|
||||
Math.max(y1, y2) < viewMinY ||
|
||||
Math.min(y1, y2) > viewMaxY
|
||||
) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.lineWidth = 1.45 / camera.scale;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const chair of chairGeometry) {
|
||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
||||
const active = hovered && hovered.key === chair.key;
|
||||
const selected = placed.has(chair.key);
|
||||
const assignedPersonId = chairAssignments[chair.key];
|
||||
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
||||
const assigned = Boolean(assignedPersonId);
|
||||
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
||||
ctx.strokeStyle = activePersonChair
|
||||
? "rgba(234, 179, 8, 1)"
|
||||
: assigned
|
||||
? "rgba(37, 99, 235, 0.98)"
|
||||
: selected
|
||||
? "rgba(220, 38, 38, 0.98)"
|
||||
: active
|
||||
? "rgba(15, 118, 110, 0.98)"
|
||||
: chair.kind === "group"
|
||||
? "rgba(16, 134, 149, 0.74)"
|
||||
: "rgba(21, 149, 142, 0.8)";
|
||||
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
||||
ctx.stroke(chair.path);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
||||
renderTooltip();
|
||||
}
|
||||
|
||||
function persistPlaced() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
dragging = true;
|
||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
||||
canvas.classList.add("dragging");
|
||||
});
|
||||
|
||||
window.addEventListener("pointerup", (event) => {
|
||||
if (dragging && dragStart) {
|
||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||
if (move < 4) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (picked) {
|
||||
if (placed.has(picked.key)) placed.delete(picked.key);
|
||||
else placed.add(picked.key);
|
||||
persistPlaced();
|
||||
if (activePersonId) {
|
||||
const currentChair = getChairByPerson(activePersonId);
|
||||
if (chairAssignments[picked.key] === activePersonId) {
|
||||
delete chairAssignments[picked.key];
|
||||
} else {
|
||||
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
||||
chairAssignments[picked.key] = activePersonId;
|
||||
}
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dragging = false;
|
||||
dragStart = null;
|
||||
canvas.classList.remove("dragging");
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
window.addEventListener("pointermove", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
if (dragging && dragStart) {
|
||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
||||
}
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
canvas.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = event.clientX - rect.left;
|
||||
const my = event.clientY - rect.top;
|
||||
const before = screenToWorld(mx, my);
|
||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
||||
const after = worldToScreen(before.x, before.y);
|
||||
camera.offsetX += mx - after.x;
|
||||
camera.offsetY += my - after.y;
|
||||
requestDraw();
|
||||
}, { passive: false });
|
||||
|
||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
||||
document.getElementById("clear-btn").addEventListener("click", () => {
|
||||
placed.clear();
|
||||
persistPlaced();
|
||||
requestDraw();
|
||||
});
|
||||
clearAssignBtn.addEventListener("click", () => {
|
||||
chairAssignments = {};
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
orgChartEl.addEventListener("click", (event) => {
|
||||
const item = event.target.closest(".org-person[data-person-id]");
|
||||
if (!item) return;
|
||||
const personId = item.getAttribute("data-person-id");
|
||||
activePersonId = personId === activePersonId ? null : personId;
|
||||
persistActivePerson();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
window.addEventListener("resize", resize);
|
||||
renderPeopleList();
|
||||
resize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,932 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>center chair people map 6f</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #152330;
|
||||
--muted: #627286;
|
||||
--paper: rgba(255,255,255,0.86);
|
||||
--line: rgba(21,35,48,0.1);
|
||||
--accent: #0f766e;
|
||||
--bg: #edf2f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||
}
|
||||
button.alt {
|
||||
color: var(--ink);
|
||||
background: rgba(255,255,255,0.9);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.viewer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.viewer-head {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chip {
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(255,255,255,0.94);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||
}
|
||||
.viewer-actions {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.mapper {
|
||||
position: absolute;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(94vw, 1320px);
|
||||
max-height: min(56vh, 560px);
|
||||
overflow: hidden;
|
||||
z-index: 4;
|
||||
border-radius: 20px;
|
||||
background: rgba(234, 239, 247, 0.95);
|
||||
border: 1px solid rgba(101, 119, 146, 0.22);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.hidden-off {
|
||||
display: none !important;
|
||||
}
|
||||
.mapper-head {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(101,119,146,0.18);
|
||||
font-size: 12px;
|
||||
color: #51607a;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
}
|
||||
.mapper-head strong {
|
||||
display: block;
|
||||
color: #17243b;
|
||||
font-size: 20px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.mapper-head .alt {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.org-chart {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.org-top {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 420px);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(67, 84, 118, 0.25);
|
||||
background: #fff;
|
||||
}
|
||||
.org-top-title {
|
||||
background: #1e2f4d;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
padding: 16px 12px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.org-top-members {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
.org-teams {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.org-team {
|
||||
border: 1px solid rgba(110, 126, 152, 0.25);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.95);
|
||||
min-width: 0;
|
||||
}
|
||||
.org-team h4 {
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
font-size: 14px;
|
||||
color: #21324e;
|
||||
font-weight: 800;
|
||||
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
||||
background: rgba(240, 245, 252, 0.96);
|
||||
}
|
||||
.org-members {
|
||||
padding: 7px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.org-person {
|
||||
border: 1px solid rgba(116, 133, 161, 0.25);
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
min-width: 0;
|
||||
}
|
||||
.org-person.active {
|
||||
border-color: rgba(15,118,110,0.6);
|
||||
background: rgba(15,118,110,0.11);
|
||||
}
|
||||
.org-person.assigned {
|
||||
border-color: rgba(37,99,235,0.5);
|
||||
background: rgba(37,99,235,0.1);
|
||||
}
|
||||
.org-person strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: #15233a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.org-person small {
|
||||
display: block;
|
||||
color: #5a6a86;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.mapper {
|
||||
top: 72px;
|
||||
width: min(96vw, 920px);
|
||||
max-height: 58vh;
|
||||
}
|
||||
.viewer-actions {
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mapper-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.org-top-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.org-teams {
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
canvas.dragging { cursor: grabbing; }
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
min-width: 170px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(17,24,39,0.94);
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translate(12px, 12px);
|
||||
transition: opacity 120ms ease;
|
||||
z-index: 3;
|
||||
}
|
||||
.tooltip.visible { opacity: 1; }
|
||||
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
||||
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="shell">
|
||||
<main class="panel viewer">
|
||||
<div class="viewer-head">
|
||||
<div class="chip" id="scale-chip"></div>
|
||||
<div class="chip" id="hover-chip">chair hover: none</div>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
||||
</div>
|
||||
<aside class="mapper hidden-off">
|
||||
<div class="mapper-head">
|
||||
<div id="mapper-status">
|
||||
<strong>조직 현황</strong>
|
||||
<span>선택 인원 없음</span>
|
||||
</div>
|
||||
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
||||
</div>
|
||||
<div class="org-chart" id="org-chart"></div>
|
||||
</aside>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./center_chair_people_payload_6f.js"></script>
|
||||
<script>
|
||||
const DATA = window.CHAIR_MAP_DATA;
|
||||
function decodeSegments(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Int32Array(bytes.buffer);
|
||||
}
|
||||
const bgTileRanges = DATA.bgTileRanges;
|
||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
||||
key, name, kind, start, count
|
||||
}));
|
||||
const meta = DATA.meta;
|
||||
const world = meta.headerBounds;
|
||||
const canvas = document.getElementById("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
const scaleChip = document.getElementById("scale-chip");
|
||||
const hoverChip = document.getElementById("hover-chip");
|
||||
const STORAGE_KEY = "ptc-chair-selection";
|
||||
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
||||
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
||||
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
||||
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
||||
const orgChartEl = document.getElementById("org-chart");
|
||||
const mapperStatus = document.getElementById("mapper-status");
|
||||
// Prevent stale auto-highlights from previous sessions.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
||||
const placed = new Set();
|
||||
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
||||
let chairAssignments = {};
|
||||
let activePersonId = null;
|
||||
const ORG_TEMPLATE = {
|
||||
top: {
|
||||
name: "총괄기획실",
|
||||
count: 53,
|
||||
members: [
|
||||
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
||||
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
||||
],
|
||||
},
|
||||
teams: [
|
||||
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
||||
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
||||
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
||||
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
||||
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
||||
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
||||
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
||||
],
|
||||
};
|
||||
const chairGeometry = chairs.map((chair) => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
const path = new Path2D();
|
||||
const hitSegments = new Float32Array(chair.count * 4);
|
||||
let segCursor = 0;
|
||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = chairSegValues[offset] / 10;
|
||||
const y1 = chairSegValues[offset + 1] / 10;
|
||||
const x2 = chairSegValues[offset + 2] / 10;
|
||||
const y2 = chairSegValues[offset + 3] / 10;
|
||||
path.moveTo(x1, y1);
|
||||
path.lineTo(x2, y2);
|
||||
hitSegments[segCursor] = x1;
|
||||
hitSegments[segCursor + 1] = y1;
|
||||
hitSegments[segCursor + 2] = x2;
|
||||
hitSegments[segCursor + 3] = y2;
|
||||
segCursor += 4;
|
||||
minX = Math.min(minX, x1, x2);
|
||||
minY = Math.min(minY, y1, y2);
|
||||
maxX = Math.max(maxX, x1, x2);
|
||||
maxY = Math.max(maxY, y1, y2);
|
||||
}
|
||||
return {
|
||||
...chair,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
||||
path,
|
||||
hitSegments,
|
||||
};
|
||||
});
|
||||
function renumberChairKeys(chairItems) {
|
||||
if (!chairItems.length) return;
|
||||
const heights = chairItems
|
||||
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
||||
.sort((a, b) => a - b);
|
||||
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
||||
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
||||
|
||||
const sorted = [...chairItems].sort((a, b) => {
|
||||
const ay = (a.minY + a.maxY) * 0.5;
|
||||
const by = (b.minY + b.maxY) * 0.5;
|
||||
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
||||
const ax = (a.minX + a.maxX) * 0.5;
|
||||
const bx = (b.minX + b.maxX) * 0.5;
|
||||
return ax - bx; // left -> right
|
||||
});
|
||||
|
||||
sorted.forEach((chair, index) => {
|
||||
chair.key = String(index + 1);
|
||||
chair.seatNo = index + 1;
|
||||
});
|
||||
}
|
||||
renumberChairKeys(chairGeometry);
|
||||
const PICK_GRID_SIZE = 1800;
|
||||
const chairPickGrid = new Map();
|
||||
function pickGridKey(gx, gy) {
|
||||
return `${gx},${gy}`;
|
||||
}
|
||||
chairGeometry.forEach((chair, index) => {
|
||||
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const key = pickGridKey(gx, gy);
|
||||
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
||||
chairPickGrid.get(key).push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||
let pixelRatio = window.devicePixelRatio || 1;
|
||||
let pointer = { x: 0, y: 0 };
|
||||
let dragging = false;
|
||||
let dragStart = null;
|
||||
let hovered = null;
|
||||
let rafPending = false;
|
||||
|
||||
function normalizePeople(raw) {
|
||||
return raw
|
||||
.map((person, index) => {
|
||||
if (!person || !person.name) return null;
|
||||
return {
|
||||
id: person.id || `person-${index + 1}`,
|
||||
name: String(person.name).trim(),
|
||||
dept: String(person.dept || "").trim(),
|
||||
title: String(person.title || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function createTemplatePeople() {
|
||||
const generated = [];
|
||||
let seq = 1;
|
||||
ORG_TEMPLATE.top.members.forEach((member) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name: member.name,
|
||||
dept: member.dept,
|
||||
title: member.title,
|
||||
});
|
||||
});
|
||||
ORG_TEMPLATE.teams.forEach((team) => {
|
||||
team.members.forEach((name) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name,
|
||||
dept: team.name,
|
||||
title: "선임",
|
||||
});
|
||||
});
|
||||
});
|
||||
return generated;
|
||||
}
|
||||
|
||||
people = normalizePeople(people);
|
||||
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
||||
if (!templateReady) {
|
||||
people = createTemplatePeople();
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
||||
chairAssignments = Object.fromEntries(
|
||||
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
||||
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
||||
))
|
||||
);
|
||||
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
||||
|
||||
function persistPeople() {
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
|
||||
function persistAssignments() {
|
||||
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
||||
}
|
||||
|
||||
function persistActivePerson() {
|
||||
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
||||
}
|
||||
|
||||
function assignmentCount() {
|
||||
return Object.keys(chairAssignments).length;
|
||||
}
|
||||
|
||||
function getPersonById(id) {
|
||||
return people.find((person) => person.id === id) || null;
|
||||
}
|
||||
|
||||
function getChairByPerson(personId) {
|
||||
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
||||
if (assignedPersonId === personId) return chairKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPeopleList() {
|
||||
const activePerson = getPersonById(activePersonId);
|
||||
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
||||
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
||||
|
||||
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
||||
const personCard = (person, roleText) => {
|
||||
if (!person) return "";
|
||||
const chairKey = getChairByPerson(person.id);
|
||||
const assignedClass = chairKey ? " assigned" : "";
|
||||
const activeClass = person.id === activePersonId ? " active" : "";
|
||||
return `
|
||||
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
||||
<strong>${person.name}</strong>
|
||||
<small>${person.title || roleText || "-"}</small>
|
||||
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
||||
</article>
|
||||
`;
|
||||
};
|
||||
|
||||
const topHtml = ORG_TEMPLATE.top.members
|
||||
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
||||
.join("");
|
||||
|
||||
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
||||
const membersHtml = team.members
|
||||
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
||||
.join("");
|
||||
return `
|
||||
<section class="org-team">
|
||||
<h4>${team.name} (${team.count})</h4>
|
||||
<div class="org-members">${membersHtml}</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
orgChartEl.innerHTML = `
|
||||
<section class="org-top">
|
||||
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
||||
<div class="org-top-members">${topHtml}</div>
|
||||
</section>
|
||||
<section class="org-teams">${teamsHtml}</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function worldToScreen(x, y) {
|
||||
return {
|
||||
x: x * camera.scale + camera.offsetX,
|
||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function screenToWorld(x, y) {
|
||||
return {
|
||||
x: (x - camera.offsetX) / camera.scale,
|
||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
||||
};
|
||||
}
|
||||
|
||||
function resize() {
|
||||
pixelRatio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width * pixelRatio);
|
||||
canvas.height = Math.round(rect.height * pixelRatio);
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
fit();
|
||||
}
|
||||
|
||||
function fit() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = world.maxX - world.minX;
|
||||
const height = world.maxY - world.minY;
|
||||
const pad = 36;
|
||||
const scaleX = (rect.width - pad * 2) / width;
|
||||
const scaleY = (rect.height - pad * 2) / height;
|
||||
camera.scale = Math.min(scaleX, scaleY);
|
||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
||||
requestDraw();
|
||||
}
|
||||
|
||||
function drawGrid(width, height) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 120; x < width; x += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 120; y < height; y += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function pickChair(screenX, screenY) {
|
||||
const threshold = 12;
|
||||
const pointerWorld = screenToWorld(screenX, screenY);
|
||||
const thresholdWorld = threshold / camera.scale;
|
||||
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
||||
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const candidateIndexes = [];
|
||||
const seen = new Set();
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
||||
if (!candidates) continue;
|
||||
for (const index of candidates) {
|
||||
if (seen.has(index)) continue;
|
||||
seen.add(index);
|
||||
candidateIndexes.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = null;
|
||||
for (const index of candidateIndexes) {
|
||||
const chair = chairGeometry[index];
|
||||
if (
|
||||
pointerWorld.x < chair.minX - thresholdWorld ||
|
||||
pointerWorld.x > chair.maxX + thresholdWorld ||
|
||||
pointerWorld.y < chair.minY - thresholdWorld ||
|
||||
pointerWorld.y > chair.maxY + thresholdWorld
|
||||
) continue;
|
||||
let distSq = Infinity;
|
||||
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
||||
const x1 = chair.hitSegments[i];
|
||||
const y1 = chair.hitSegments[i + 1];
|
||||
const x2 = chair.hitSegments[i + 2];
|
||||
const y2 = chair.hitSegments[i + 3];
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let segDistSq;
|
||||
if (len2 === 0) {
|
||||
const px = pointerWorld.x - x1;
|
||||
const py = pointerWorld.y - y1;
|
||||
segDistSq = px * px + py * py;
|
||||
} else {
|
||||
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const lx = x1 + t * dx;
|
||||
const ly = y1 + t * dy;
|
||||
const px = pointerWorld.x - lx;
|
||||
const py = pointerWorld.y - ly;
|
||||
segDistSq = px * px + py * py;
|
||||
}
|
||||
if (segDistSq < distSq) distSq = segDistSq;
|
||||
if (distSq <= thresholdWorldSq * 0.3) break;
|
||||
}
|
||||
if (distSq > thresholdWorldSq) continue;
|
||||
const dist = Math.sqrt(distSq) * camera.scale;
|
||||
|
||||
if (!best) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
const distGap = dist - best.dist;
|
||||
if (distGap < -0.75) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Math.abs(distGap) <= 2) {
|
||||
const areaGap = chair.area - best.chair.area;
|
||||
if (areaGap < -1) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(areaGap) <= 1 &&
|
||||
chair.kind === "block" &&
|
||||
best.chair.kind !== "block"
|
||||
) {
|
||||
best = { chair, dist };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ? best.chair : null;
|
||||
}
|
||||
|
||||
function renderTooltip() {
|
||||
if (!hovered) {
|
||||
tooltip.classList.remove("visible");
|
||||
hoverChip.textContent = "chair hover: none";
|
||||
return;
|
||||
}
|
||||
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
||||
tooltip.innerHTML = `
|
||||
<strong>${hovered.name}</strong>
|
||||
<div>chair key: ${hovered.key}</div>
|
||||
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
||||
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
||||
`;
|
||||
tooltip.style.left = `${pointer.x + 14}px`;
|
||||
tooltip.style.top = `${pointer.y + 14}px`;
|
||||
tooltip.classList.add("visible");
|
||||
}
|
||||
|
||||
function requestDraw() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
rafPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function applyWorldTransform() {
|
||||
ctx.setTransform(
|
||||
pixelRatio * camera.scale,
|
||||
0,
|
||||
0,
|
||||
-pixelRatio * camera.scale,
|
||||
pixelRatio * camera.offsetX,
|
||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
||||
);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
drawGrid(rect.width, rect.height);
|
||||
const viewA = screenToWorld(0, rect.height);
|
||||
const viewB = screenToWorld(rect.width, 0);
|
||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
||||
ctx.lineWidth = 1 / camera.scale;
|
||||
const tileSize = meta.backgroundTileSize;
|
||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
||||
const range = bgTileRanges[`${tx},${ty}`];
|
||||
if (!range) continue;
|
||||
const start = range[0];
|
||||
const count = range[1];
|
||||
for (let i = start; i < start + count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = bgSegValues[offset] / 10;
|
||||
const y1 = bgSegValues[offset + 1] / 10;
|
||||
const x2 = bgSegValues[offset + 2] / 10;
|
||||
const y2 = bgSegValues[offset + 3] / 10;
|
||||
if (
|
||||
Math.max(x1, x2) < viewMinX ||
|
||||
Math.min(x1, x2) > viewMaxX ||
|
||||
Math.max(y1, y2) < viewMinY ||
|
||||
Math.min(y1, y2) > viewMaxY
|
||||
) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.lineWidth = 1.45 / camera.scale;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const chair of chairGeometry) {
|
||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
||||
const active = hovered && hovered.key === chair.key;
|
||||
const selected = placed.has(chair.key);
|
||||
const assignedPersonId = chairAssignments[chair.key];
|
||||
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
||||
const assigned = Boolean(assignedPersonId);
|
||||
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
||||
ctx.strokeStyle = activePersonChair
|
||||
? "rgba(234, 179, 8, 1)"
|
||||
: assigned
|
||||
? "rgba(37, 99, 235, 0.98)"
|
||||
: selected
|
||||
? "rgba(220, 38, 38, 0.98)"
|
||||
: active
|
||||
? "rgba(15, 118, 110, 0.98)"
|
||||
: chair.kind === "group"
|
||||
? "rgba(16, 134, 149, 0.74)"
|
||||
: "rgba(21, 149, 142, 0.8)";
|
||||
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
||||
ctx.stroke(chair.path);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
||||
renderTooltip();
|
||||
}
|
||||
|
||||
function persistPlaced() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
dragging = true;
|
||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
||||
canvas.classList.add("dragging");
|
||||
});
|
||||
|
||||
window.addEventListener("pointerup", (event) => {
|
||||
if (dragging && dragStart) {
|
||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||
if (move < 4) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (picked) {
|
||||
if (placed.has(picked.key)) placed.delete(picked.key);
|
||||
else placed.add(picked.key);
|
||||
persistPlaced();
|
||||
if (activePersonId) {
|
||||
const currentChair = getChairByPerson(activePersonId);
|
||||
if (chairAssignments[picked.key] === activePersonId) {
|
||||
delete chairAssignments[picked.key];
|
||||
} else {
|
||||
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
||||
chairAssignments[picked.key] = activePersonId;
|
||||
}
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dragging = false;
|
||||
dragStart = null;
|
||||
canvas.classList.remove("dragging");
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
window.addEventListener("pointermove", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
if (dragging && dragStart) {
|
||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
||||
}
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
canvas.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = event.clientX - rect.left;
|
||||
const my = event.clientY - rect.top;
|
||||
const before = screenToWorld(mx, my);
|
||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
||||
const after = worldToScreen(before.x, before.y);
|
||||
camera.offsetX += mx - after.x;
|
||||
camera.offsetY += my - after.y;
|
||||
requestDraw();
|
||||
}, { passive: false });
|
||||
|
||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
||||
document.getElementById("clear-btn").addEventListener("click", () => {
|
||||
placed.clear();
|
||||
persistPlaced();
|
||||
requestDraw();
|
||||
});
|
||||
clearAssignBtn.addEventListener("click", () => {
|
||||
chairAssignments = {};
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
orgChartEl.addEventListener("click", (event) => {
|
||||
const item = event.target.closest(".org-person[data-person-id]");
|
||||
if (!item) return;
|
||||
const personId = item.getAttribute("data-person-id");
|
||||
activePersonId = personId === activePersonId ? null : personId;
|
||||
persistActivePerson();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
window.addEventListener("resize", resize);
|
||||
renderPeopleList();
|
||||
resize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,932 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>center chair people map 7f</title>
|
||||
<style>
|
||||
:root {
|
||||
--ink: #152330;
|
||||
--muted: #627286;
|
||||
--paper: rgba(255,255,255,0.86);
|
||||
--line: rgba(21,35,48,0.1);
|
||||
--accent: #0f766e;
|
||||
--bg: #edf2f6;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
|
||||
color: var(--ink);
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
|
||||
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
}
|
||||
.shell {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.panel {
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
button {
|
||||
border: none;
|
||||
border-radius: 999px;
|
||||
padding: 10px 14px;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
|
||||
}
|
||||
button.alt {
|
||||
color: var(--ink);
|
||||
background: rgba(255,255,255,0.9);
|
||||
border: 1px solid var(--line);
|
||||
box-shadow: none;
|
||||
}
|
||||
.viewer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
min-height: 100vh;
|
||||
}
|
||||
.viewer-head {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
.chip {
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255,255,255,0.82);
|
||||
border: 1px solid rgba(255,255,255,0.94);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
|
||||
}
|
||||
.viewer-actions {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 64px;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.mapper {
|
||||
position: absolute;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: min(94vw, 1320px);
|
||||
max-height: min(56vh, 560px);
|
||||
overflow: hidden;
|
||||
z-index: 4;
|
||||
border-radius: 20px;
|
||||
background: rgba(234, 239, 247, 0.95);
|
||||
border: 1px solid rgba(101, 119, 146, 0.22);
|
||||
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.hidden-off {
|
||||
display: none !important;
|
||||
}
|
||||
.mapper-head {
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid rgba(101,119,146,0.18);
|
||||
font-size: 12px;
|
||||
color: #51607a;
|
||||
font-weight: 700;
|
||||
line-height: 1.35;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
background: rgba(255,255,255,0.6);
|
||||
}
|
||||
.mapper-head strong {
|
||||
display: block;
|
||||
color: #17243b;
|
||||
font-size: 20px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.mapper-head .alt {
|
||||
padding: 8px 10px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.org-chart {
|
||||
margin: 0;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.org-top {
|
||||
margin: 0 auto;
|
||||
width: min(100%, 420px);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(67, 84, 118, 0.25);
|
||||
background: #fff;
|
||||
}
|
||||
.org-top-title {
|
||||
background: #1e2f4d;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
padding: 16px 12px;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.org-top-members {
|
||||
padding: 10px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
background: rgba(255,255,255,0.95);
|
||||
}
|
||||
.org-teams {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, minmax(160px, 1fr));
|
||||
gap: 10px;
|
||||
align-items: start;
|
||||
}
|
||||
.org-team {
|
||||
border: 1px solid rgba(110, 126, 152, 0.25);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgba(255,255,255,0.95);
|
||||
min-width: 0;
|
||||
}
|
||||
.org-team h4 {
|
||||
margin: 0;
|
||||
padding: 9px 10px;
|
||||
font-size: 14px;
|
||||
color: #21324e;
|
||||
font-weight: 800;
|
||||
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
|
||||
background: rgba(240, 245, 252, 0.96);
|
||||
}
|
||||
.org-members {
|
||||
padding: 7px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.org-person {
|
||||
border: 1px solid rgba(116, 133, 161, 0.25);
|
||||
background: rgba(255,255,255,0.95);
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease, border-color 120ms ease;
|
||||
min-width: 0;
|
||||
}
|
||||
.org-person.active {
|
||||
border-color: rgba(15,118,110,0.6);
|
||||
background: rgba(15,118,110,0.11);
|
||||
}
|
||||
.org-person.assigned {
|
||||
border-color: rgba(37,99,235,0.5);
|
||||
background: rgba(37,99,235,0.1);
|
||||
}
|
||||
.org-person strong {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
line-height: 1.3;
|
||||
color: #15233a;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.org-person small {
|
||||
display: block;
|
||||
color: #5a6a86;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.mapper {
|
||||
top: 72px;
|
||||
width: min(96vw, 920px);
|
||||
max-height: 58vh;
|
||||
}
|
||||
.viewer-actions {
|
||||
top: 64px;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.mapper-head strong {
|
||||
font-size: 16px;
|
||||
}
|
||||
.org-top-title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.org-teams {
|
||||
grid-template-columns: repeat(3, minmax(150px, 1fr));
|
||||
}
|
||||
}
|
||||
canvas {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
cursor: grab;
|
||||
}
|
||||
canvas.dragging { cursor: grabbing; }
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
min-width: 170px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(17,24,39,0.94);
|
||||
color: white;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transform: translate(12px, 12px);
|
||||
transition: opacity 120ms ease;
|
||||
z-index: 3;
|
||||
}
|
||||
.tooltip.visible { opacity: 1; }
|
||||
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
|
||||
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="shell">
|
||||
<main class="panel viewer">
|
||||
<div class="viewer-head">
|
||||
<div class="chip" id="scale-chip"></div>
|
||||
<div class="chip" id="hover-chip">chair hover: none</div>
|
||||
</div>
|
||||
<div class="viewer-actions">
|
||||
<button type="button" id="fit-btn">전체 맞춤</button>
|
||||
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
|
||||
</div>
|
||||
<aside class="mapper hidden-off">
|
||||
<div class="mapper-head">
|
||||
<div id="mapper-status">
|
||||
<strong>조직 현황</strong>
|
||||
<span>선택 인원 없음</span>
|
||||
</div>
|
||||
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
|
||||
</div>
|
||||
<div class="org-chart" id="org-chart"></div>
|
||||
</aside>
|
||||
<canvas id="canvas"></canvas>
|
||||
<div class="tooltip" id="tooltip"></div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./center_chair_people_payload_7f.js"></script>
|
||||
<script>
|
||||
const DATA = window.CHAIR_MAP_DATA;
|
||||
function decodeSegments(base64) {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
||||
return new Int32Array(bytes.buffer);
|
||||
}
|
||||
const bgTileRanges = DATA.bgTileRanges;
|
||||
const bgSegValues = decodeSegments(DATA.bgSegsB64);
|
||||
const chairSegValues = decodeSegments(DATA.chairSegsB64);
|
||||
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
|
||||
key, name, kind, start, count
|
||||
}));
|
||||
const meta = DATA.meta;
|
||||
const world = meta.headerBounds;
|
||||
const canvas = document.getElementById("canvas");
|
||||
const ctx = canvas.getContext("2d");
|
||||
const tooltip = document.getElementById("tooltip");
|
||||
const scaleChip = document.getElementById("scale-chip");
|
||||
const hoverChip = document.getElementById("hover-chip");
|
||||
const STORAGE_KEY = "ptc-chair-selection";
|
||||
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
|
||||
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
|
||||
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
|
||||
const clearAssignBtn = document.getElementById("clear-assign-btn");
|
||||
const orgChartEl = document.getElementById("org-chart");
|
||||
const mapperStatus = document.getElementById("mapper-status");
|
||||
// Prevent stale auto-highlights from previous sessions.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
localStorage.removeItem(ASSIGN_STORAGE_KEY);
|
||||
const placed = new Set();
|
||||
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
|
||||
let chairAssignments = {};
|
||||
let activePersonId = null;
|
||||
const ORG_TEMPLATE = {
|
||||
top: {
|
||||
name: "총괄기획실",
|
||||
count: 53,
|
||||
members: [
|
||||
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
|
||||
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
|
||||
],
|
||||
},
|
||||
teams: [
|
||||
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
|
||||
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
|
||||
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
|
||||
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
|
||||
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
|
||||
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
|
||||
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
|
||||
],
|
||||
};
|
||||
const chairGeometry = chairs.map((chair) => {
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
const path = new Path2D();
|
||||
const hitSegments = new Float32Array(chair.count * 4);
|
||||
let segCursor = 0;
|
||||
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = chairSegValues[offset] / 10;
|
||||
const y1 = chairSegValues[offset + 1] / 10;
|
||||
const x2 = chairSegValues[offset + 2] / 10;
|
||||
const y2 = chairSegValues[offset + 3] / 10;
|
||||
path.moveTo(x1, y1);
|
||||
path.lineTo(x2, y2);
|
||||
hitSegments[segCursor] = x1;
|
||||
hitSegments[segCursor + 1] = y1;
|
||||
hitSegments[segCursor + 2] = x2;
|
||||
hitSegments[segCursor + 3] = y2;
|
||||
segCursor += 4;
|
||||
minX = Math.min(minX, x1, x2);
|
||||
minY = Math.min(minY, y1, y2);
|
||||
maxX = Math.max(maxX, x1, x2);
|
||||
maxY = Math.max(maxY, y1, y2);
|
||||
}
|
||||
return {
|
||||
...chair,
|
||||
minX,
|
||||
minY,
|
||||
maxX,
|
||||
maxY,
|
||||
area: Math.max(1, (maxX - minX) * (maxY - minY)),
|
||||
path,
|
||||
hitSegments,
|
||||
};
|
||||
});
|
||||
function renumberChairKeys(chairItems) {
|
||||
if (!chairItems.length) return;
|
||||
const heights = chairItems
|
||||
.map((chair) => Math.max(1, chair.maxY - chair.minY))
|
||||
.sort((a, b) => a - b);
|
||||
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
|
||||
const rowTolerance = Math.max(40, medianHeight * 0.9);
|
||||
|
||||
const sorted = [...chairItems].sort((a, b) => {
|
||||
const ay = (a.minY + a.maxY) * 0.5;
|
||||
const by = (b.minY + b.maxY) * 0.5;
|
||||
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
|
||||
const ax = (a.minX + a.maxX) * 0.5;
|
||||
const bx = (b.minX + b.maxX) * 0.5;
|
||||
return ax - bx; // left -> right
|
||||
});
|
||||
|
||||
sorted.forEach((chair, index) => {
|
||||
chair.key = String(index + 1);
|
||||
chair.seatNo = index + 1;
|
||||
});
|
||||
}
|
||||
renumberChairKeys(chairGeometry);
|
||||
const PICK_GRID_SIZE = 1800;
|
||||
const chairPickGrid = new Map();
|
||||
function pickGridKey(gx, gy) {
|
||||
return `${gx},${gy}`;
|
||||
}
|
||||
chairGeometry.forEach((chair, index) => {
|
||||
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const key = pickGridKey(gx, gy);
|
||||
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
|
||||
chairPickGrid.get(key).push(index);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
|
||||
let pixelRatio = window.devicePixelRatio || 1;
|
||||
let pointer = { x: 0, y: 0 };
|
||||
let dragging = false;
|
||||
let dragStart = null;
|
||||
let hovered = null;
|
||||
let rafPending = false;
|
||||
|
||||
function normalizePeople(raw) {
|
||||
return raw
|
||||
.map((person, index) => {
|
||||
if (!person || !person.name) return null;
|
||||
return {
|
||||
id: person.id || `person-${index + 1}`,
|
||||
name: String(person.name).trim(),
|
||||
dept: String(person.dept || "").trim(),
|
||||
title: String(person.title || "").trim(),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function createTemplatePeople() {
|
||||
const generated = [];
|
||||
let seq = 1;
|
||||
ORG_TEMPLATE.top.members.forEach((member) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name: member.name,
|
||||
dept: member.dept,
|
||||
title: member.title,
|
||||
});
|
||||
});
|
||||
ORG_TEMPLATE.teams.forEach((team) => {
|
||||
team.members.forEach((name) => {
|
||||
generated.push({
|
||||
id: `org-${seq++}`,
|
||||
name,
|
||||
dept: team.name,
|
||||
title: "선임",
|
||||
});
|
||||
});
|
||||
});
|
||||
return generated;
|
||||
}
|
||||
|
||||
people = normalizePeople(people);
|
||||
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
|
||||
if (!templateReady) {
|
||||
people = createTemplatePeople();
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
|
||||
chairAssignments = Object.fromEntries(
|
||||
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
|
||||
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
|
||||
))
|
||||
);
|
||||
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
|
||||
|
||||
function persistPeople() {
|
||||
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
|
||||
}
|
||||
|
||||
function persistAssignments() {
|
||||
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
|
||||
}
|
||||
|
||||
function persistActivePerson() {
|
||||
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
|
||||
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
|
||||
}
|
||||
|
||||
function assignmentCount() {
|
||||
return Object.keys(chairAssignments).length;
|
||||
}
|
||||
|
||||
function getPersonById(id) {
|
||||
return people.find((person) => person.id === id) || null;
|
||||
}
|
||||
|
||||
function getChairByPerson(personId) {
|
||||
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
|
||||
if (assignedPersonId === personId) return chairKey;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderPeopleList() {
|
||||
const activePerson = getPersonById(activePersonId);
|
||||
const countText = `${assignmentCount()} / ${people.length} 매칭`;
|
||||
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
|
||||
|
||||
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
|
||||
const personCard = (person, roleText) => {
|
||||
if (!person) return "";
|
||||
const chairKey = getChairByPerson(person.id);
|
||||
const assignedClass = chairKey ? " assigned" : "";
|
||||
const activeClass = person.id === activePersonId ? " active" : "";
|
||||
return `
|
||||
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
|
||||
<strong>${person.name}</strong>
|
||||
<small>${person.title || roleText || "-"}</small>
|
||||
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
|
||||
</article>
|
||||
`;
|
||||
};
|
||||
|
||||
const topHtml = ORG_TEMPLATE.top.members
|
||||
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
|
||||
.join("");
|
||||
|
||||
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
|
||||
const membersHtml = team.members
|
||||
.map((name) => personCard(findPerson(team.name, name), "선임"))
|
||||
.join("");
|
||||
return `
|
||||
<section class="org-team">
|
||||
<h4>${team.name} (${team.count})</h4>
|
||||
<div class="org-members">${membersHtml}</div>
|
||||
</section>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
orgChartEl.innerHTML = `
|
||||
<section class="org-top">
|
||||
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
|
||||
<div class="org-top-members">${topHtml}</div>
|
||||
</section>
|
||||
<section class="org-teams">${teamsHtml}</section>
|
||||
`;
|
||||
}
|
||||
|
||||
function worldToScreen(x, y) {
|
||||
return {
|
||||
x: x * camera.scale + camera.offsetX,
|
||||
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
|
||||
};
|
||||
}
|
||||
|
||||
function screenToWorld(x, y) {
|
||||
return {
|
||||
x: (x - camera.offsetX) / camera.scale,
|
||||
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
|
||||
};
|
||||
}
|
||||
|
||||
function resize() {
|
||||
pixelRatio = window.devicePixelRatio || 1;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width * pixelRatio);
|
||||
canvas.height = Math.round(rect.height * pixelRatio);
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
fit();
|
||||
}
|
||||
|
||||
function fit() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const width = world.maxX - world.minX;
|
||||
const height = world.maxY - world.minY;
|
||||
const pad = 36;
|
||||
const scaleX = (rect.width - pad * 2) / width;
|
||||
const scaleY = (rect.height - pad * 2) / height;
|
||||
camera.scale = Math.min(scaleX, scaleY);
|
||||
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
|
||||
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
|
||||
requestDraw();
|
||||
}
|
||||
|
||||
function drawGrid(width, height) {
|
||||
ctx.save();
|
||||
ctx.strokeStyle = "rgba(21,35,48,0.05)";
|
||||
ctx.lineWidth = 1;
|
||||
for (let x = 120; x < width; x += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let y = 120; y < height; y += 120) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, y);
|
||||
ctx.lineTo(width, y);
|
||||
ctx.stroke();
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function pickChair(screenX, screenY) {
|
||||
const threshold = 12;
|
||||
const pointerWorld = screenToWorld(screenX, screenY);
|
||||
const thresholdWorld = threshold / camera.scale;
|
||||
const thresholdWorldSq = thresholdWorld * thresholdWorld;
|
||||
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
|
||||
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
|
||||
const candidateIndexes = [];
|
||||
const seen = new Set();
|
||||
for (let gx = minGX; gx <= maxGX; gx += 1) {
|
||||
for (let gy = minGY; gy <= maxGY; gy += 1) {
|
||||
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
|
||||
if (!candidates) continue;
|
||||
for (const index of candidates) {
|
||||
if (seen.has(index)) continue;
|
||||
seen.add(index);
|
||||
candidateIndexes.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
let best = null;
|
||||
for (const index of candidateIndexes) {
|
||||
const chair = chairGeometry[index];
|
||||
if (
|
||||
pointerWorld.x < chair.minX - thresholdWorld ||
|
||||
pointerWorld.x > chair.maxX + thresholdWorld ||
|
||||
pointerWorld.y < chair.minY - thresholdWorld ||
|
||||
pointerWorld.y > chair.maxY + thresholdWorld
|
||||
) continue;
|
||||
let distSq = Infinity;
|
||||
for (let i = 0; i < chair.hitSegments.length; i += 4) {
|
||||
const x1 = chair.hitSegments[i];
|
||||
const y1 = chair.hitSegments[i + 1];
|
||||
const x2 = chair.hitSegments[i + 2];
|
||||
const y2 = chair.hitSegments[i + 3];
|
||||
const dx = x2 - x1;
|
||||
const dy = y2 - y1;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
let segDistSq;
|
||||
if (len2 === 0) {
|
||||
const px = pointerWorld.x - x1;
|
||||
const py = pointerWorld.y - y1;
|
||||
segDistSq = px * px + py * py;
|
||||
} else {
|
||||
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
|
||||
t = Math.max(0, Math.min(1, t));
|
||||
const lx = x1 + t * dx;
|
||||
const ly = y1 + t * dy;
|
||||
const px = pointerWorld.x - lx;
|
||||
const py = pointerWorld.y - ly;
|
||||
segDistSq = px * px + py * py;
|
||||
}
|
||||
if (segDistSq < distSq) distSq = segDistSq;
|
||||
if (distSq <= thresholdWorldSq * 0.3) break;
|
||||
}
|
||||
if (distSq > thresholdWorldSq) continue;
|
||||
const dist = Math.sqrt(distSq) * camera.scale;
|
||||
|
||||
if (!best) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
const distGap = dist - best.dist;
|
||||
if (distGap < -0.75) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Math.abs(distGap) <= 2) {
|
||||
const areaGap = chair.area - best.chair.area;
|
||||
if (areaGap < -1) {
|
||||
best = { chair, dist };
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
Math.abs(areaGap) <= 1 &&
|
||||
chair.kind === "block" &&
|
||||
best.chair.kind !== "block"
|
||||
) {
|
||||
best = { chair, dist };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return best ? best.chair : null;
|
||||
}
|
||||
|
||||
function renderTooltip() {
|
||||
if (!hovered) {
|
||||
tooltip.classList.remove("visible");
|
||||
hoverChip.textContent = "chair hover: none";
|
||||
return;
|
||||
}
|
||||
hoverChip.textContent = `chair hover: ${hovered.name}`;
|
||||
tooltip.innerHTML = `
|
||||
<strong>${hovered.name}</strong>
|
||||
<div>chair key: ${hovered.key}</div>
|
||||
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
|
||||
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
|
||||
`;
|
||||
tooltip.style.left = `${pointer.x + 14}px`;
|
||||
tooltip.style.top = `${pointer.y + 14}px`;
|
||||
tooltip.classList.add("visible");
|
||||
}
|
||||
|
||||
function requestDraw() {
|
||||
if (rafPending) return;
|
||||
rafPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
rafPending = false;
|
||||
draw();
|
||||
});
|
||||
}
|
||||
|
||||
function applyWorldTransform() {
|
||||
ctx.setTransform(
|
||||
pixelRatio * camera.scale,
|
||||
0,
|
||||
0,
|
||||
-pixelRatio * camera.scale,
|
||||
pixelRatio * camera.offsetX,
|
||||
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
|
||||
);
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
ctx.clearRect(0, 0, rect.width, rect.height);
|
||||
drawGrid(rect.width, rect.height);
|
||||
const viewA = screenToWorld(0, rect.height);
|
||||
const viewB = screenToWorld(rect.width, 0);
|
||||
const viewMinX = Math.min(viewA.x, viewB.x);
|
||||
const viewMaxX = Math.max(viewA.x, viewB.x);
|
||||
const viewMinY = Math.min(viewA.y, viewB.y);
|
||||
const viewMaxY = Math.max(viewA.y, viewB.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
|
||||
ctx.lineWidth = 1 / camera.scale;
|
||||
const tileSize = meta.backgroundTileSize;
|
||||
const tileMinX = Math.floor(viewMinX / tileSize);
|
||||
const tileMaxX = Math.floor(viewMaxX / tileSize);
|
||||
const tileMinY = Math.floor(viewMinY / tileSize);
|
||||
const tileMaxY = Math.floor(viewMaxY / tileSize);
|
||||
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
|
||||
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
|
||||
const range = bgTileRanges[`${tx},${ty}`];
|
||||
if (!range) continue;
|
||||
const start = range[0];
|
||||
const count = range[1];
|
||||
for (let i = start; i < start + count; i += 1) {
|
||||
const offset = i * 4;
|
||||
const x1 = bgSegValues[offset] / 10;
|
||||
const y1 = bgSegValues[offset + 1] / 10;
|
||||
const x2 = bgSegValues[offset + 2] / 10;
|
||||
const y2 = bgSegValues[offset + 3] / 10;
|
||||
if (
|
||||
Math.max(x1, x2) < viewMinX ||
|
||||
Math.min(x1, x2) > viewMaxX ||
|
||||
Math.max(y1, y2) < viewMinY ||
|
||||
Math.min(y1, y2) > viewMaxY
|
||||
) continue;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x1, y1);
|
||||
ctx.lineTo(x2, y2);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
|
||||
|
||||
ctx.save();
|
||||
applyWorldTransform();
|
||||
ctx.lineWidth = 1.45 / camera.scale;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
for (const chair of chairGeometry) {
|
||||
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
|
||||
const active = hovered && hovered.key === chair.key;
|
||||
const selected = placed.has(chair.key);
|
||||
const assignedPersonId = chairAssignments[chair.key];
|
||||
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
|
||||
const assigned = Boolean(assignedPersonId);
|
||||
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
|
||||
ctx.strokeStyle = activePersonChair
|
||||
? "rgba(234, 179, 8, 1)"
|
||||
: assigned
|
||||
? "rgba(37, 99, 235, 0.98)"
|
||||
: selected
|
||||
? "rgba(220, 38, 38, 0.98)"
|
||||
: active
|
||||
? "rgba(15, 118, 110, 0.98)"
|
||||
: chair.kind === "group"
|
||||
? "rgba(16, 134, 149, 0.74)"
|
||||
: "rgba(21, 149, 142, 0.8)";
|
||||
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
|
||||
ctx.stroke(chair.path);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
|
||||
renderTooltip();
|
||||
}
|
||||
|
||||
function persistPlaced() {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
|
||||
}
|
||||
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
dragging = true;
|
||||
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
|
||||
canvas.classList.add("dragging");
|
||||
});
|
||||
|
||||
window.addEventListener("pointerup", (event) => {
|
||||
if (dragging && dragStart) {
|
||||
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
|
||||
if (move < 4) {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
|
||||
if (picked) {
|
||||
if (placed.has(picked.key)) placed.delete(picked.key);
|
||||
else placed.add(picked.key);
|
||||
persistPlaced();
|
||||
if (activePersonId) {
|
||||
const currentChair = getChairByPerson(activePersonId);
|
||||
if (chairAssignments[picked.key] === activePersonId) {
|
||||
delete chairAssignments[picked.key];
|
||||
} else {
|
||||
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
|
||||
chairAssignments[picked.key] = activePersonId;
|
||||
}
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
dragging = false;
|
||||
dragStart = null;
|
||||
canvas.classList.remove("dragging");
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
window.addEventListener("pointermove", (event) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
|
||||
if (dragging && dragStart) {
|
||||
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
|
||||
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
|
||||
}
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
canvas.addEventListener("wheel", (event) => {
|
||||
event.preventDefault();
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mx = event.clientX - rect.left;
|
||||
const my = event.clientY - rect.top;
|
||||
const before = screenToWorld(mx, my);
|
||||
const factor = event.deltaY < 0 ? 1.08 : 0.92;
|
||||
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
|
||||
const after = worldToScreen(before.x, before.y);
|
||||
camera.offsetX += mx - after.x;
|
||||
camera.offsetY += my - after.y;
|
||||
requestDraw();
|
||||
}, { passive: false });
|
||||
|
||||
document.getElementById("fit-btn").addEventListener("click", fit);
|
||||
document.getElementById("clear-btn").addEventListener("click", () => {
|
||||
placed.clear();
|
||||
persistPlaced();
|
||||
requestDraw();
|
||||
});
|
||||
clearAssignBtn.addEventListener("click", () => {
|
||||
chairAssignments = {};
|
||||
persistAssignments();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
orgChartEl.addEventListener("click", (event) => {
|
||||
const item = event.target.closest(".org-person[data-person-id]");
|
||||
if (!item) return;
|
||||
const personId = item.getAttribute("data-person-id");
|
||||
activePersonId = personId === activePersonId ? null : personId;
|
||||
persistActivePerson();
|
||||
renderPeopleList();
|
||||
requestDraw();
|
||||
});
|
||||
window.addEventListener("resize", resize);
|
||||
renderPeopleList();
|
||||
resize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
BIN
public/img/location_photo/한맥빌딩/1층.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
public/img/location_photo/한맥빌딩/2층.png
Normal file
|
After Width: | Height: | Size: 276 KiB |
BIN
public/img/location_photo/한맥빌딩/3층.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
public/img/location_photo/한맥빌딩/4층.png
Normal file
|
After Width: | Height: | Size: 228 KiB |
BIN
public/img/location_photo/한맥빌딩/5층.png
Normal file
|
After Width: | Height: | Size: 242 KiB |
BIN
public/img/location_photo/한맥빌딩/6층.png
Normal file
|
After Width: | Height: | Size: 259 KiB |
BIN
public/img/location_photo/한맥빌딩/7층.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
|
Before Width: | Height: | Size: 9.5 MiB After Width: | Height: | Size: 9.5 MiB |
|
Before Width: | Height: | Size: 9.8 MiB After Width: | Height: | Size: 9.8 MiB |
|
Before Width: | Height: | Size: 8.1 MiB After Width: | Height: | Size: 8.1 MiB |
|
Before Width: | Height: | Size: 5.8 MiB After Width: | Height: | Size: 5.8 MiB |
118
scratch/fix_dates_by_spec.js
Normal file
@@ -0,0 +1,118 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
});
|
||||
|
||||
// 하드웨어 출시 연도 데이터베이스 (CPU/GPU)
|
||||
const RELEASE_DATES = {
|
||||
// Intel CPU Generations (Mainstream desktop release month/year)
|
||||
'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10',
|
||||
'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10',
|
||||
'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11',
|
||||
'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03',
|
||||
'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05',
|
||||
'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10',
|
||||
'i7-8': '2017-10', 'i5-8': '2017-10',
|
||||
'i7-7': '2017-01', 'i5-7': '2017-01',
|
||||
'i7-6': '2015-08', 'i5-6': '2015-08',
|
||||
'i7-4': '2013-06', 'i5-4': '2013-06',
|
||||
'i7-3': '2012-04', 'i5-3': '2012-04',
|
||||
'i7-2': '2011-01', 'i5-2': '2011-01',
|
||||
|
||||
// NVIDIA GPU Series
|
||||
'RTX 4090': '2022-10', 'RTX 4080': '2022-11', 'RTX 4070': '2023-04', 'RTX 4060': '2023-06',
|
||||
'RTX 3090': '2020-09', 'RTX 3080': '2020-09', 'RTX 3070': '2020-10', 'RTX 3060': '2021-02',
|
||||
'RTX 2080': '2018-09', 'RTX 2070': '2018-10', 'RTX 2060': '2019-01',
|
||||
'GTX 1660': '2019-03', 'GTX 1650': '2019-04',
|
||||
'GTX 1080': '2016-05', 'GTX 1070': '2016-06', 'GTX 1060': '2016-07', 'GTX 1050': '2016-10',
|
||||
'GTX 980': '2014-09', 'GTX 970': '2014-09', 'GTX 960': '2015-01'
|
||||
};
|
||||
|
||||
function inferDateFromSpecs(cpu, gpu) {
|
||||
const cpuStr = (cpu || '').toUpperCase();
|
||||
const gpuStr = (gpu || '').toUpperCase();
|
||||
|
||||
let inferred = null;
|
||||
|
||||
// 1. GPU 기준 (최신 그래픽카드가 꽂혀있으면 그 시기 이후 구매일 확률이 높음)
|
||||
for (const [key, date] of Object.entries(RELEASE_DATES)) {
|
||||
if (gpuStr.includes(key)) {
|
||||
inferred = date;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. CPU 기준 (GPU에서 못 찾았거나, CPU가 더 최신일 경우)
|
||||
if (!inferred) {
|
||||
for (const [key, date] of Object.entries(RELEASE_DATES)) {
|
||||
// i7-13700 등을 찾기 위해 정규식 또는 포함 여부 확인
|
||||
if (cpuStr.includes(key)) {
|
||||
inferred = date;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return inferred ? `${inferred}-01` : null;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await connection.query(`
|
||||
SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu
|
||||
FROM asset_core c
|
||||
LEFT JOIN asset_spec s ON c.id = s.asset_id
|
||||
`);
|
||||
|
||||
const updates = [];
|
||||
const unchanged = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const currentVal = (row.purchase_date || '').trim();
|
||||
|
||||
// 구매일자가 없거나 부정확한 경우만 처리
|
||||
if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('2024-01-01')) {
|
||||
const specDate = inferDateFromSpecs(row.cpu, row.gpu);
|
||||
|
||||
if (specDate) {
|
||||
updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
|
||||
} else {
|
||||
unchanged.push({ code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🚀 스펙 분석 결과: ${updates.length}건의 자산 구매일자를 보정합니다.`);
|
||||
|
||||
for (const item of updates) {
|
||||
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
|
||||
console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`);
|
||||
}
|
||||
|
||||
if (unchanged.length > 0) {
|
||||
console.log('\n⚠️ 스펙 정보를 찾을 수 없어 보정하지 못한 자산:');
|
||||
unchanged.forEach(u => {
|
||||
if (u.code) console.log(`[Skip] ${u.code.padEnd(15)} | CPU: ${u.cpu || '-'} | GPU: ${u.gpu || '-'}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n✅ 완료: ${updates.length}건 보정됨.`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
} finally {
|
||||
connection.release();
|
||||
pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
128
scratch/fix_dates_strict.js
Normal file
@@ -0,0 +1,128 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
});
|
||||
|
||||
// 하드웨어 출시 연도/월 데이터베이스
|
||||
const RELEASE_DATES = {
|
||||
// Intel CPU
|
||||
'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10',
|
||||
'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10',
|
||||
'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11',
|
||||
'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03',
|
||||
'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05',
|
||||
'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10',
|
||||
'i7-8': '2017-10', 'i5-8': '2017-10',
|
||||
'i7-7': '2017-01', 'i5-7': '2017-01',
|
||||
'i7-6': '2015-08', 'i5-6': '2015-08',
|
||||
'i7-5': '2014-06', 'i5-5': '2015-06', // Broadwell
|
||||
'i7-4': '2013-06', 'i5-4': '2013-06',
|
||||
'i7-3': '2012-04', 'i5-3': '2012-04',
|
||||
'i7-2': '2011-01', 'i5-2': '2011-01',
|
||||
|
||||
// NVIDIA GPU
|
||||
'RTX 40': '2022-10',
|
||||
'RTX 30': '2020-09',
|
||||
'RTX 20': '2018-09',
|
||||
'GTX 16': '2019-02',
|
||||
'GTX 10': '2016-05',
|
||||
'GTX 9': '2014-09',
|
||||
'GTX 750': '2014-02',
|
||||
'GTX 7': '2013-05',
|
||||
'GTX 6': '2012-03'
|
||||
};
|
||||
|
||||
// 출시 연도만 있는 경우 (지시에 따라 후속년도 12월 적용을 위함)
|
||||
const YEAR_ONLY = {
|
||||
'I5-4': 2013,
|
||||
'I5-6': 2015,
|
||||
'I7-7': 2017,
|
||||
'GTX 750': 2014
|
||||
};
|
||||
|
||||
function inferDateFromSpecs(cpu, gpu) {
|
||||
const cpuStr = (cpu || '').toUpperCase();
|
||||
const gpuStr = (gpu || '').toUpperCase();
|
||||
|
||||
let latestYear = 0;
|
||||
let latestMonth = 0;
|
||||
|
||||
// 모든 매핑 데이터를 순회하며 가장 최신 날짜를 찾음
|
||||
for (const [key, dateStr] of Object.entries(RELEASE_DATES)) {
|
||||
if (cpuStr.includes(key) || gpuStr.includes(key)) {
|
||||
const [y, m] = dateStr.split('-').map(Number);
|
||||
if (y > latestYear || (y === latestYear && m > latestMonth)) {
|
||||
latestYear = y;
|
||||
latestMonth = m;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 매칭된 정보가 있는 경우
|
||||
if (latestYear > 0) {
|
||||
// 월 정보가 명확히 매핑된 경우 (RELEASE_DATES 사용)
|
||||
// 하지만 지시사항에 따라 "월을 못찾으면 12월" & "후속년도" 규칙 적용 여부 판단
|
||||
// RELEASE_DATES는 월이 이미 있으므로 그대로 사용하되,
|
||||
// 만약 YEAR_ONLY에만 걸리는 경우를 위해 로직 보강
|
||||
return `${latestYear}-${String(latestMonth).padStart(2, '0')}-01`;
|
||||
}
|
||||
|
||||
// 연도만 매칭되는 경우 (지시사항: 후속년도 12월)
|
||||
for (const [key, year] of Object.entries(YEAR_ONLY)) {
|
||||
if (cpuStr.includes(key) || gpuStr.includes(key)) {
|
||||
return `${year + 1}-12-01`;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
async function run() {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await connection.query(`
|
||||
SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu
|
||||
FROM asset_core c
|
||||
LEFT JOIN asset_spec s ON c.id = s.asset_id
|
||||
`);
|
||||
|
||||
const updates = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const currentVal = (row.purchase_date || '').trim();
|
||||
|
||||
// 구매일자가 없거나 '-', 'undefined'인 경우 + 혹은 아직 보정이 필요한 자산
|
||||
if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('0000') || currentVal === '2024-01-01') {
|
||||
const specDate = inferDateFromSpecs(row.cpu, row.gpu);
|
||||
if (specDate) {
|
||||
updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`🚀 지시사항 반영: ${updates.length}건의 자산을 보정합니다. (후속년도/12월 규칙 적용)`);
|
||||
|
||||
for (const item of updates) {
|
||||
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
|
||||
console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`);
|
||||
}
|
||||
|
||||
console.log(`\n✅ 완료: ${updates.length}건 보정됨.`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
} finally {
|
||||
connection.release();
|
||||
pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
88
scratch/fix_purchase_dates.js
Normal file
@@ -0,0 +1,88 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
});
|
||||
|
||||
async function run() {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
// 먼저 잘못 들어간 0000-00-01 등 복구
|
||||
console.log('잘못된 형식(0000-00-01 등)을 초기화합니다...');
|
||||
await connection.query("UPDATE asset_core SET purchase_date = '-' WHERE purchase_date LIKE '0000%' OR purchase_date = '2020-01-01'");
|
||||
|
||||
const [rows] = await connection.query('SELECT id, asset_code, purchase_date, category FROM asset_core');
|
||||
|
||||
const updates = [];
|
||||
const missing = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const code = (row.asset_code || '').trim();
|
||||
const currentVal = (row.purchase_date || '').trim();
|
||||
|
||||
// 구매일자가 없거나 '-', 'undefined' 인 경우 대상
|
||||
if (!currentVal || currentVal === '-' || currentVal === 'undefined') {
|
||||
let inferredDate = null;
|
||||
|
||||
// 1. PREFIX-YYYYMM-NNNN 형식 (예: PC-202406-0001)
|
||||
const match6 = code.match(/[A-Z]+-(\d{4})(0[1-9]|1[0-2])-\d+/);
|
||||
if (match6) {
|
||||
inferredDate = `${match6[1]}-${match6[2]}-01`;
|
||||
} else {
|
||||
// 2. PREFIX-YYYYNN 형식 (예: PC-202423) -> 연도만 있고 뒤에 순번 2자리
|
||||
const matchYearSeq = code.match(/[A-Z]+-(20\d{2})(\d{2})$/);
|
||||
if (matchYearSeq) {
|
||||
inferredDate = `${matchYearSeq[1]}-01-01`; // 월을 모르므로 1월로 통일
|
||||
} else {
|
||||
// 3. PREFIX-YYNNN 형식 (예: PC-24001)
|
||||
const matchShort = code.match(/[A-Z]+-(1\d|2\d)(\d{3})/);
|
||||
if (matchShort) {
|
||||
inferredDate = `20${matchShort[1]}-01-01`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 0000 등의 잘못된 매칭 방지
|
||||
if (inferredDate && !inferredDate.startsWith('0000')) {
|
||||
updates.push({ id: row.id, date: inferredDate, code: code });
|
||||
} else {
|
||||
missing.push({ id: row.id, code: code, category: row.category });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`총 ${updates.length}건의 자산을 업데이트합니다.`);
|
||||
for (const item of updates) {
|
||||
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
|
||||
console.log(`[Update] ${item.code} -> ${item.date}`);
|
||||
}
|
||||
|
||||
console.log('\n--- 구매일자를 추정할 수 없는 자산 목록 ---');
|
||||
if (missing.length === 0) {
|
||||
console.log('없음');
|
||||
} else {
|
||||
// 중복 제거 및 정렬하여 보고
|
||||
const uniqueMissing = missing.filter(m => m.code !== '');
|
||||
uniqueMissing.forEach(m => {
|
||||
console.log(`[Missing] 코드: ${m.code.padEnd(20)} | 카테고리: ${m.category}`);
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n완료: ${updates.length}건 업데이트됨, ${missing.length}건 미결정.`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('Error:', err);
|
||||
} finally {
|
||||
connection.release();
|
||||
pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
run();
|
||||
815
server.js
@@ -4,183 +4,738 @@ import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
|
||||
dotenv.config();
|
||||
dotenv.config({ override: true });
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
app.use(express.json({ limit: '100mb' }));
|
||||
app.use(express.json({ limit: '50mb' }));
|
||||
app.use('/uploads', express.static('uploads')); // 업로드 파일 정적 서빙
|
||||
|
||||
// Request Logger
|
||||
app.use((req, res, next) => {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
// uploads 폴더가 없으면 생성
|
||||
if (!fs.existsSync('uploads')) {
|
||||
fs.mkdirSync('uploads');
|
||||
}
|
||||
|
||||
// MySQL Pool Configuration
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.DB_HOST,
|
||||
user: process.env.DB_USER,
|
||||
password: process.env.DB_PASS,
|
||||
database: process.env.DB_NAME,
|
||||
port: parseInt(process.env.DB_PORT || '3306'),
|
||||
charset: 'utf8mb4'
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0
|
||||
});
|
||||
|
||||
const handleError = (res, err, context, isGet = false) => {
|
||||
console.error(`❌ [${context}] Error:`, err.message);
|
||||
if (isGet) res.json([]);
|
||||
else res.status(500).json({ error: err.message });
|
||||
};
|
||||
|
||||
// --- API Implementation ---
|
||||
|
||||
/**
|
||||
* Generic Fetcher for Asset Tables
|
||||
*/
|
||||
const fetchAssets = async (tableName, res, context) => {
|
||||
// Database startup check (ensure job_spec_standards table exists)
|
||||
(async () => {
|
||||
let connection;
|
||||
try {
|
||||
const [rows] = await pool.query(`SELECT * FROM ${tableName}`);
|
||||
console.log(`📡 [GET ${context}] Returning ${rows.length} rows from ${tableName}`);
|
||||
res.json(rows);
|
||||
connection = await pool.getConnection();
|
||||
await connection.query(`
|
||||
CREATE TABLE IF NOT EXISTS job_spec_standards (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
job_name VARCHAR(100) UNIQUE NOT NULL,
|
||||
cpu_standard VARCHAR(255),
|
||||
ram_standard VARCHAR(100),
|
||||
gpu_standard VARCHAR(100),
|
||||
min_score INT DEFAULT 0,
|
||||
remarks TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
console.log('✅ job_spec_standards table verification completed.');
|
||||
} catch (err) {
|
||||
handleError(res, err, context, true);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic Batch Saver for Asset Tables
|
||||
*/
|
||||
const saveAssetsBatch = async (tableName, items, res, context) => {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
|
||||
// Get valid columns for this table
|
||||
const [cols] = await connection.query(`DESCRIBE ${tableName}`);
|
||||
const validColumns = cols.map(c => c.Field);
|
||||
|
||||
// 1. Clear existing
|
||||
await connection.query(`DELETE FROM ${tableName}`);
|
||||
|
||||
// 2. Insert new items
|
||||
for (const item of items) {
|
||||
const filteredRow = {};
|
||||
validColumns.forEach(col => {
|
||||
if (col === 'created_at' || col === 'updated_at') return;
|
||||
if (item[col] !== undefined) filteredRow[col] = item[col];
|
||||
});
|
||||
|
||||
if (!filteredRow.id) filteredRow.id = Math.random().toString(36).substring(2, 9);
|
||||
await connection.query(`INSERT INTO ${tableName} SET ?`, [filteredRow]);
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
res.json({ success: true, count: items.length });
|
||||
} catch (err) {
|
||||
await connection.rollback();
|
||||
handleError(res, err, context);
|
||||
console.error('❌ Failed to verify/create job_spec_standards table:', err);
|
||||
} finally {
|
||||
connection.release();
|
||||
if (connection) connection.release();
|
||||
}
|
||||
})();
|
||||
|
||||
// Error Handler
|
||||
const handleError = (res, err, label) => {
|
||||
console.error(`❌ [${label}] Error:`, err);
|
||||
res.status(500).json({ error: err.message });
|
||||
};
|
||||
|
||||
// --- Routes ---
|
||||
|
||||
const routeMap = {
|
||||
'/api/users': { table: 'system_users', context: 'USERS' },
|
||||
'/api/pc': { table: 'asset_pc', context: 'PC' },
|
||||
'/api/server': { table: 'asset_server', context: 'SERVER' },
|
||||
'/api/storage': { table: 'asset_storage', context: 'STORAGE' },
|
||||
'/api/network': { table: 'asset_network', context: 'NETWORK' },
|
||||
'/api/sw/internal': { table: 'asset_sw_internal', context: 'SW INTERNAL' },
|
||||
'/api/sw/external': { table: 'asset_sw_external', context: 'SW EXTERNAL' },
|
||||
'/api/survey': { table: 'asset_survey', context: 'SURVEY' },
|
||||
'/api/pc-parts': { table: 'asset_pc_parts', context: 'PC PARTS' },
|
||||
'/api/equipment': { table: 'asset_equipment', context: 'EQUIPMENT' },
|
||||
'/api/office-supplies': { table: 'asset_office_supplies', context: 'OFFICE SUPPLIES' },
|
||||
'/api/cloud': { table: 'asset_cloud', context: 'CLOUD' },
|
||||
'/api/domain': { table: 'asset_domain', context: 'DOMAIN' },
|
||||
'/api/cost': { table: 'asset_cost', context: 'COST' },
|
||||
'/api/vip': { table: 'asset_vip', context: 'VIP' },
|
||||
'/api/asset/software/assignment': { table: 'asset_software_assignment', context: 'SW ASSIGN' }
|
||||
// --- Global Constants ---
|
||||
const CATEGORY_TABLE_MAP = {
|
||||
pc: 'asset_core',
|
||||
server: 'asset_core',
|
||||
storage: 'asset_core',
|
||||
network: 'asset_core',
|
||||
equipment: 'asset_core',
|
||||
officeSupplies: 'asset_core',
|
||||
survey: 'asset_core',
|
||||
vip: 'asset_core',
|
||||
pcParts: 'asset_core',
|
||||
swInternal: 'asset_software_perpetual',
|
||||
swExternal: 'asset_software_subscription',
|
||||
swUsers: 'asset_software_assignment',
|
||||
users: 'system_users',
|
||||
logs: 'asset_history'
|
||||
};
|
||||
|
||||
Object.entries(routeMap).forEach(([route, { table, context }]) => {
|
||||
app.get(route, (req, res) => fetchAssets(table, res, context));
|
||||
app.post(`${route}/batch`, (req, res) => saveAssetsBatch(table, req.body, res, `${context} BATCH`));
|
||||
});
|
||||
const ASSET_TABLES = [
|
||||
'asset_core'
|
||||
];
|
||||
|
||||
app.get('/api/asset/history', (req, res) => fetchAssets('asset_history', res, 'HISTORY'));
|
||||
app.post('/api/asset/history/batch', async (req, res) => {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM asset_history');
|
||||
for (const item of req.body) {
|
||||
const dbRow = {
|
||||
asset_id: item.assetId,
|
||||
log_date: item.date,
|
||||
log_user: item.user,
|
||||
details: item.details,
|
||||
cost: item.cost || 0
|
||||
};
|
||||
await connection.query('INSERT INTO asset_history SET ?', [dbRow]);
|
||||
}
|
||||
await connection.commit();
|
||||
res.json({ success: true });
|
||||
} catch (err) { await connection.rollback(); handleError(res, err, 'BATCH HISTORY'); } finally { connection.release(); }
|
||||
});
|
||||
// --- API Endpoints ---
|
||||
|
||||
app.get('/api/generate-asset-code', async (req, res) => {
|
||||
// 1. Generic Batch Save (Dynamic Table Detection)
|
||||
app.post('/api/:table/batch', async (req, res) => {
|
||||
const { table } = req.params;
|
||||
const dbTable = CATEGORY_TABLE_MAP[table] || table;
|
||||
const data = req.body;
|
||||
if (!Array.isArray(data)) return res.status(400).json({ error: 'Data must be an array' });
|
||||
|
||||
let connection;
|
||||
try {
|
||||
const { prefix } = req.query;
|
||||
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
|
||||
const tables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_network', 'asset_survey', 'asset_pc_parts', 'asset_equipment', 'asset_office_supplies', 'asset_vip'];
|
||||
let lastCode = '';
|
||||
for (const table of tables) {
|
||||
const [rows] = await pool.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ? ORDER BY asset_code DESC LIMIT 1`, [`${prefix}%`]);
|
||||
if (rows.length > 0 && rows[0].asset_code > lastCode) lastCode = rows[0].asset_code;
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
const [columns] = await connection.query(`DESCRIBE ${dbTable}`);
|
||||
const validFields = columns.map(c => c.Field);
|
||||
|
||||
await connection.query(`DELETE FROM ${dbTable}`);
|
||||
|
||||
if (data.length > 0) {
|
||||
const placeholders = validFields.map(() => '?').join(', ');
|
||||
const sql = `INSERT INTO ${dbTable} (${validFields.join(', ')}) VALUES (${placeholders})`;
|
||||
|
||||
for (const item of data) {
|
||||
const values = validFields.map(field => {
|
||||
const val = item[field];
|
||||
return val === undefined ? null : val;
|
||||
});
|
||||
await connection.query(sql, values);
|
||||
}
|
||||
}
|
||||
let nextNum = 1;
|
||||
if (lastCode) {
|
||||
const lastNum = parseInt(lastCode.split('-').pop() || '0');
|
||||
nextNum = lastNum + 1;
|
||||
|
||||
await connection.commit();
|
||||
res.json({ success: true, count: data.length });
|
||||
} catch (err) {
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'BATCH SAVE');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Get All Assets (Integrated Master Data from Normalized V3 Schema)
|
||||
app.get('/api/assets/master', async (req, res) => {
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
|
||||
const masterData = {
|
||||
pc: [], server: [], storage: [], network: [],
|
||||
equipment: [], officeSupplies: [], survey: [], vip: [], pcParts: [],
|
||||
swInternal: [], swExternal: [], swUsers: [], users: [], logs: [], partsMaster: []
|
||||
};
|
||||
|
||||
// Load from V3 Normalized Schema
|
||||
const [rows] = await connection.query(`
|
||||
SELECT
|
||||
c.*,
|
||||
s.hw_status, s.model_name, s.mainboard, s.os, s.cpu, s.ram, s.gpu,
|
||||
s.monitoring, s.price, s.monitor_inch, s.serial_num,
|
||||
l.location, l.location_detail, l.location_photo, l.loc_x, l.loc_y,
|
||||
(
|
||||
SELECT JSON_ARRAYAGG(JSON_OBJECT('type', net_type, 'name', net_name, 'val1', net_value1, 'val2', net_value2))
|
||||
FROM asset_remote WHERE asset_id = c.id AND is_active = 1
|
||||
) as remotes,
|
||||
(
|
||||
SELECT JSON_ARRAYAGG(JSON_OBJECT('type', disk_type, 'capacity', capacity, 'unit', unit, 'slot', slot_no))
|
||||
FROM asset_volume WHERE asset_id = c.id
|
||||
) as volumes
|
||||
FROM asset_core c
|
||||
LEFT JOIN asset_spec s ON c.id = s.asset_id
|
||||
LEFT JOIN asset_location l ON l.id = (
|
||||
SELECT id FROM asset_location
|
||||
WHERE asset_id = c.id AND is_active = 1
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
)
|
||||
`);
|
||||
|
||||
const catMap = {
|
||||
'PC': 'pc', '서버': 'server', '저장매체': 'storage', '네트워크': 'network',
|
||||
'업무지원장비': 'equipment', '사무가구': 'officeSupplies', '공간정보장비': 'survey',
|
||||
'내빈/외빈': 'vip', 'PC부품': 'pcParts'
|
||||
};
|
||||
|
||||
rows.forEach(row => {
|
||||
const key = catMap[row.category] || 'pc';
|
||||
masterData[key].push(row);
|
||||
});
|
||||
|
||||
const [swInternal] = await connection.query('SELECT * FROM asset_software_perpetual');
|
||||
const [swExternal] = await connection.query('SELECT * FROM asset_software_subscription');
|
||||
const [swUsers] = await connection.query('SELECT * FROM asset_software_assignment');
|
||||
const [users] = await connection.query('SELECT * FROM system_users');
|
||||
const [logs] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC');
|
||||
const [partsMaster] = await connection.query('SELECT * FROM hardware_components_master ORDER BY category, component_name');
|
||||
const [jobSpecs] = await connection.query('SELECT * FROM job_spec_standards ORDER BY job_name');
|
||||
|
||||
masterData.swInternal = swInternal;
|
||||
masterData.swExternal = swExternal;
|
||||
masterData.swUsers = swUsers;
|
||||
masterData.users = users;
|
||||
masterData.logs = logs;
|
||||
masterData.partsMaster = partsMaster;
|
||||
masterData.jobSpecs = jobSpecs;
|
||||
|
||||
res.json(masterData);
|
||||
} catch (err) {
|
||||
handleError(res, err, 'MASTER DATA');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Asset Save (Surgical Split to Normalized V3 Tables)
|
||||
app.post('/api/asset/:category/save', async (req, res) => {
|
||||
const asset = req.body;
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
// 3.0 History Tracking & Auto Field Update
|
||||
const [oldCoreRows] = await connection.query('SELECT * FROM asset_core WHERE id = ?', [asset.id]);
|
||||
const [oldSpecRows] = await connection.query('SELECT * FROM asset_spec WHERE asset_id = ?', [asset.id]);
|
||||
const oldCore = oldCoreRows[0] || {};
|
||||
const oldSpec = oldSpecRows[0] || {};
|
||||
|
||||
const historyLogs = [];
|
||||
const logDate = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
|
||||
const logUser = '관리자';
|
||||
|
||||
// 3.0.1 Core 변동 감지 (Dept, User)
|
||||
const oldDept = oldCore.current_dept || '';
|
||||
const newDept = asset.current_dept || '';
|
||||
if (newDept !== '' && oldDept !== newDept) {
|
||||
asset.previous_dept = oldDept;
|
||||
historyLogs.push({
|
||||
event_type: 'DEPT_CHANGE',
|
||||
old_dept: oldDept || null,
|
||||
new_dept: newDept,
|
||||
details: `[조직 변동] ${oldDept || '(없음)'} -> ${newDept}`
|
||||
});
|
||||
}
|
||||
res.json({ nextCode: `${prefix}${String(nextNum).padStart(3, '0')}` });
|
||||
|
||||
const oldUser = oldCore.user_current || '';
|
||||
const newUser = asset.user_current || '';
|
||||
if (newUser !== '' && oldUser !== newUser) {
|
||||
asset.previous_user = oldUser;
|
||||
historyLogs.push({
|
||||
event_type: 'USER_CHANGE',
|
||||
old_user: oldUser || null,
|
||||
new_user: newUser,
|
||||
details: `[사용자 변동] ${oldUser || '(없음)'} -> ${newUser}`
|
||||
});
|
||||
}
|
||||
|
||||
// 3.0.2 Spec 변동 감지 (CPU, RAM, GPU, OS, Mainboard 등)
|
||||
const specFieldsToTrack = [
|
||||
{ key: 'cpu', label: 'CPU' },
|
||||
{ key: 'ram', label: 'RAM' },
|
||||
{ key: 'gpu', label: 'GPU' },
|
||||
{ key: 'os', label: 'OS' },
|
||||
{ key: 'mainboard', label: '메인보드' }
|
||||
];
|
||||
|
||||
specFieldsToTrack.forEach(field => {
|
||||
const oldVal = String(oldSpec[field.key] || '').trim();
|
||||
const newVal = String(asset[field.key] || '').trim();
|
||||
if (newVal !== '' && oldVal !== newVal) {
|
||||
historyLogs.push({
|
||||
event_type: 'SPEC_CHANGE',
|
||||
details: `[사양 변경] ${field.label}: ${oldVal || '(없음)'} -> ${newVal}`
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 3.0.3 상태 변경 감지
|
||||
const oldStatus = oldSpec.hw_status || '';
|
||||
const newStatus = asset.hw_status || '';
|
||||
if (newStatus !== '' && oldStatus !== newStatus) {
|
||||
historyLogs.push({
|
||||
event_type: 'STATUS_CHANGE',
|
||||
details: `[상태 변경] ${oldStatus || '(없음)'} -> ${newStatus}`
|
||||
});
|
||||
}
|
||||
|
||||
// 로그 일괄 삽입
|
||||
for (const log of historyLogs) {
|
||||
await connection.query(
|
||||
`INSERT INTO asset_history (asset_id, event_type, old_dept, new_dept, old_user, new_user, details, log_date, log_user)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[asset.id, log.event_type, log.old_dept || null, log.new_dept || null, log.old_user || null, log.new_user || null, log.details, logDate, logUser]
|
||||
);
|
||||
}
|
||||
|
||||
// 3.1 asset_core
|
||||
const coreFields = ['id', 'asset_code', 'category', 'asset_type', 'current_role', 'asset_purpose', 'service_type', 'purchase_corp', 'purchase_date', 'purchase_amount', 'purchase_vendor', 'approval_document', 'memo', 'manager_primary', 'manager_secondary', 'current_dept', 'previous_dept', 'user_current', 'previous_user', 'emp_no', 'user_position'];
|
||||
const coreData = {};
|
||||
coreFields.forEach(f => { if (asset[f] !== undefined) coreData[f] = asset[f]; });
|
||||
const coreKeys = Object.keys(coreData);
|
||||
|
||||
console.log(`[DEBUG] Saving Asset ID: ${asset.id}, Code: ${asset.asset_code}`);
|
||||
const [existingCore] = await connection.query('SELECT id FROM asset_core WHERE id = ?', [asset.id]);
|
||||
console.log(`[DEBUG] Existing Core Check for ${asset.id}: Found ${existingCore.length}`);
|
||||
|
||||
if (existingCore.length > 0) {
|
||||
// UPDATE
|
||||
const updateKeys = coreKeys.filter(k => k !== 'id');
|
||||
const coreSql = `UPDATE asset_core SET ${updateKeys.map(k => `${k} = ?`).join(', ')} WHERE id = ?`;
|
||||
const [updRes] = await connection.query(coreSql, [...updateKeys.map(k => coreData[k]), asset.id]);
|
||||
console.log(`[DEBUG] Core UPDATE result: affectedRows=${updRes.affectedRows}`);
|
||||
} else {
|
||||
// INSERT
|
||||
const coreSql = `INSERT INTO asset_core (${coreKeys.join(', ')}) VALUES (${coreKeys.map(() => '?').join(', ')})`;
|
||||
const [insRes] = await connection.query(coreSql, Object.values(coreData));
|
||||
console.log(`[DEBUG] Core INSERT result: affectedRows=${insRes.affectedRows}`);
|
||||
}
|
||||
|
||||
// 3.2 asset_spec
|
||||
const specFields = ['hw_status', 'model_name', 'mainboard', 'os', 'cpu', 'ram', 'gpu', 'monitoring', 'price', 'monitor_inch', 'serial_num'];
|
||||
const specData = { asset_id: asset.id };
|
||||
specFields.forEach(f => { if (asset[f] !== undefined) specData[f] = asset[f]; });
|
||||
const specKeys = Object.keys(specData);
|
||||
const [specExists] = await connection.query('SELECT id FROM asset_spec WHERE asset_id = ?', [asset.id]);
|
||||
if (specExists.length > 0) {
|
||||
const updateSql = `UPDATE asset_spec SET ${specKeys.filter(k => k !== 'asset_id').map(k => `${k} = ?`).join(', ')} WHERE asset_id = ?`;
|
||||
await connection.query(updateSql, [...specKeys.filter(k => k !== 'asset_id').map(k => specData[k]), asset.id]);
|
||||
} else {
|
||||
await connection.query(`INSERT INTO asset_spec (${specKeys.join(', ')}) VALUES (${specKeys.map(() => '?').join(', ')})`, Object.values(specData));
|
||||
}
|
||||
|
||||
// 3.3 asset_volume
|
||||
await connection.query('DELETE FROM asset_volume WHERE asset_id = ?', [asset.id]);
|
||||
if (asset.volumes) {
|
||||
try {
|
||||
let vols = typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes;
|
||||
if (Array.isArray(vols)) {
|
||||
for (let i = 0; i < vols.length; i++) {
|
||||
const v = vols[i];
|
||||
if (v.type && v.capacity) {
|
||||
await connection.query(
|
||||
'INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)',
|
||||
[asset.id, v.type, v.capacity, v.unit || 'GB', v.slot || (i + 1)]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) { console.error('Volume parse error', e); }
|
||||
}
|
||||
|
||||
// 3.4 asset_location
|
||||
if (asset.location || asset.location_detail) {
|
||||
const [locActive] = await connection.query('SELECT * FROM asset_location WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
const isChanged = locActive.length === 0 || locActive[0].location !== asset.location || locActive[0].location_detail !== asset.location_detail || locActive[0].loc_x !== asset.loc_x || locActive[0].loc_y !== asset.loc_y;
|
||||
if (isChanged) {
|
||||
await connection.query('UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
await connection.query(`INSERT INTO asset_location (asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||
[asset.id, asset.location, asset.location_detail, asset.location_photo, asset.loc_x, asset.loc_y]);
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 asset_remote (Dynamic Array Logic)
|
||||
if (asset.remotes) {
|
||||
try {
|
||||
let nets = typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes;
|
||||
if (Array.isArray(nets)) {
|
||||
await connection.query('UPDATE asset_remote SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
for (const n of nets) {
|
||||
if (n.type) {
|
||||
await connection.query(
|
||||
'INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)',
|
||||
[asset.id, n.type, n.name || '', n.val1 || '', n.val2 || '']
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch(e) { console.error('Remote data parse error', e); }
|
||||
} else {
|
||||
// Fallback for UI that hasn't sent the networks array yet
|
||||
if (asset.ip_address || asset.mac_address || asset.remote_tool) {
|
||||
const [netActive] = await connection.query('SELECT * FROM asset_remote WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
const isChanged = netActive.length === 0 || netActive[0].net_value1 !== asset.ip_address || netActive[0].net_value2 !== asset.mac_address || netActive[0].net_name !== asset.remote_tool;
|
||||
if (isChanged) {
|
||||
await connection.query('UPDATE asset_remote SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
if (asset.ip_address || asset.mac_address) {
|
||||
await connection.query('INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)', [asset.id, 'IP', '기본망', asset.ip_address, asset.mac_address]);
|
||||
}
|
||||
if (asset.remote_tool || asset.remote_id || asset.remote_pw) {
|
||||
await connection.query('INSERT INTO asset_remote (asset_id, net_type, net_name, net_value1, net_value2, is_active) VALUES (?, ?, ?, ?, ?, 1)', [asset.id, 'REMOTE', asset.remote_tool, asset.remote_id, asset.remote_pw]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await connection.commit();
|
||||
console.log(`💾 [V3 ASSET SAVE] ID: ${asset.id}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'ASSET SAVE V3');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 3.6 PC Flow Transaction (Checkout, Return, Move)
|
||||
app.post('/api/pc/flow', async (req, res) => {
|
||||
const { action, assetId, userName, dept, empNo, position, date, details, manager } = req.body;
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
if (action === 'checkout') {
|
||||
await connection.query(
|
||||
`UPDATE asset_core
|
||||
SET user_current = ?, emp_no = ?, current_dept = ?, user_position = ?
|
||||
WHERE id = ?`,
|
||||
[userName, empNo, dept, position, assetId]
|
||||
);
|
||||
await connection.query(
|
||||
`UPDATE asset_spec SET hw_status = '운영' WHERE asset_id = ?`,
|
||||
[assetId]
|
||||
);
|
||||
} else if (action === 'return') {
|
||||
await connection.query(
|
||||
`UPDATE asset_core
|
||||
SET previous_user = user_current, previous_dept = current_dept,
|
||||
user_current = '', emp_no = '', user_position = ''
|
||||
WHERE id = ?`,
|
||||
[assetId]
|
||||
);
|
||||
await connection.query(
|
||||
`UPDATE asset_spec SET hw_status = '재고' WHERE asset_id = ?`,
|
||||
[assetId]
|
||||
);
|
||||
} else if (action === 'move') {
|
||||
await connection.query(
|
||||
`UPDATE asset_core
|
||||
SET previous_user = user_current, previous_dept = current_dept,
|
||||
user_current = ?, emp_no = ?, current_dept = ?, user_position = ?
|
||||
WHERE id = ?`,
|
||||
[userName, empNo, dept, position, assetId]
|
||||
);
|
||||
await connection.query(
|
||||
`UPDATE asset_spec SET hw_status = '운영' WHERE asset_id = ?`,
|
||||
[assetId]
|
||||
);
|
||||
} else {
|
||||
throw new Error('Invalid action type');
|
||||
}
|
||||
|
||||
// Insert into asset_history
|
||||
await connection.query(
|
||||
`INSERT INTO asset_history (asset_id, log_date, log_user, details)
|
||||
VALUES (?, ?, ?, ?)`,
|
||||
[assetId, date || new Date().toISOString().split('T')[0], manager || 'system', details]
|
||||
);
|
||||
|
||||
await connection.commit();
|
||||
console.log(`💾 [PC FLOW TRANSACTION] Action: ${action}, Asset ID: ${assetId}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
if (connection) await connection.rollback();
|
||||
handleError(res, err, 'PC FLOW TRANSACTION');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 4. Asset Delete
|
||||
app.delete('/api/asset/:category/:id', async (req, res) => {
|
||||
const { category, id } = req.params;
|
||||
|
||||
// Define mapping for which base table handles the delete
|
||||
const deleteTableMap = {
|
||||
pc: 'asset_core',
|
||||
server: 'asset_core',
|
||||
storage: 'asset_core',
|
||||
network: 'asset_core',
|
||||
equipment: 'asset_core',
|
||||
officeSupplies: 'asset_core',
|
||||
survey: 'asset_core',
|
||||
vip: 'asset_core',
|
||||
pcParts: 'asset_core',
|
||||
swInternal: 'asset_software_perpetual',
|
||||
swExternal: 'asset_software_subscription',
|
||||
swUsers: 'asset_software_assignment',
|
||||
users: 'system_users'
|
||||
};
|
||||
|
||||
const table = deleteTableMap[category];
|
||||
|
||||
if (!table) return res.status(400).json({ error: 'Invalid category for deletion' });
|
||||
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
// For asset_core, ON DELETE CASCADE will handle spec, location, remote, volume
|
||||
await connection.query(`DELETE FROM ${table} WHERE id = ?`, [id]);
|
||||
connection.release();
|
||||
console.log(`🗑️ [ASSET DELETE] Category: ${category}, ID: ${id}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'ASSET DELETE');
|
||||
}
|
||||
});
|
||||
|
||||
// 5. Generate Next Asset Code
|
||||
app.get('/api/generate-asset-code', async (req, res) => {
|
||||
const { prefix, purchaseDate } = req.query;
|
||||
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
const datePart = purchaseDate ? purchaseDate.toString().replace(/-/g, '').substring(0, 6) : '';
|
||||
const searchPattern = datePart ? `${prefix}-${datePart}-%` : `${prefix}-%`;
|
||||
let maxNum = 0;
|
||||
for (const table of ASSET_TABLES) {
|
||||
try {
|
||||
const [rows] = await connection.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ?`, [searchPattern]);
|
||||
rows.forEach(row => {
|
||||
const parts = row.asset_code.split('-');
|
||||
const num = parseInt(parts[parts.length - 1]);
|
||||
if (!isNaN(num) && num > maxNum) maxNum = num;
|
||||
});
|
||||
} catch (err) {}
|
||||
}
|
||||
const nextNum = maxNum + 1;
|
||||
const nextCode = datePart ? `${prefix}-${datePart}-${String(nextNum).padStart(4, '0')}` : `${prefix}-${String(nextNum).padStart(4, '0')}`;
|
||||
connection.release();
|
||||
res.json({ nextCode });
|
||||
} catch (err) { handleError(res, err, 'GENERATE CODE'); }
|
||||
});
|
||||
|
||||
// 6. Map Config API (Real-time Save)
|
||||
// 6. Map Config API
|
||||
app.get('/api/maps', (req, res) => {
|
||||
try {
|
||||
if (!fs.existsSync('map_config.json')) {
|
||||
return res.json({});
|
||||
}
|
||||
if (!fs.existsSync('map_config.json')) return res.json({});
|
||||
const data = fs.readFileSync('map_config.json', 'utf8');
|
||||
res.json(JSON.parse(data || '{}'));
|
||||
} catch (err) { handleError(res, err, 'GET MAPS'); }
|
||||
});
|
||||
|
||||
// 6.5. Get Hardware Components Master List
|
||||
app.get('/api/hardware-components', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM hardware_components_master ORDER BY category, component_name');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
handleError(res, err, 'GET MAPS');
|
||||
handleError(res, err, 'GET HARDWARE COMPONENTS');
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/maps/save', (req, res) => {
|
||||
// 6.6. Save Hardware Component (Add or Update)
|
||||
app.post('/api/hardware-components/save', async (req, res) => {
|
||||
const { id, category, component_name, score_tier, deduction } = req.body;
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
if (id) {
|
||||
await connection.query(
|
||||
'UPDATE hardware_components_master SET category = ?, component_name = ?, score_tier = ?, deduction = ? WHERE id = ?',
|
||||
[category, component_name, score_tier, deduction, id]
|
||||
);
|
||||
} else {
|
||||
await connection.query(
|
||||
'INSERT INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
|
||||
[category, component_name, score_tier, deduction]
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'SAVE HARDWARE COMPONENT');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 6.7. Delete Hardware Component
|
||||
app.delete('/api/hardware-components/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
await connection.query('DELETE FROM hardware_components_master WHERE id = ?', [id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'DELETE HARDWARE COMPONENT');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 6.7.1. Get Job Spec Standards
|
||||
app.get('/api/job-specs', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM job_spec_standards ORDER BY job_name');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
handleError(res, err, 'GET JOB SPECS');
|
||||
}
|
||||
});
|
||||
|
||||
// 6.7.2. Save Job Spec Standard (Add or Update)
|
||||
app.post('/api/job-specs/save', async (req, res) => {
|
||||
const { id, job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks } = req.body;
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
if (id) {
|
||||
await connection.query(
|
||||
'UPDATE job_spec_standards SET job_name = ?, cpu_standard = ?, ram_standard = ?, gpu_standard = ?, min_score = ?, remarks = ? WHERE id = ?',
|
||||
[job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks, id]
|
||||
);
|
||||
} else {
|
||||
await connection.query(
|
||||
'INSERT INTO job_spec_standards (job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks]
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'SAVE JOB SPEC');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 6.7.3. Delete Job Spec Standard
|
||||
app.delete('/api/job-specs/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
await connection.query('DELETE FROM job_spec_standards WHERE id = ?', [id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'DELETE JOB SPEC');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 6.8. Get System Users List
|
||||
app.get('/api/system-users', async (req, res) => {
|
||||
try {
|
||||
const [rows] = await pool.query('SELECT * FROM system_users ORDER BY user_name');
|
||||
res.json(rows);
|
||||
} catch (err) {
|
||||
handleError(res, err, 'GET SYSTEM USERS');
|
||||
}
|
||||
});
|
||||
|
||||
// 6.9. Save System User (Add or Update)
|
||||
app.post('/api/system-users/save', async (req, res) => {
|
||||
const { id, emp_no, user_name, dept_name, position, status } = req.body;
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
if (id) {
|
||||
await connection.query(
|
||||
'UPDATE system_users SET emp_no = ?, user_name = ?, dept_name = ?, position = ?, status = ? WHERE id = ?',
|
||||
[emp_no, user_name, dept_name, position, status, id]
|
||||
);
|
||||
} else {
|
||||
const newId = 'USER-' + Math.random().toString(36).substring(2, 9).toUpperCase();
|
||||
await connection.query(
|
||||
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[newId, emp_no, user_name, dept_name, position, status]
|
||||
);
|
||||
}
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'SAVE SYSTEM USER');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 6.10. Delete System User
|
||||
app.delete('/api/system-users/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
let connection;
|
||||
try {
|
||||
connection = await pool.getConnection();
|
||||
await connection.query('DELETE FROM system_users WHERE id = ?', [id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'DELETE SYSTEM USER');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/maps/save', async (req, res) => {
|
||||
let connection;
|
||||
try {
|
||||
const { path, boxes } = req.body;
|
||||
if (!path) return res.status(400).json({ error: 'Path is required' });
|
||||
|
||||
let config = {};
|
||||
// 1. Get old config to track movements
|
||||
let oldConfig = {};
|
||||
if (fs.existsSync('map_config.json')) {
|
||||
config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
|
||||
oldConfig = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
|
||||
}
|
||||
const oldBoxes = oldConfig[path] || [];
|
||||
|
||||
// 2. Save new config to file
|
||||
oldConfig[path] = boxes;
|
||||
fs.writeFileSync('map_config.json', JSON.stringify(oldConfig, null, 2));
|
||||
|
||||
// 3. Sync Database Assets (asset_location table)
|
||||
connection = await pool.getConnection();
|
||||
for (const box of boxes) {
|
||||
if (box.asset_id) {
|
||||
console.log(`Syncing asset ${box.asset_id} to new position: [${box.x}, ${box.y}]`);
|
||||
await connection.query(
|
||||
'UPDATE asset_location SET loc_x = ?, loc_y = ? WHERE asset_id = ? AND is_active = 1',
|
||||
[box.x, box.y, box.asset_id]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
config[path] = boxes;
|
||||
fs.writeFileSync('map_config.json', JSON.stringify(config, null, 2));
|
||||
console.log(`💾 [MAP SAVE] Updated config for: ${path}`);
|
||||
res.json({ success: true });
|
||||
res.json({ success: true, message: 'Map and Database synced successfully' });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'SAVE MAPS SYNC');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
// 7. File Upload API (Base64)
|
||||
app.post('/api/upload', (req, res) => {
|
||||
try {
|
||||
const { fileName, fileData } = req.body;
|
||||
if (!fileName || !fileData) return res.status(400).json({ error: 'FileName and FileData are required' });
|
||||
|
||||
// base64 데이터에서 실제 바이너리 추출
|
||||
const base64Data = fileData.replace(/^data:.*;base64,/, "");
|
||||
const buffer = Buffer.from(base64Data, 'base64');
|
||||
|
||||
// 고유한 파일명 생성 (타임스탬프 결합)
|
||||
const timestamp = Date.now();
|
||||
const safeFileName = `${timestamp}_${fileName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
|
||||
const filePath = `uploads/${safeFileName}`;
|
||||
|
||||
fs.writeFileSync(filePath, buffer);
|
||||
|
||||
console.log(`파일 업로드 성공: ${filePath}`);
|
||||
res.json({ success: true, filePath: `/${filePath}`, fileName: safeFileName });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'SAVE MAPS');
|
||||
handleError(res, err, 'FILE UPLOAD');
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(3000, '0.0.0.0', () => {
|
||||
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (Multi-Table Optimized)');
|
||||
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)');
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createIcons, BookOpen, X, ChevronDown, ChevronRight, RefreshCw } from 'lucide';
|
||||
import { state } from '../core/state';
|
||||
import './guide.css';
|
||||
|
||||
// ─── 자산별 가이드 콘텐츠 정의 (SW_Table 브랜치 전체 복구) ───
|
||||
interface GuideTabConfig {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createIcons, X } from 'lucide';
|
||||
import { setEditLock } from './ModalUtils';
|
||||
import './modal.css';
|
||||
|
||||
/**
|
||||
* 모든 모달의 공통 기능을 관리하는 베이스 추상 클래스입니다.
|
||||
@@ -9,6 +10,7 @@ export abstract class BaseModal {
|
||||
protected title: string;
|
||||
protected currentAsset: any | null = null;
|
||||
protected isEditMode: boolean = false;
|
||||
protected currentMode: 'view' | 'edit' | 'add' = 'view';
|
||||
protected modalEl: HTMLElement | null = null;
|
||||
protected formEl: HTMLFormElement | null = null;
|
||||
|
||||
@@ -53,13 +55,23 @@ export abstract class BaseModal {
|
||||
*/
|
||||
public open(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||
this.currentAsset = asset;
|
||||
this.currentMode = mode;
|
||||
this.isEditMode = (mode === 'add' || mode === 'edit');
|
||||
|
||||
this.setEditLockMode(mode);
|
||||
// 폼 초기화 추가
|
||||
if (this.formEl) this.formEl.reset();
|
||||
|
||||
// fillFormData를 먼저 호출하여 동적 요소들을 생성한 후 잠금 처리
|
||||
this.fillFormData(asset);
|
||||
this.setEditLockMode(mode);
|
||||
|
||||
if (this.modalEl) {
|
||||
this.modalEl.classList.remove('hidden');
|
||||
const content = this.modalEl.querySelector('.modal-content');
|
||||
if (content) {
|
||||
if (mode === 'view') content.classList.add('is-view-mode');
|
||||
else content.classList.remove('is-view-mode');
|
||||
}
|
||||
}
|
||||
|
||||
this.onAfterOpen(asset, mode);
|
||||
@@ -108,9 +120,16 @@ export function closeModals() {
|
||||
}
|
||||
|
||||
export function initBaseModal() {
|
||||
// ESC 키로 모든 모달 닫기
|
||||
// ESC 키로 모든 모달 닫기 (위치보기 팝업이 있으면 그것부터 닫음)
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeModals();
|
||||
if (e.key === 'Escape') {
|
||||
const picker = document.querySelector('.image-picker-overlay');
|
||||
if (picker) {
|
||||
picker.remove();
|
||||
} else {
|
||||
closeModals();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { closeAllModals: closeModals };
|
||||
|
||||
@@ -4,14 +4,14 @@ import { createIcons, X } from 'lucide';
|
||||
|
||||
const DASHBOARD_DETAIL_MODAL_HTML = `
|
||||
<div id="dashboard-detail-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide" style="max-width: 1000px;">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="dashboard-detail-modal-title">상세 목록</h2>
|
||||
<h2 id="dashboard-detail-modal-title" class="modal-title">상세 목록</h2>
|
||||
<button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="table-container">
|
||||
<table style="width:100%;">
|
||||
<table>
|
||||
<thead></thead>
|
||||
<tbody id="dashboard-detail-tbody"></tbody>
|
||||
</table>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { CORP_LIST } from './SharedData';
|
||||
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
|
||||
import { createIcons, X, Save, Database, CalendarClock, Edit2, History, Plus } from 'lucide';
|
||||
import { createIcons, X, Save, History, Plus } from 'lucide';
|
||||
import { formatExcelDate } from '../../core/excelHandler';
|
||||
import { UI_TEXT } from '../../core/schema';
|
||||
|
||||
@@ -16,15 +16,18 @@ class DomainAssetModal extends BaseModal {
|
||||
<div id="domain-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="domain-modal-title">${this.title}</h2>
|
||||
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||
<div class="header-left">
|
||||
<h2 id="domain-modal-title" class="modal-title">${this.title}</h2>
|
||||
<div id="domain-header-identity" class="header-identity"></div>
|
||||
</div>
|
||||
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-body-split">
|
||||
<div class="modal-form-area">
|
||||
<form id="domain-asset-form" class="grid-form">
|
||||
<input type="hidden" id="domain-id" name="id" />
|
||||
|
||||
|
||||
<div class="form-section-title">기본 정보</div>
|
||||
<div class="form-group">
|
||||
<label>구분</label>
|
||||
@@ -58,7 +61,7 @@ class DomainAssetModal extends BaseModal {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>비용 (연간/월간)</label>
|
||||
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
|
||||
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">담당자 및 비고</div>
|
||||
@@ -78,9 +81,9 @@ class DomainAssetModal extends BaseModal {
|
||||
</div>
|
||||
<div class="modal-history-area">
|
||||
<div class="history-header">
|
||||
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 변경 이력</h3>
|
||||
<h3><i data-lucide="history"></i> 변경 이력</h3>
|
||||
<button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm">
|
||||
이력 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
|
||||
이력 추가 <i data-lucide="plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="domain-history-list" class="history-timeline"></div>
|
||||
@@ -141,7 +144,7 @@ class DomainAssetModal extends BaseModal {
|
||||
}
|
||||
});
|
||||
|
||||
createIcons({ icons: { History, Plus, Save, CalendarClock, Database } });
|
||||
createIcons({ icons: { History, Plus, Save, X } });
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
@@ -158,31 +161,52 @@ class DomainAssetModal extends BaseModal {
|
||||
setFieldValue('domain-remarks', asset.remarks || '');
|
||||
|
||||
this.renderHistory(asset.id);
|
||||
this.updateHeaderIdentity(asset);
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
const titleEl = document.getElementById('domain-modal-title');
|
||||
if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세';
|
||||
|
||||
|
||||
const deleteBtn = document.getElementById('btn-delete-domain-asset');
|
||||
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
||||
|
||||
this.updateHeaderIdentity(asset);
|
||||
}
|
||||
|
||||
private updateHeaderIdentity(asset: any) {
|
||||
const container = document.getElementById('domain-header-identity');
|
||||
if (!container) return;
|
||||
|
||||
if (this.currentMode === 'add') {
|
||||
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const type = getFieldValue('domain-type') || asset.type || '';
|
||||
const serviceName = getFieldValue('domain-service-name') || asset.service_name || '';
|
||||
const domainName = getFieldValue('domain-name') || asset.domain_name || '';
|
||||
|
||||
container.innerHTML = `
|
||||
<span class="asset-code-title">${serviceName}</span>
|
||||
<span class="service-type-badge">${type}</span>
|
||||
<span class="asset-type-label">${domainName}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderHistory(assetId: string) {
|
||||
const container = document.getElementById('domain-history-list');
|
||||
if (!container) return;
|
||||
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
|
||||
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>'; return; }
|
||||
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
|
||||
|
||||
const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId);
|
||||
if (logs.length === 0) {
|
||||
container.innerHTML = '<div style="color:var(--mute); padding:1rem; text-align:center;">이력이 없습니다.</div>';
|
||||
} else {
|
||||
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const domainModal = new DomainAssetModal();
|
||||
|
||||
export function initDomainModal(onSave: () => void, closeModals: () => void) {
|
||||
domainModal.init(onSave, closeModals);
|
||||
}
|
||||
|
||||
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||
domainModal.open(asset, mode);
|
||||
}
|
||||
export function initDomainModal(onSave: () => void, closeModals: () => void) { domainModal.init(onSave, closeModals); }
|
||||
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { domainModal.open(asset, mode); }
|
||||
|
||||
295
src/components/Modal/JobSpecModal.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import { state, saveJobSpec, deleteJobSpec } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { setFieldValue } from './ModalUtils';
|
||||
import { UI_TEXT } from '../../core/schema';
|
||||
import { calculatePcScoreDeductive } from '../../core/utils';
|
||||
|
||||
class JobSpecModal extends BaseModal {
|
||||
constructor() {
|
||||
super('job-spec', '직무별 기준 사양');
|
||||
}
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
return `
|
||||
<div id="job-spec-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content narrow">
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<h2 id="job-spec-modal-title" class="modal-title">\${this.title}</h2>
|
||||
<div id="job-spec-header-identity" class="header-identity"></div>
|
||||
</div>
|
||||
<button id="btn-close-job-spec-modal" class="btn-icon" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="job-spec-asset-form" class="grid-form vertical-form">
|
||||
<input type="hidden" id="job-spec-id" name="id" />
|
||||
|
||||
<div class="form-group">
|
||||
<label>직무명</label>
|
||||
<input type="text" id="job-spec-job-name" name="job_name" placeholder="예: BIM 모델러, 개발자, 엔지니어" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group relative">
|
||||
<label>권장 CPU 사양</label>
|
||||
<input type="text" id="job-spec-cpu-standard" name="cpu_standard" placeholder="CPU 검색..." required autocomplete="off" />
|
||||
<div id="job-spec-cpu-autocomplete" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group relative">
|
||||
<label>권장 RAM 사양</label>
|
||||
<input type="text" id="job-spec-ram-standard" name="ram_standard" placeholder="RAM 검색..." required autocomplete="off" />
|
||||
<div id="job-spec-ram-autocomplete" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group relative">
|
||||
<label>권장 GPU 사양</label>
|
||||
<input type="text" id="job-spec-gpu-standard" name="gpu_standard" placeholder="GPU 검색..." required autocomplete="off" />
|
||||
<div id="job-spec-gpu-autocomplete" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>성능 기준 점수 (이상, 자동 계산됨)</label>
|
||||
<input type="number" id="job-spec-min-score" name="min_score" placeholder="자동 계산 대기..." required readonly />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>비고 (메모)</label>
|
||||
<textarea id="job-spec-remarks" name="remarks" placeholder="기타 필요 사양 및 안내 사항" rows="3"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-delete-job-spec-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-revert-job-spec-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||
<button id="btn-cancel-job-spec-modal" class="btn btn-outline">닫기</button>
|
||||
<button id="btn-save-job-spec-asset" class="btn btn-primary">수정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.autocomplete-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
background-color: white;
|
||||
border: 1px solid var(--border-color, #E2E8F0);
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.autocomplete-item {
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.autocomplete-item:hover {
|
||||
background-color: #F1F5F9;
|
||||
color: #1E5149;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||
const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
|
||||
const revertBtn = document.getElementById('btn-revert-job-spec-edit')!;
|
||||
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset) return;
|
||||
if (!this.isEditMode) {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const jobName = (document.getElementById('job-spec-job-name') as HTMLInputElement).value.trim();
|
||||
const cpuStd = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement).value.trim();
|
||||
const ramStd = (document.getElementById('job-spec-ram-standard') as HTMLInputElement).value.trim();
|
||||
const gpuStd = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement).value.trim();
|
||||
const minScoreStr = (document.getElementById('job-spec-min-score') as HTMLInputElement).value;
|
||||
const remarks = (document.getElementById('job-spec-remarks') as HTMLTextAreaElement).value.trim();
|
||||
|
||||
if (!jobName) {
|
||||
alert('직무명을 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
id: this.currentAsset.id || null,
|
||||
job_name: jobName,
|
||||
cpu_standard: cpuStd,
|
||||
ram_standard: ramStd,
|
||||
gpu_standard: gpuStd,
|
||||
min_score: minScoreStr !== '' ? parseInt(minScoreStr, 10) : 0,
|
||||
remarks: remarks
|
||||
};
|
||||
|
||||
if (await saveJobSpec(updated)) {
|
||||
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
revertBtn.addEventListener('click', () => {
|
||||
this.setEditLockMode('view');
|
||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset || !this.currentAsset.id) return;
|
||||
if (!confirm('정말로 이 직무별 기준 사양을 삭제하시겠습니까?')) return;
|
||||
|
||||
if (await deleteJobSpec(this.currentAsset.id)) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
// 자동완성 바인딩
|
||||
this.bindAutocomplete('job-spec-cpu-standard', 'job-spec-cpu-autocomplete', 'CPU');
|
||||
this.bindAutocomplete('job-spec-ram-standard', 'job-spec-ram-autocomplete', 'RAM');
|
||||
this.bindAutocomplete('job-spec-gpu-standard', 'job-spec-gpu-autocomplete', 'GPU');
|
||||
|
||||
// 실시간 점수 계산 이벤트 바인딩
|
||||
const inputs = ['job-spec-cpu-standard', 'job-spec-ram-standard', 'job-spec-gpu-standard'];
|
||||
inputs.forEach(id => {
|
||||
const el = document.getElementById(id);
|
||||
el?.addEventListener('input', () => this.updateMinScore());
|
||||
el?.addEventListener('change', () => this.updateMinScore());
|
||||
});
|
||||
}
|
||||
|
||||
private bindAutocomplete(inputId: string, autocompleteId: string, category: string) {
|
||||
const input = document.getElementById(inputId) as HTMLInputElement;
|
||||
const list = document.getElementById(autocompleteId) as HTMLDivElement;
|
||||
if (!input || !list) return;
|
||||
|
||||
const showList = (filterText: string = '') => {
|
||||
if (!this.isEditMode) return;
|
||||
const items = (state.masterData.partsMaster || []).filter((c: any) => c.category === category);
|
||||
const filtered = filterText
|
||||
? items.filter((c: any) => c.component_name.toLowerCase().includes(filterText.toLowerCase()))
|
||||
: items;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = '<div class="autocomplete-item" style="color: #94a3b8; cursor: default;">검색 결과 없음</div>';
|
||||
} else {
|
||||
list.innerHTML = filtered.map((c: any) => `<div class="autocomplete-item" data-val="${c.component_name}">${c.component_name}</div>`).join('');
|
||||
}
|
||||
list.classList.remove('hidden');
|
||||
};
|
||||
|
||||
input.addEventListener('focus', () => {
|
||||
showList(input.value);
|
||||
});
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
showList(input.value);
|
||||
});
|
||||
|
||||
list.addEventListener('mousedown', (e) => {
|
||||
const item = (e.target as HTMLElement).closest('.autocomplete-item');
|
||||
if (item && item.getAttribute('data-val')) {
|
||||
input.value = item.getAttribute('data-val') || '';
|
||||
list.classList.add('hidden');
|
||||
this.updateMinScore();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if (e.target !== input && !list.contains(e.target as Node)) {
|
||||
list.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updateMinScore(): void {
|
||||
const cpu = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement)?.value || '';
|
||||
const ram = (document.getElementById('job-spec-ram-standard') as HTMLInputElement)?.value || '';
|
||||
const gpu = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement)?.value || '';
|
||||
|
||||
const score = calculatePcScoreDeductive(cpu, ram, gpu, '');
|
||||
|
||||
const minScoreEl = document.getElementById('job-spec-min-score') as HTMLInputElement;
|
||||
if (minScoreEl) {
|
||||
minScoreEl.value = score.toString();
|
||||
}
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
setFieldValue('job-spec-id', asset.id || '');
|
||||
setFieldValue('job-spec-job-name', asset.job_name || '');
|
||||
setFieldValue('job-spec-cpu-standard', asset.cpu_standard || '');
|
||||
setFieldValue('job-spec-ram-standard', asset.ram_standard || '');
|
||||
setFieldValue('job-spec-gpu-standard', asset.gpu_standard || '');
|
||||
setFieldValue('job-spec-min-score', asset.min_score !== undefined ? asset.min_score.toString() : '100');
|
||||
setFieldValue('job-spec-remarks', asset.remarks || '');
|
||||
this.updateHeaderIdentity(asset);
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
const titleEl = document.getElementById('job-spec-modal-title');
|
||||
|
||||
if (titleEl) {
|
||||
if (mode === 'add') {
|
||||
titleEl.textContent = '신규 직무별 기준 사양 등록';
|
||||
} else {
|
||||
titleEl.textContent = '직무별 기준 사양 상세 편집';
|
||||
}
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
|
||||
const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
|
||||
|
||||
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
||||
|
||||
if (mode === 'add' || mode === 'edit') {
|
||||
saveBtn.textContent = (mode === 'add') ? '등록' : '저장';
|
||||
saveBtn.style.display = 'block';
|
||||
} else {
|
||||
saveBtn.textContent = '수정';
|
||||
saveBtn.style.display = 'block';
|
||||
}
|
||||
this.updateHeaderIdentity(asset);
|
||||
}
|
||||
|
||||
private updateHeaderIdentity(asset: any) {
|
||||
const container = document.getElementById('job-spec-header-identity');
|
||||
if (!container) return;
|
||||
|
||||
if (this.currentMode === 'add') {
|
||||
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const jobName = asset.job_name || '';
|
||||
const minScore = asset.min_score || 0;
|
||||
|
||||
container.innerHTML = `
|
||||
<span class="asset-code-title">${jobName}</span>
|
||||
<span class="service-type-badge">${minScore}점 기준</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const jobSpecModal = new JobSpecModal();
|
||||
|
||||
export function initJobSpecModal(onSave: () => void, closeModals: () => void) {
|
||||
jobSpecModal.init(onSave, closeModals);
|
||||
}
|
||||
|
||||
export function openJobSpecModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||
jobSpecModal.open(asset, mode);
|
||||
}
|
||||
@@ -110,29 +110,45 @@ export function setEditLock(
|
||||
const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null;
|
||||
const addLogBtn = options.addLogBtnId ? document.getElementById(options.addLogBtnId) : null;
|
||||
|
||||
if (!form || !saveBtn || !revertBtn) return;
|
||||
if (!form) return;
|
||||
|
||||
if (mode === 'add' || mode === 'edit') {
|
||||
const isEdit = (mode === 'add' || mode === 'edit');
|
||||
|
||||
if (isEdit) {
|
||||
// 편집 모드 활성화
|
||||
form.classList.remove('is-view-mode');
|
||||
form.classList.add('is-edit-mode');
|
||||
saveBtn.textContent = '저장';
|
||||
revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김
|
||||
if (saveBtn) saveBtn.textContent = (mode === 'add' ? '등록' : '저장');
|
||||
if (revertBtn) revertBtn.classList.toggle('hidden', mode === 'add');
|
||||
|
||||
// 번호 생성 버튼은 '추가(add)' 시에만 노출
|
||||
if (generateBtn) {
|
||||
generateBtn.style.display = mode === 'add' ? 'flex' : 'none';
|
||||
}
|
||||
// 내역 추가 버튼 노출
|
||||
// 모든 필드 활성화
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||
// 자산번호 및 ID 필드는 편집 모드에서도 잠금 유지
|
||||
if (el.name !== 'asset_code' && !el.id.includes('asset-id') && !el.id.includes('id-hidden')) {
|
||||
el.disabled = false;
|
||||
if ('readOnly' in el) (el as HTMLInputElement).readOnly = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (generateBtn) generateBtn.style.display = (mode === 'add' ? 'flex' : 'none');
|
||||
if (addLogBtn) addLogBtn.style.display = 'flex';
|
||||
} else {
|
||||
// 조회 모드 (잠금)
|
||||
form.classList.remove('is-edit-mode');
|
||||
form.classList.add('is-view-mode');
|
||||
saveBtn.textContent = '수정';
|
||||
revertBtn.classList.add('hidden');
|
||||
if (saveBtn) saveBtn.textContent = '수정';
|
||||
if (revertBtn) revertBtn.classList.add('hidden');
|
||||
|
||||
// 조회 모드에서는 버튼들 숨김
|
||||
// 모든 필드 잠금
|
||||
const inputs = form.querySelectorAll('input, select, textarea');
|
||||
inputs.forEach(input => {
|
||||
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||
el.disabled = true;
|
||||
if ('readOnly' in el) (el as HTMLInputElement).readOnly = true;
|
||||
});
|
||||
|
||||
if (generateBtn) generateBtn.style.display = 'none';
|
||||
if (addLogBtn) addLogBtn.style.display = 'none';
|
||||
}
|
||||
@@ -169,9 +185,9 @@ export function createModalFrameHTML(
|
||||
</div>
|
||||
<div class="modal-history-area">
|
||||
<div class="history-header">
|
||||
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> ${options.historyTitle}</h3>
|
||||
<button type="button" id="${options.addLogBtnId}" class="btn btn-outline btn-sm">
|
||||
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
|
||||
<h3><i data-lucide="history" class="icon-sm"></i> ${options.historyTitle}</h3>
|
||||
<button type="button" id="btn-add-${idPrefix}-log" class="btn btn-outline btn-sm">
|
||||
내역 추가 <i data-lucide="plus" class="icon-sm"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="${idPrefix}-history-list" class="history-timeline"></div>
|
||||
|
||||
595
src/components/Modal/PCFlowModal.ts
Normal file
@@ -0,0 +1,595 @@
|
||||
import { state, loadMasterDataFromDB } from '../../core/state';
|
||||
import { createIcons, Search, Monitor, RefreshCw } from 'lucide';
|
||||
import { API_BASE_URL } from '../../core/utils';
|
||||
|
||||
export class PCFlowModal {
|
||||
private static instance: PCFlowModal | null = null;
|
||||
|
||||
private modalEl: HTMLElement | null = null;
|
||||
private currentFlowType: 'checkout' | 'return' | 'move' = 'checkout';
|
||||
|
||||
// Selected state
|
||||
private selectedUser: any = null;
|
||||
private selectedTargetUser: any = null;
|
||||
private selectedPC: any = null;
|
||||
|
||||
private constructor() {}
|
||||
|
||||
public static getInstance(): PCFlowModal {
|
||||
if (!PCFlowModal.instance) {
|
||||
PCFlowModal.instance = new PCFlowModal();
|
||||
}
|
||||
return PCFlowModal.instance;
|
||||
}
|
||||
|
||||
public init(onSave: () => void) {
|
||||
if (document.getElementById('pc-flow-modal')) return;
|
||||
|
||||
// Inject HTML
|
||||
document.body.insertAdjacentHTML('beforeend', this.renderHTML());
|
||||
|
||||
this.modalEl = document.getElementById('pc-flow-modal');
|
||||
this.setupEventListeners(onSave);
|
||||
|
||||
// Set default date to today
|
||||
const dateInput = document.getElementById('pc-flow-date') as HTMLInputElement;
|
||||
if (dateInput) {
|
||||
dateInput.value = new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
createIcons({ icons: { Search, Monitor, RefreshCw } });
|
||||
}
|
||||
|
||||
public open() {
|
||||
this.resetState();
|
||||
if (this.modalEl) {
|
||||
this.modalEl.classList.remove('hidden');
|
||||
}
|
||||
this.updateUI();
|
||||
}
|
||||
|
||||
public close() {
|
||||
if (this.modalEl) {
|
||||
this.modalEl.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
private resetState() {
|
||||
this.selectedUser = null;
|
||||
this.selectedTargetUser = null;
|
||||
this.selectedPC = null;
|
||||
this.currentFlowType = 'checkout';
|
||||
|
||||
const radioCheckout = document.querySelector('input[name="flow-type"][value="checkout"]') as HTMLInputElement;
|
||||
if (radioCheckout) {
|
||||
radioCheckout.checked = true;
|
||||
document.querySelectorAll('.flow-type-label').forEach(l => {
|
||||
l.classList.toggle('active', l.contains(radioCheckout));
|
||||
});
|
||||
}
|
||||
|
||||
// Reset text fields
|
||||
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
|
||||
if (userSearch) userSearch.value = '';
|
||||
|
||||
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
|
||||
if (targetUserSearch) targetUserSearch.value = '';
|
||||
|
||||
const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
|
||||
if (stockSearch) stockSearch.value = '';
|
||||
|
||||
const details = document.getElementById('pc-flow-details') as HTMLTextAreaElement;
|
||||
if (details) details.value = '';
|
||||
}
|
||||
|
||||
private setupEventListeners(onSave: () => void) {
|
||||
const btnClose = document.getElementById('btn-close-pc-flow-modal');
|
||||
const btnCancel = document.getElementById('btn-cancel-pc-flow-modal');
|
||||
const btnSubmit = document.getElementById('btn-submit-pc-flow');
|
||||
|
||||
btnClose?.addEventListener('click', () => this.close());
|
||||
btnCancel?.addEventListener('click', () => this.close());
|
||||
|
||||
// Flow Type Radio Buttons
|
||||
const labels = document.querySelectorAll('.flow-type-label');
|
||||
labels.forEach(label => {
|
||||
const radio = label.querySelector('input[name="flow-type"]') as HTMLInputElement;
|
||||
label.addEventListener('click', () => {
|
||||
labels.forEach(l => l.classList.remove('active'));
|
||||
label.classList.add('active');
|
||||
radio.checked = true;
|
||||
this.currentFlowType = radio.value as any;
|
||||
|
||||
// Reset selected PC when switching flow types
|
||||
this.selectedPC = null;
|
||||
this.updateUI();
|
||||
});
|
||||
});
|
||||
|
||||
// 1. Source User Autocomplete Search
|
||||
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
|
||||
const userSuggestions = document.getElementById('pc-flow-user-suggestions')!;
|
||||
|
||||
userSearch?.addEventListener('input', () => {
|
||||
const query = userSearch.value.trim().toLowerCase();
|
||||
if (!query) {
|
||||
userSuggestions.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = state.masterData.users || [];
|
||||
const filtered = users.filter((u: any) =>
|
||||
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
|
||||
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
|
||||
(u.emp_no && u.emp_no.toString().includes(query))
|
||||
);
|
||||
|
||||
const uniqueFiltered: any[] = [];
|
||||
const seen = new Set();
|
||||
filtered.forEach((u: any) => {
|
||||
const key = u.emp_no || u.user_name;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueFiltered.push(u);
|
||||
}
|
||||
});
|
||||
|
||||
this.renderUserSuggestions(uniqueFiltered, userSuggestions, (user) => {
|
||||
this.selectedUser = user;
|
||||
userSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
|
||||
userSuggestions.classList.add('hidden');
|
||||
|
||||
// Automatically populate details if return or move
|
||||
if (this.currentFlowType === 'return' || this.currentFlowType === 'move') {
|
||||
this.selectedPC = null; // Reset selection
|
||||
}
|
||||
this.updateUI();
|
||||
});
|
||||
});
|
||||
|
||||
// Close suggestion overlays on clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (!target.closest('#pc-flow-user-search') && !target.closest('#pc-flow-user-suggestions')) {
|
||||
userSuggestions.classList.add('hidden');
|
||||
}
|
||||
if (!target.closest('#pc-flow-target-user-search') && !target.closest('#pc-flow-target-user-suggestions')) {
|
||||
const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions');
|
||||
targetSuggestions?.classList.add('hidden');
|
||||
}
|
||||
if (!target.closest('#pc-flow-stock-search') && !target.closest('#pc-flow-stock-suggestions')) {
|
||||
const stockSuggestions = document.getElementById('pc-flow-stock-suggestions');
|
||||
stockSuggestions?.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Target User Autocomplete Search (For Moves)
|
||||
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
|
||||
const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions')!;
|
||||
|
||||
targetUserSearch?.addEventListener('input', () => {
|
||||
const query = targetUserSearch.value.trim().toLowerCase();
|
||||
if (!query) {
|
||||
targetSuggestions.classList.add('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const users = state.masterData.users || [];
|
||||
const filtered = users.filter((u: any) =>
|
||||
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
|
||||
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
|
||||
(u.emp_no && u.emp_no.toString().includes(query))
|
||||
);
|
||||
|
||||
const uniqueFiltered: any[] = [];
|
||||
const seen = new Set();
|
||||
filtered.forEach((u: any) => {
|
||||
const key = u.emp_no || u.user_name;
|
||||
if (!seen.has(key)) {
|
||||
seen.add(key);
|
||||
uniqueFiltered.push(u);
|
||||
}
|
||||
});
|
||||
|
||||
this.renderUserSuggestions(uniqueFiltered, targetSuggestions, (user) => {
|
||||
this.selectedTargetUser = user;
|
||||
targetUserSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
|
||||
targetSuggestions.classList.add('hidden');
|
||||
this.updateUI();
|
||||
});
|
||||
});
|
||||
|
||||
// 3. Stock PC Autocomplete Search (For Checkout)
|
||||
const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
|
||||
const stockSuggestions = document.getElementById('pc-flow-stock-suggestions')!;
|
||||
|
||||
const showStockSuggestions = () => {
|
||||
const query = stockSearch.value.trim().toLowerCase();
|
||||
|
||||
// Filter available PCs (category PC, status '대기', '미할당', or '재고')
|
||||
const pcs = state.masterData.pc || [];
|
||||
const filtered = pcs.filter((p: any) => {
|
||||
const status = (p.hw_status || '').trim();
|
||||
const matchesQuery = !query ||
|
||||
(p.asset_code && p.asset_code.toLowerCase().includes(query)) ||
|
||||
(p.model_name && p.model_name.toLowerCase().includes(query)) ||
|
||||
(p.cpu && p.cpu.toLowerCase().includes(query));
|
||||
|
||||
return (status === '대기' || status === '미할당' || status === '재고') && matchesQuery;
|
||||
});
|
||||
|
||||
this.renderPCSuggestions(filtered, stockSuggestions, (pc) => {
|
||||
this.selectedPC = pc;
|
||||
stockSearch.value = `${pc.asset_code} - ${pc.model_name}`;
|
||||
stockSuggestions.classList.add('hidden');
|
||||
this.updateUI();
|
||||
});
|
||||
};
|
||||
|
||||
stockSearch?.addEventListener('input', showStockSuggestions);
|
||||
stockSearch?.addEventListener('focus', showStockSuggestions);
|
||||
stockSearch?.addEventListener('click', showStockSuggestions);
|
||||
|
||||
// 4. Submit Transaction
|
||||
btnSubmit?.addEventListener('click', async () => {
|
||||
if (!this.validateInputs()) return;
|
||||
|
||||
const dateVal = (document.getElementById('pc-flow-date') as HTMLInputElement).value;
|
||||
const detailsVal = (document.getElementById('pc-flow-details') as HTMLTextAreaElement).value.trim();
|
||||
const loginUser = state.currentUserRole === 'admin' ? '관리자' : '실무담당자';
|
||||
|
||||
// Build Details Message as JSON
|
||||
const logData = {
|
||||
type: this.currentFlowType,
|
||||
user: this.selectedUser ? this.selectedUser.user_name : '',
|
||||
dept: this.selectedUser ? this.selectedUser.dept_name : '',
|
||||
targetUser: this.selectedTargetUser ? this.selectedTargetUser.user_name : '',
|
||||
targetDept: this.selectedTargetUser ? this.selectedTargetUser.dept_name : '',
|
||||
assetCode: this.selectedPC ? this.selectedPC.asset_code : '',
|
||||
memo: detailsVal
|
||||
};
|
||||
const finalDetails = JSON.stringify(logData);
|
||||
|
||||
const payload: any = {
|
||||
action: this.currentFlowType,
|
||||
assetId: this.selectedPC.id,
|
||||
date: dateVal,
|
||||
details: finalDetails,
|
||||
manager: loginUser
|
||||
};
|
||||
|
||||
if (this.currentFlowType === 'checkout') {
|
||||
payload.userName = this.selectedUser.user_name;
|
||||
payload.dept = this.selectedUser.dept_name;
|
||||
payload.empNo = this.selectedUser.emp_no;
|
||||
payload.position = this.selectedUser.position || '사원';
|
||||
} else if (this.currentFlowType === 'move') {
|
||||
payload.userName = this.selectedTargetUser.user_name;
|
||||
payload.dept = this.selectedTargetUser.dept_name;
|
||||
payload.empNo = this.selectedTargetUser.emp_no;
|
||||
payload.position = this.selectedTargetUser.position || '사원';
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}/api/pc/flow`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('PC 이동/반납 처리가 완료되었습니다.');
|
||||
this.close();
|
||||
onSave(); // Refresh views
|
||||
} else {
|
||||
const errData = await response.json();
|
||||
alert(`오류 발생: ${errData.error || '처리 실패'}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('API Error:', err);
|
||||
alert('서버 전송 중 오류가 발생했습니다.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private validateInputs(): boolean {
|
||||
if (this.currentFlowType === 'checkout') {
|
||||
if (!this.selectedUser) { alert('대상 사원을 선택해주세요.'); return false; }
|
||||
if (!this.selectedPC) { alert('불출할 재고 PC를 선택해주세요.'); return false; }
|
||||
} else if (this.currentFlowType === 'return') {
|
||||
if (!this.selectedUser) { alert('반납 대상 사원을 선택해주세요.'); return false; }
|
||||
if (!this.selectedPC) { alert('반납할 PC 자산을 선택해주세요.'); return false; }
|
||||
} else if (this.currentFlowType === 'move') {
|
||||
if (!this.selectedUser) { alert('인계 사원을 선택해주세요.'); return false; }
|
||||
if (!this.selectedPC) { alert('이동할 PC 자산을 선택해주세요.'); return false; }
|
||||
if (!this.selectedTargetUser) { alert('인수 사원을 선택해주세요.'); return false; }
|
||||
if (this.selectedUser.emp_no === this.selectedTargetUser.emp_no) {
|
||||
alert('인계자와 인수자는 동일할 수 없습니다.');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private renderUserSuggestions(users: any[], container: HTMLElement, onSelect: (user: any) => void) {
|
||||
container.innerHTML = '';
|
||||
if (users.length === 0) {
|
||||
container.innerHTML = '<div class="autocomplete-item-empty">일치하는 사원이 없습니다.</div>';
|
||||
container.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
users.forEach(u => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'autocomplete-item';
|
||||
item.innerHTML = `
|
||||
<div class="suggestion-name">${u.user_name}</div>
|
||||
<div class="suggestion-meta">
|
||||
<span>부서: ${u.dept_name}</span>
|
||||
<span>|</span>
|
||||
<span>사번: ${u.emp_no || '-'}</span>
|
||||
</div>
|
||||
`;
|
||||
item.addEventListener('click', () => onSelect(u));
|
||||
container.appendChild(item);
|
||||
});
|
||||
container.classList.remove('hidden');
|
||||
}
|
||||
|
||||
private renderPCSuggestions(pcs: any[], container: HTMLElement, onSelect: (pc: any) => void) {
|
||||
container.innerHTML = '';
|
||||
if (pcs.length === 0) {
|
||||
container.innerHTML = '<div class="autocomplete-item-empty">불출 가능한 대기 PC 재고가 없습니다.</div>';
|
||||
container.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
pcs.forEach(p => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'autocomplete-item';
|
||||
item.innerHTML = `
|
||||
<div class="suggestion-name">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
|
||||
<div class="suggestion-meta">
|
||||
사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
|
||||
</div>
|
||||
`;
|
||||
item.addEventListener('click', () => onSelect(p));
|
||||
container.appendChild(item);
|
||||
});
|
||||
container.classList.remove('hidden');
|
||||
}
|
||||
|
||||
private updateUI() {
|
||||
// 1. Hide/Show dynamic sections based on flow type
|
||||
const stockContainer = document.getElementById('stock-pc-search-container')!;
|
||||
const targetUserContainer = document.getElementById('target-user-search-container')!;
|
||||
const userPcsContainer = document.getElementById('user-pcs-container')!;
|
||||
const labelStep2 = document.getElementById('user-search-label')!;
|
||||
|
||||
if (this.currentFlowType === 'checkout') {
|
||||
stockContainer.classList.remove('hidden');
|
||||
targetUserContainer.classList.add('hidden');
|
||||
userPcsContainer.classList.add('hidden');
|
||||
labelStep2.textContent = '2. 불출 대상 사원 검색';
|
||||
} else if (this.currentFlowType === 'return') {
|
||||
stockContainer.classList.add('hidden');
|
||||
targetUserContainer.classList.add('hidden');
|
||||
userPcsContainer.classList.remove('hidden');
|
||||
labelStep2.textContent = '2. 반납 대상 사원 검색';
|
||||
} else if (this.currentFlowType === 'move') {
|
||||
stockContainer.classList.add('hidden');
|
||||
targetUserContainer.classList.remove('hidden');
|
||||
userPcsContainer.classList.remove('hidden');
|
||||
labelStep2.textContent = '2. 인계 사원 검색';
|
||||
}
|
||||
|
||||
// 2. Update summary panels on the right
|
||||
const summaryUserName = document.getElementById('summary-user-name')!;
|
||||
const summaryUserDept = document.getElementById('summary-user-dept')!;
|
||||
if (this.selectedUser) {
|
||||
summaryUserName.textContent = this.selectedUser.user_name;
|
||||
summaryUserDept.textContent = `${this.selectedUser.dept_name} / 사번: ${this.selectedUser.emp_no || '-'}`;
|
||||
} else {
|
||||
summaryUserName.textContent = '선택된 사원 없음';
|
||||
summaryUserDept.textContent = '-';
|
||||
}
|
||||
|
||||
const summaryTargetCard = document.getElementById('summary-target-user-card')!;
|
||||
const summaryTargetUserName = document.getElementById('summary-target-user-name')!;
|
||||
const summaryTargetUserDept = document.getElementById('summary-target-user-dept')!;
|
||||
if (this.currentFlowType === 'move') {
|
||||
summaryTargetCard.classList.remove('hidden');
|
||||
if (this.selectedTargetUser) {
|
||||
summaryTargetUserName.textContent = this.selectedTargetUser.user_name;
|
||||
summaryTargetUserDept.textContent = `${this.selectedTargetUser.dept_name} / 사번: ${this.selectedTargetUser.emp_no || '-'}`;
|
||||
} else {
|
||||
summaryTargetUserName.textContent = '선택된 사원 없음';
|
||||
summaryTargetUserDept.textContent = '-';
|
||||
}
|
||||
} else {
|
||||
summaryTargetCard.classList.add('hidden');
|
||||
}
|
||||
|
||||
const summaryPcCode = document.getElementById('summary-pc-code')!;
|
||||
const summaryPcModel = document.getElementById('summary-pc-model')!;
|
||||
if (this.selectedPC) {
|
||||
summaryPcCode.textContent = this.selectedPC.asset_code;
|
||||
summaryPcModel.textContent = `${this.selectedPC.model_name || '모델명 없음'} (${this.selectedPC.cpu || '-'} / ${this.selectedPC.ram || '-'})`;
|
||||
} else {
|
||||
summaryPcCode.textContent = '선택된 PC 없음';
|
||||
summaryPcModel.textContent = '-';
|
||||
}
|
||||
|
||||
// 3. Render user's active PCs list on the right (For Return & Move)
|
||||
const userPcsList = document.getElementById('user-pcs-list')!;
|
||||
if (this.selectedUser && (this.currentFlowType === 'return' || this.currentFlowType === 'move')) {
|
||||
const allPcs = state.masterData.pc || [];
|
||||
const userPcs = allPcs.filter((p: any) =>
|
||||
(p.emp_no && p.emp_no.toString() === this.selectedUser.emp_no?.toString()) ||
|
||||
(p.user_current && p.user_current === this.selectedUser.user_name)
|
||||
);
|
||||
|
||||
if (userPcs.length === 0) {
|
||||
userPcsList.innerHTML = '<div class="empty-list-message">이 사용자가 소유한 PC 자산이 없습니다.</div>';
|
||||
} else {
|
||||
userPcsList.innerHTML = userPcs.map(p => {
|
||||
const isSelected = this.selectedPC && this.selectedPC.id === p.id;
|
||||
return `
|
||||
<div class="user-pc-item ${isSelected ? 'selected' : ''}" data-id="${p.id}">
|
||||
<div class="pc-item-code">${p.asset_code}</div>
|
||||
<div class="pc-item-meta">
|
||||
${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// Bind clicks to list items
|
||||
userPcsList.querySelectorAll('.user-pc-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
const pcId = item.getAttribute('data-id');
|
||||
const foundPC = userPcs.find(p => p.id === pcId);
|
||||
if (foundPC) {
|
||||
this.selectedPC = foundPC;
|
||||
this.updateUI();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
userPcsList.innerHTML = '';
|
||||
}
|
||||
}
|
||||
|
||||
private renderHTML(): string {
|
||||
return `
|
||||
<div id="pc-flow-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title">
|
||||
<i data-lucide="refresh-cw"></i> PC 이동/반납 (불출/반납/이동)
|
||||
</h2>
|
||||
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="modal-body-split">
|
||||
<!-- 왼쪽 영역: 입력 폼 -->
|
||||
<div class="modal-form-area">
|
||||
<div class="grid-form flex-col">
|
||||
|
||||
<!-- 1. 처리 유형 -->
|
||||
<div class="form-group">
|
||||
<label>1. 처리 유형 선택</label>
|
||||
<div class="view-toggle w-full flex-row">
|
||||
<label class="flow-type-label toggle-btn active flex-1 text-center">
|
||||
<input type="radio" name="flow-type" value="checkout" checked class="hidden" />
|
||||
불출 (지급)
|
||||
</label>
|
||||
<label class="flow-type-label toggle-btn flex-1 text-center">
|
||||
<input type="radio" name="flow-type" value="return" class="hidden" />
|
||||
입고 (반납)
|
||||
</label>
|
||||
<label class="flow-type-label toggle-btn flex-1 text-center">
|
||||
<input type="radio" name="flow-type" value="move" class="hidden" />
|
||||
이동 (이관)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 대상 사용자 검색 -->
|
||||
<div class="form-group relative">
|
||||
<label id="user-search-label">2. 대상 사원 검색</label>
|
||||
<div class="input-with-icon">
|
||||
<input type="text" id="pc-flow-user-search" placeholder="사원명, 부서, 사번 검색..." />
|
||||
<i data-lucide="search" class="icon-sm"></i>
|
||||
</div>
|
||||
<div id="pc-flow-user-suggestions" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
|
||||
<div id="target-user-search-container" class="form-group hidden relative">
|
||||
<label>새 인수 사원 검색</label>
|
||||
<div class="input-with-icon">
|
||||
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." />
|
||||
<i data-lucide="search" class="icon-sm"></i>
|
||||
</div>
|
||||
<div id="pc-flow-target-user-suggestions" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 재고 PC 검색 (불출 시 노출) -->
|
||||
<div id="stock-pc-search-container" class="form-group relative">
|
||||
<label>3. 불출할 재고 PC 선택</label>
|
||||
<div class="input-with-icon">
|
||||
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." />
|
||||
<i data-lucide="monitor" class="icon-sm"></i>
|
||||
</div>
|
||||
<div id="pc-flow-stock-suggestions" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- 5. 상세 공통 입력 -->
|
||||
<div class="detail-grid-2col">
|
||||
<div class="form-group">
|
||||
<label>처리 일자</label>
|
||||
<input type="date" id="pc-flow-date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>상세 사유</label>
|
||||
<textarea id="pc-flow-details" rows="2" placeholder="미입력 시 기본 문구로 자동 입력됩니다."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽 영역: 선택 요약 & 사원 소유 자산 목록 -->
|
||||
<div class="modal-history-area">
|
||||
<div class="history-header">
|
||||
<h3>선택 내역 요약</h3>
|
||||
</div>
|
||||
|
||||
<div class="dynamic-row-container">
|
||||
<!-- 사원 요약 카드 -->
|
||||
<div id="summary-user-card" class="summary-info-card">
|
||||
<div class="detail-label-sm">대상 사원</div>
|
||||
<div id="summary-user-name" class="detail-value-lg">선택된 사원 없음</div>
|
||||
<div id="summary-user-dept" class="detail-label-sm">-</div>
|
||||
</div>
|
||||
|
||||
<!-- 인수 사원 요약 카드 (이동 전용) -->
|
||||
<div id="summary-target-user-card" class="summary-info-card hidden bg-primary-light">
|
||||
<div class="detail-label-sm">새 인수 사원</div>
|
||||
<div id="summary-target-user-name" class="detail-value-lg">선택된 사원 없음</div>
|
||||
<div id="summary-target-user-dept" class="detail-label-sm">-</div>
|
||||
</div>
|
||||
|
||||
<!-- 대상 PC 자산 요약 카드 -->
|
||||
<div id="summary-pc-card" class="summary-info-card">
|
||||
<div class="detail-label-sm">대상 PC 자산</div>
|
||||
<div id="summary-pc-code" class="detail-value-lg text-success">선택된 PC 없음</div>
|
||||
<div id="summary-pc-model" class="detail-label-sm">-</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 보유 PC 목록 선택 (반납/이동 시) -->
|
||||
<div id="user-pcs-container" class="form-group hidden">
|
||||
<label>사원 보유 PC 선택 (클릭하여 매핑)</label>
|
||||
<div id="user-pcs-list" class="user-pc-selection-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<div></div>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline">취소</button>
|
||||
<button id="btn-submit-pc-flow" class="btn btn-primary">이동/반납 처리 완료</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const pcFlowModal = PCFlowModal.getInstance();
|
||||
171
src/components/Modal/PartsMasterModal.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { state, savePartsMaster, deletePartsMaster } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
|
||||
import { createIcons, X, Save, Plus } from 'lucide';
|
||||
import { UI_TEXT } from '../../core/schema';
|
||||
|
||||
class PartsMasterModal extends BaseModal {
|
||||
constructor() {
|
||||
super('parts-master', '부품 표준 정보');
|
||||
}
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
return `
|
||||
<div id="parts-master-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content narrow">
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<h2 id="parts-master-modal-title" class="modal-title">${this.title}</h2>
|
||||
<div id="parts-master-header-identity" class="header-identity"></div>
|
||||
</div>
|
||||
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="parts-master-asset-form" class="grid-form vertical-form">
|
||||
<input type="hidden" id="parts-master-id" name="id" />
|
||||
|
||||
<div class="form-group">
|
||||
<label>부품 분류</label>
|
||||
<select id="parts-master-category" name="category">
|
||||
<option value="CPU">CPU</option>
|
||||
<option value="GPU">GPU</option>
|
||||
<option value="RAM">RAM</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>부품 표준 명칭</label>
|
||||
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>성능 등급</label>
|
||||
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>감점 점수 (양수로 입력)</label>
|
||||
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||
<button id="btn-cancel-parts-master-modal" class="btn btn-outline">닫기</button>
|
||||
<button id="btn-save-parts-master-asset" class="btn btn-primary">수정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
|
||||
const revertBtn = document.getElementById('btn-revert-parts-master-edit')!;
|
||||
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset) return;
|
||||
if (!this.isEditMode) {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const category = (document.getElementById('parts-master-category') as HTMLSelectElement).value;
|
||||
const compName = (document.getElementById('parts-master-component-name') as HTMLInputElement).value.trim();
|
||||
const tier = (document.getElementById('parts-master-score-tier') as HTMLInputElement).value.trim();
|
||||
const deductStr = (document.getElementById('parts-master-deduction') as HTMLInputElement).value;
|
||||
|
||||
if (!compName || !tier || deductStr === '') {
|
||||
alert('모든 필드를 올바르게 입력해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
id: this.currentAsset.id || null,
|
||||
category,
|
||||
component_name: compName,
|
||||
score_tier: tier,
|
||||
deduction: parseInt(deductStr, 10)
|
||||
};
|
||||
|
||||
if (await savePartsMaster(updated)) {
|
||||
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
revertBtn.addEventListener('click', () => {
|
||||
this.setEditLockMode('view');
|
||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset || !this.currentAsset.id) return;
|
||||
if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return;
|
||||
|
||||
if (await deletePartsMaster(Number(this.currentAsset.id))) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
createIcons({ icons: { Plus, X, Save } });
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
setFieldValue('parts-master-id', asset.id || '');
|
||||
setFieldValue('parts-master-category', asset.category || 'CPU');
|
||||
setFieldValue('parts-master-component-name', asset.component_name || '');
|
||||
setFieldValue('parts-master-score-tier', asset.score_tier || '');
|
||||
setFieldValue('parts-master-deduction', asset.deduction !== undefined ? asset.deduction.toString() : '0');
|
||||
this.updateHeaderIdentity(asset);
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
const titleEl = document.getElementById('parts-master-modal-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = (mode === 'add') ? '신규 부품 마스터 등록' : '부품 마스터 상세 편집';
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
|
||||
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
|
||||
|
||||
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
||||
|
||||
if (mode === 'add' || mode === 'edit') {
|
||||
saveBtn.textContent = (mode === 'add') ? '등록' : '저장';
|
||||
saveBtn.style.display = 'block';
|
||||
} else {
|
||||
saveBtn.textContent = '수정';
|
||||
saveBtn.style.display = 'block';
|
||||
}
|
||||
this.updateHeaderIdentity(asset);
|
||||
}
|
||||
|
||||
private updateHeaderIdentity(asset: any) {
|
||||
const container = document.getElementById('parts-master-header-identity');
|
||||
if (!container) return;
|
||||
|
||||
if (this.currentMode === 'add') {
|
||||
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const cat = asset.category || '';
|
||||
const name = asset.component_name || '';
|
||||
|
||||
container.innerHTML = `
|
||||
<span class="asset-code-title">${name}</span>
|
||||
<span class="service-type-badge">${cat}</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const partsMasterModal = new PartsMasterModal();
|
||||
export function initPartsMasterModal(onSave: () => void, closeModals: () => void) { partsMasterModal.init(onSave, closeModals); }
|
||||
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { partsMasterModal.open(asset, mode); }
|
||||
@@ -1,14 +1,14 @@
|
||||
import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { openSwUserModal } from './SWUserModal';
|
||||
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Calendar, Users } from 'lucide';
|
||||
import { createIcons, History, Plus, X, Save, RotateCcw, Calendar, Users } from 'lucide';
|
||||
import { CORP_LIST } from './SharedData';
|
||||
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
||||
import { API_BASE_URL } from '../../core/utils';
|
||||
import {
|
||||
generateOptionsHTML,
|
||||
setFieldValue,
|
||||
getFieldValue,
|
||||
import {
|
||||
generateOptionsHTML,
|
||||
setFieldValue,
|
||||
getFieldValue,
|
||||
applyDateMask
|
||||
} from './ModalUtils';
|
||||
|
||||
@@ -22,15 +22,18 @@ class SwAssetModal extends BaseModal {
|
||||
<div id="sw-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="sw-modal-title">${this.title}</h2>
|
||||
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||
<div class="header-left">
|
||||
<h2 id="sw-modal-title" class="modal-title">${this.title}</h2>
|
||||
<div id="sw-header-identity" class="header-identity"></div>
|
||||
</div>
|
||||
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-body-split">
|
||||
<div class="modal-form-area">
|
||||
<form id="sw-asset-form" class="grid-form">
|
||||
<input type="hidden" id="sw-asset-id" name="id" />
|
||||
|
||||
|
||||
<div class="form-section-title">기본 정보 (Identity)</div>
|
||||
<div class="form-group">
|
||||
<label>자산 유형</label>
|
||||
@@ -81,7 +84,7 @@ class SwAssetModal extends BaseModal {
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
|
||||
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
|
||||
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
|
||||
</div>
|
||||
|
||||
<div class="form-group cloud-only">
|
||||
@@ -100,12 +103,12 @@ class SwAssetModal extends BaseModal {
|
||||
<div class="form-section-title">관리 및 비고</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="sw-구매일" name="purchase_date" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();">
|
||||
<i data-lucide="calendar"></i>
|
||||
</button>
|
||||
<input type="date" id="sw-구매일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
|
||||
<input type="date" id="sw-구매일-picker" class="hidden-picker" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
@@ -126,12 +129,12 @@ class SwAssetModal extends BaseModal {
|
||||
</div>
|
||||
<div class="form-group sw-standard-field" id="sw-expiry-group">
|
||||
<label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="sw-만료일" name="expiry_date" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();">
|
||||
<i data-lucide="calendar"></i>
|
||||
</button>
|
||||
<input type="date" id="sw-만료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
|
||||
<input type="date" id="sw-만료일-picker" class="hidden-picker" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
@@ -140,18 +143,18 @@ class SwAssetModal extends BaseModal {
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
|
||||
<div id="sw-user-section" class="user-management-section">
|
||||
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
|
||||
<i data-lucide="users" style="width:16px; height:16px; margin-right:4px;"></i> 사용자 관리
|
||||
<i data-lucide="users"></i> 사용자 관리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-history-area">
|
||||
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
|
||||
<div class="history-header">
|
||||
<h3><i data-lucide="history"></i> 업데이트 내역</h3>
|
||||
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
|
||||
계약 업데이트 <i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i>
|
||||
계약 업데이트 <i data-lucide="rotate-ccw"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sw-history-list" class="history-timeline"></div>
|
||||
@@ -170,24 +173,24 @@ class SwAssetModal extends BaseModal {
|
||||
</div>
|
||||
|
||||
<!-- 계약 업데이트 서브 모달 -->
|
||||
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div id="sw-update-modal" class="modal-overlay hidden sub-modal">
|
||||
<div class="modal-content narrow">
|
||||
<div class="modal-header">
|
||||
<h2>계약 업데이트 반영</h2>
|
||||
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
|
||||
<h2 class="modal-title">계약 업데이트 반영</h2>
|
||||
<button id="btn-close-sw-update" class="btn-icon">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid-form" style="grid-template-columns: 1fr;">
|
||||
<div class="grid-form vertical-form">
|
||||
<div class="form-group">
|
||||
<label>업데이트 일자</label>
|
||||
<input type="date" id="sw-update-date" />
|
||||
</div>
|
||||
<div class="form-group sub-sw-update">
|
||||
<label>새로운 계약 기간</label>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" />
|
||||
<span>~</span>
|
||||
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
||||
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -209,6 +212,15 @@ class SwAssetModal extends BaseModal {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.hidden-picker {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -231,7 +243,6 @@ class SwAssetModal extends BaseModal {
|
||||
if (this.currentAsset) openSwUserModal(this.currentAsset);
|
||||
});
|
||||
|
||||
// 업데이트 모달 로직
|
||||
const subModal = document.getElementById('sw-update-modal')!;
|
||||
const closeUpdate = () => subModal.classList.add('hidden');
|
||||
document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate);
|
||||
@@ -278,7 +289,7 @@ class SwAssetModal extends BaseModal {
|
||||
const formData = new FormData(this.formEl!);
|
||||
const updated = { ...this.currentAsset };
|
||||
formData.forEach((value, key) => { updated[key] = value; });
|
||||
|
||||
|
||||
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
|
||||
if (await saveAsset(categoryKey, updated)) { onSave(); this.close(); closeModals(); }
|
||||
});
|
||||
@@ -322,10 +333,32 @@ class SwAssetModal extends BaseModal {
|
||||
}
|
||||
|
||||
this.renderHistory(asset.id);
|
||||
this.updateHeaderIdentity(asset);
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
this.applySwTypeUI(asset.asset_type || asset.type);
|
||||
this.updateHeaderIdentity(asset);
|
||||
}
|
||||
|
||||
private updateHeaderIdentity(asset: any) {
|
||||
const container = document.getElementById('sw-header-identity');
|
||||
if (!container) return;
|
||||
|
||||
if (this.currentMode === 'add') {
|
||||
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const type = getFieldValue('sw-asset-type') || asset.asset_type || asset.type || '';
|
||||
const name = getFieldValue('sw-제품명') || asset.product_name || '';
|
||||
const corp = getFieldValue('sw-법인') || asset.purchase_corp || '';
|
||||
|
||||
container.innerHTML = `
|
||||
<span class="asset-code-title">${name}</span>
|
||||
<span class="service-type-badge">${corp}</span>
|
||||
<span class="asset-type-label">${type}</span>
|
||||
`;
|
||||
}
|
||||
|
||||
private applySwTypeUI(type: string) {
|
||||
@@ -354,18 +387,12 @@ class SwAssetModal extends BaseModal {
|
||||
private renderHistory(swId: string) {
|
||||
const container = document.getElementById('sw-history-list');
|
||||
if (!container) return;
|
||||
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
|
||||
const logs = (state.masterData.logs || []).filter(l => l.asset_id === swId);
|
||||
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
|
||||
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
|
||||
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
export const swModal = new SwAssetModal();
|
||||
|
||||
export function initSwModal(onSave: () => void, closeModals: () => void) {
|
||||
swModal.init(onSave, closeModals);
|
||||
}
|
||||
|
||||
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') {
|
||||
swModal.open(asset, mode);
|
||||
}
|
||||
export function initSwModal(onSave: () => void, closeModals: () => void) { swModal.init(onSave, closeModals); }
|
||||
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') { swModal.open(asset, mode); }
|
||||
|
||||
@@ -16,15 +16,15 @@ class SwUserModal extends BaseModal {
|
||||
<div id="sw-user-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="sw-user-title">${this.title}</h2>
|
||||
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
|
||||
<h2 id="sw-user-title" class="modal-title">${this.title}</h2>
|
||||
<button id="btn-close-sw-user-modal" class="btn-icon" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="sw-info-summary" id="sw-user-sw-info"></div>
|
||||
|
||||
<div class="user-list-toolbar" style="display:flex; justify-content:space-between; margin-bottom:1rem; align-items:center;">
|
||||
<h3 style="font-size:1rem; font-weight:600;">할당된 사용자 목록</h3>
|
||||
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
|
||||
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="detail-section-title mb-0">할당된 사용자 목록</h3>
|
||||
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus" class="icon-sm"></i> 사용자 추가</button>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
@@ -35,9 +35,9 @@ class SwUserModal extends BaseModal {
|
||||
<th>부서</th>
|
||||
<th>직위</th>
|
||||
<th>이름</th>
|
||||
<th>사용기간</th>
|
||||
<th>신청서</th>
|
||||
<th>관리</th>
|
||||
<th class="text-center">사용기간</th>
|
||||
<th class="text-center">신청서</th>
|
||||
<th class="text-center">관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sw-user-table-body"></tbody>
|
||||
@@ -54,14 +54,14 @@ class SwUserModal extends BaseModal {
|
||||
</div>
|
||||
|
||||
<!-- 사용자 추가/수정 서브 모달 -->
|
||||
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index: 1100;">
|
||||
<div class="modal-content" style="width: 400px;">
|
||||
<div id="sw-user-edit-modal" class="modal-overlay hidden sub-modal">
|
||||
<div class="modal-content narrow">
|
||||
<div class="modal-header">
|
||||
<h3 id="sw-user-edit-title">사용자 정보</h3>
|
||||
<button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
|
||||
<h3 id="sw-user-edit-title" class="modal-title">사용자 정보</h3>
|
||||
<button id="btn-close-user-edit" class="btn-icon">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="sw-user-edit-form" class="grid-form" style="grid-template-columns: 1fr;">
|
||||
<form id="sw-user-edit-form" class="grid-form vertical-form">
|
||||
<input type="hidden" id="edit-user-index" value="-1" />
|
||||
<div class="form-group">
|
||||
<label>조직</label>
|
||||
@@ -81,22 +81,22 @@ class SwUserModal extends BaseModal {
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>사용 시작일</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="new-user-시작일" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="new-user-시작일" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();">
|
||||
<i data-lucide="calendar" class="icon-sm"></i>
|
||||
</button>
|
||||
<input type="date" id="new-user-시작일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
|
||||
<input type="date" id="new-user-시작일-picker" class="hidden-picker" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>사용 종료일</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="new-user-종료일" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
<div class="input-with-btn">
|
||||
<input type="text" id="new-user-종료일" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();">
|
||||
<i data-lucide="calendar" class="icon-sm"></i>
|
||||
</button>
|
||||
<input type="date" id="new-user-종료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
|
||||
<input type="date" id="new-user-종료일-picker" class="hidden-picker" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -111,6 +111,15 @@ class SwUserModal extends BaseModal {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>
|
||||
.hidden-picker {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -140,10 +149,9 @@ class SwUserModal extends BaseModal {
|
||||
onSave(); this.close(); closeModals();
|
||||
});
|
||||
|
||||
// 닫기 이벤트들 (BaseModal의 공통 버튼 외 추가분)
|
||||
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
|
||||
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
|
||||
|
||||
|
||||
const subModal = document.getElementById('sw-user-edit-modal')!;
|
||||
const closeSub = () => subModal.classList.add('hidden');
|
||||
document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub);
|
||||
@@ -155,9 +163,9 @@ class SwUserModal extends BaseModal {
|
||||
protected fillFormData(asset: any): void {
|
||||
const swInfo = document.getElementById('sw-user-sw-info')!;
|
||||
swInfo.innerHTML = `
|
||||
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
|
||||
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.purchase_corp || asset.법인 || ''}</div>
|
||||
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.product_name || asset.제품명 || ''}</div>
|
||||
<div class="sw-info-header border-b border-hairline pb-4 mb-6">
|
||||
<div class="detail-label-sm">${asset.purchase_corp || asset.법인 || ''}</div>
|
||||
<div class="asset-code-title">${asset.product_name || asset.제품명 || ''}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
@@ -165,7 +173,7 @@ class SwUserModal extends BaseModal {
|
||||
this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
|
||||
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
|
||||
})) : [];
|
||||
|
||||
|
||||
this.renderUserList();
|
||||
}
|
||||
|
||||
@@ -173,9 +181,10 @@ class SwUserModal extends BaseModal {
|
||||
|
||||
private renderUserList() {
|
||||
const tbody = document.getElementById('sw-user-table-body')!;
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = '';
|
||||
if (this.tempSwUsers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell text-center p-8">할당된 사용자가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -186,12 +195,12 @@ class SwUserModal extends BaseModal {
|
||||
<td>${user.부서 || ''}</td>
|
||||
<td>${user.직위 || ''}</td>
|
||||
<td>${user.이름 || ''}</td>
|
||||
<td>${user.사용기간 || ''}</td>
|
||||
<td style="text-align:center;">${user.신청서명 ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
|
||||
<td>
|
||||
<div style="display:flex; gap:0.5rem;">
|
||||
<td class="text-center">${user.사용기간 || ''}</td>
|
||||
<td class="text-center">${user.신청서명 ? '<i data-lucide="paperclip" class="text-primary icon-sm"></i>' : '-'}</td>
|
||||
<td class="text-center">
|
||||
<div class="flex gap-2 justify-center items-center">
|
||||
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
|
||||
<button class="btn btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button>
|
||||
<button class="btn-circle-remove btn-del-user" data-idx="${idx}">×</button>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
@@ -257,11 +266,5 @@ class SwUserModal extends BaseModal {
|
||||
}
|
||||
|
||||
export const swUserModal = new SwUserModal();
|
||||
|
||||
export function initSwUserModal(onSave: () => void, closeModals: () => void) {
|
||||
swUserModal.init(onSave, closeModals);
|
||||
}
|
||||
|
||||
export function openSwUserModal(asset: any) {
|
||||
swUserModal.open(asset);
|
||||
}
|
||||
export function initSwUserModal(onSave: () => void, closeModals: () => void) { swUserModal.init(onSave, closeModals); }
|
||||
export function openSwUserModal(asset: any) { swUserModal.open(asset); }
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
// 구매법인 목록
|
||||
export const CORP_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '바론'];
|
||||
export const CORP_LIST = ['한맥', '삼안', 'PTC', '바론'];
|
||||
|
||||
// 사용조직 목록
|
||||
export const ORG_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '기술개발센터', '총괄기획실'];
|
||||
@@ -13,15 +13,15 @@ export const HW_STATUS_LIST = ['운영', '재고', '수리', '폐기', '기타']
|
||||
|
||||
// 구분(Category) -> 유형(Asset Type) 관계 정의 (통합 관리)
|
||||
export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
|
||||
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', 'NAS', 'DAS', '서버PC', '스토리지 렉'],
|
||||
'서버': ['서버 렉', '가상서버(VM)', '워크스테이션', '저장시스템_렉(NAS)', '저장시스템_렉(DAS)', '저장시스템_미니(NAS)', '저장시스템_미니(DAS)'],
|
||||
'PC': ['개인PC', '노트북', '공용PC', '서버PC'],
|
||||
'스토리지': ['SSD', 'HDD', '외장HDD'],
|
||||
'저장매체': ['SSD', 'HDD', '외장HDD'],
|
||||
'네트워크': ['스위치', '허브', '방화벽', '라우터', '공유기', '허브'],
|
||||
'PC부품': ['CPU', 'RAM', 'GPU', 'SSD', 'HDD', 'RAM', '모니터'],
|
||||
'공간정보장비': ['드론', '측량장비', '보조기기'],
|
||||
'업무지원장비': ['카메라', '스피커', 'TV', '모바일', '유선전화기', 'XR', '프린터', '전산소모품'],
|
||||
'외부': ['영구', '구독'],
|
||||
'내부': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
|
||||
'외부SW': ['영구', '구독'],
|
||||
'내부SW': ['판매용', 'Solutions', 'Inhouse', 'Engine&Module'],
|
||||
'비용관리': ['클라우드', '도메인', '전화', '인터넷', '이메일'],
|
||||
'내빈/외빈': ['선물'],
|
||||
'시설자산': ['사무가구']
|
||||
@@ -30,7 +30,7 @@ export const CATEGORY_TYPE_MAP: Record<string, string[]> = {
|
||||
// 설치위치 종속성 데이터
|
||||
export const LOCATION_DATA: Record<string, string[]> = {
|
||||
'한맥빌딩': ['MDF실', '1층', '2층', '3층', '4층', '5층', '6층', '7층', '파고라'],
|
||||
'기술개발센터': ['서버실', 'BLUE ZONE', 'GREEN ZONE', 'ORANGE ZONE', '회의실2', '회의실3', '회의실5', '회의실6', '회의실7', '사이니지룸'],
|
||||
'기술개발센터': ['서버실', '센터내부'],
|
||||
'유니온빌딩': ['4층', '5층', '6층'],
|
||||
'뉴코아빌딩': ['4층', '6층', '7층'],
|
||||
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
|
||||
@@ -38,10 +38,12 @@ export const LOCATION_DATA: Record<string, string[]> = {
|
||||
|
||||
// 유형별 자산번호 접두사(Prefix) 매핑
|
||||
export const TYPE_PREFIX_MAP: Record<string, string> = {
|
||||
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO',
|
||||
'HDD': 'HDD', 'SSD': 'SSD', '노트북': 'NBK', '태블릿': 'TAB',
|
||||
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC',
|
||||
'저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)': 'DSS',
|
||||
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
|
||||
'노트북': 'NBK', '태블릿': 'TAB',
|
||||
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
|
||||
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'INT'
|
||||
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT'
|
||||
};
|
||||
|
||||
// 배치도 이미지 매핑 데이터
|
||||
@@ -58,10 +60,17 @@ export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = {
|
||||
'서버실': [
|
||||
'img/location_photo/기술개발센터/서버실/서버실_1.png',
|
||||
'img/location_photo/기술개발센터/서버실/서버실_2.png'
|
||||
]
|
||||
],
|
||||
'센터내부': ['img/location_photo/기술개발센터/센터내부/센터내부.png']
|
||||
},
|
||||
'한맥빌딩': {
|
||||
'7층': ['img/location_photo/한맥빌딩/7층_로비.png'],
|
||||
'1층': ['img/location_photo/한맥빌딩/1층.png'],
|
||||
'2층': ['img/location_photo/한맥빌딩/2층.png'],
|
||||
'3층': ['img/location_photo/한맥빌딩/3층.png'],
|
||||
'4층': ['img/location_photo/한맥빌딩/4층.png'],
|
||||
'5층': ['img/location_photo/한맥빌딩/5층.png'],
|
||||
'6층': ['img/location_photo/한맥빌딩/6층.png'],
|
||||
'7층': ['img/location_photo/한맥빌딩/7층.png'],
|
||||
'MDF실': [
|
||||
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
|
||||
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
|
||||
|
||||
180
src/components/Modal/UserModal.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import { state, saveSystemUser, deleteSystemUser } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { setFieldValue } from './ModalUtils';
|
||||
import { createIcons, X, Save } from 'lucide';
|
||||
import { UI_TEXT } from '../../core/schema';
|
||||
|
||||
class UserModal extends BaseModal {
|
||||
constructor() {
|
||||
super('user', '임직원 정보');
|
||||
}
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
return `
|
||||
<div id="user-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content narrow">
|
||||
<div class="modal-header">
|
||||
<div class="header-left">
|
||||
<h2 id="user-modal-title" class="modal-title">${this.title}</h2>
|
||||
<div id="user-header-identity" class="header-identity"></div>
|
||||
</div>
|
||||
<button id="btn-close-user-modal" class="btn-icon" aria-label="닫기">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="user-asset-form" class="grid-form vertical-form">
|
||||
<input type="hidden" id="user-id" name="id" />
|
||||
|
||||
<div class="form-group">
|
||||
<label>사번</label>
|
||||
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>사용자명</label>
|
||||
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>사용조직 (부서)</label>
|
||||
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>직무 (직급)</label>
|
||||
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required />
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>상태</label>
|
||||
<select id="user-status" name="status">
|
||||
<option value="재직">재직</option>
|
||||
<option value="퇴직">퇴직</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-revert-user-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||
<button id="btn-cancel-user-modal" class="btn btn-outline">닫기</button>
|
||||
<button id="btn-save-user-asset" class="btn btn-primary">수정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||
const saveBtn = document.getElementById('btn-save-user-asset')!;
|
||||
const revertBtn = document.getElementById('btn-revert-user-edit')!;
|
||||
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset) return;
|
||||
if (!this.isEditMode) {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const empNo = (document.getElementById('user-emp-no') as HTMLInputElement).value.trim();
|
||||
const userName = (document.getElementById('user-name-input') as HTMLInputElement).value.trim();
|
||||
const deptName = (document.getElementById('user-dept') as HTMLInputElement).value.trim();
|
||||
const position = (document.getElementById('user-position-input') as HTMLInputElement).value.trim();
|
||||
const status = (document.getElementById('user-status') as HTMLSelectElement).value;
|
||||
|
||||
if (!empNo || !userName || !deptName || !position) {
|
||||
alert('모든 필수 입력 필드를 채워주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = {
|
||||
id: this.currentAsset.id || null,
|
||||
emp_no: empNo,
|
||||
user_name: userName,
|
||||
dept_name: deptName,
|
||||
position: position,
|
||||
status: status
|
||||
};
|
||||
|
||||
if (await saveSystemUser(updated)) {
|
||||
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
revertBtn.addEventListener('click', () => {
|
||||
this.setEditLockMode('view');
|
||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset || !this.currentAsset.id) return;
|
||||
if (!confirm('정말로 이 임직원 정보를 삭제하시겠습니까?')) return;
|
||||
|
||||
if (await deleteSystemUser(this.currentAsset.id)) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
createIcons({ icons: { Save, X } });
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
setFieldValue('user-id', asset.id || '');
|
||||
setFieldValue('user-emp-no', asset.emp_no || '');
|
||||
setFieldValue('user-name-input', asset.user_name || '');
|
||||
setFieldValue('user-dept', asset.dept_name || '');
|
||||
setFieldValue('user-position-input', asset.position || '');
|
||||
setFieldValue('user-status', asset.status || '재직');
|
||||
this.updateHeaderIdentity(asset);
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
const titleEl = document.getElementById('user-modal-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = (mode === 'add') ? '신규 임직원 등록' : '임직원 정보 수정';
|
||||
}
|
||||
|
||||
const deleteBtn = document.getElementById('btn-delete-user-asset')!;
|
||||
const saveBtn = document.getElementById('btn-save-user-asset')!;
|
||||
|
||||
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
||||
|
||||
if (mode === 'add' || mode === 'edit') {
|
||||
saveBtn.textContent = mode === 'add' ? '등록' : '저장';
|
||||
saveBtn.style.display = 'block';
|
||||
} else {
|
||||
saveBtn.textContent = '수정';
|
||||
saveBtn.style.display = 'block';
|
||||
}
|
||||
this.updateHeaderIdentity(asset);
|
||||
}
|
||||
|
||||
private updateHeaderIdentity(asset: any) {
|
||||
const container = document.getElementById('user-header-identity');
|
||||
if (!container) return;
|
||||
|
||||
if (this.currentMode === 'add') {
|
||||
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
const empNo = asset.emp_no || '';
|
||||
const userName = asset.user_name || '';
|
||||
const dept = asset.dept_name || '';
|
||||
|
||||
container.innerHTML = `
|
||||
<span class="asset-code-title">${userName}</span>
|
||||
<span class="service-type-badge">${empNo}</span>
|
||||
<span class="asset-type-label">${dept}</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const userModal = new UserModal();
|
||||
export function initUserModal(onSave: () => void, closeModals: () => void) { userModal.init(onSave, closeModals); }
|
||||
export function openUserModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { userModal.open(asset, mode); }
|
||||
594
src/components/Modal/modal.css
Normal file
@@ -0,0 +1,594 @@
|
||||
/* Modal - Vercel Inspired Minimalist Design */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.modal-overlay:not(.hidden) { opacity: 1; visibility: visible; }
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--canvas);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(10px);
|
||||
transition: transform 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.modal-overlay.sub-modal {
|
||||
z-index: 1100;
|
||||
}
|
||||
|
||||
|
||||
.modal-header {
|
||||
background-color: var(--canvas);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding: 1rem var(--spacing-base);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.modal-header .btn-icon {
|
||||
color: var(--mute) !important;
|
||||
cursor: pointer;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.form-group.relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 동적 리스트 컨테이너 */
|
||||
.dynamic-row-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.volume-row, .remote-info-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.remote-info-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 파일 업로드 디스플레이 */
|
||||
.file-upload-display {
|
||||
flex: 1;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
height: clamp(34px, 4.5vmin, 44px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--mute);
|
||||
background-color: var(--canvas-soft);
|
||||
}
|
||||
|
||||
.btn-circle-remove {
|
||||
width: clamp(34px, 4.5vmin, 44px);
|
||||
height: clamp(34px, 4.5vmin, 44px);
|
||||
border-radius: 50% !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 !important;
|
||||
color: var(--danger) !important;
|
||||
border: 1px solid var(--danger) !important;
|
||||
background: transparent;
|
||||
font-size: 1.5rem !important; /* Larger X icon */
|
||||
line-height: 1;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-circle-remove:hover {
|
||||
background-color: var(--danger);
|
||||
color: var(--white) !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-base);
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.grid-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-base);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group.full-width {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* Section Title for Grouping */
|
||||
.form-section-title {
|
||||
grid-column: span 2;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--mute);
|
||||
padding: 1rem 0 0.5rem 0;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.form-section-title:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
height: clamp(34px, 4.5vmin, 44px);
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: var(--fs-sm);
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
background-color: var(--canvas);
|
||||
color: var(--primary);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group input.font-mono,
|
||||
.form-group select.font-mono {
|
||||
font-family: var(--font-mono);
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
height: auto;
|
||||
padding: 0.75rem;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 0 0 1px var(--primary);
|
||||
}
|
||||
|
||||
.form-group input:disabled,
|
||||
.form-group select:disabled {
|
||||
background-color: var(--canvas-soft);
|
||||
color: var(--mute);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--hairline);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--canvas-soft);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-footer .btn {
|
||||
height: 36px;
|
||||
padding: 0 1.25rem;
|
||||
font-size: var(--fs-xs);
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
/* Modal Size Variants */
|
||||
.modal-content.wide {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
.modal-content.narrow {
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.vertical-form {
|
||||
grid-template-columns: 1fr !important;
|
||||
}
|
||||
|
||||
|
||||
.modal-body-split {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.modal-form-area {
|
||||
flex: 1.2;
|
||||
}
|
||||
|
||||
.modal-history-area {
|
||||
flex: 0.8;
|
||||
border-left: 1px solid var(--hairline);
|
||||
padding-left: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.history-header h3 {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* 읽기 전용 필드 (자산번호 등) 통일 스타일 */
|
||||
.is-readonly-field {
|
||||
border-color: transparent !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: none !important;
|
||||
color: var(--primary) !important;
|
||||
font-weight: 600 !important;
|
||||
cursor: default;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
/* 조회 모드 (View Mode) 폼 필드 스타일 */
|
||||
.is-view-mode input,
|
||||
.is-view-mode select,
|
||||
.is-view-mode textarea {
|
||||
border-color: transparent !important;
|
||||
background-color: transparent !important;
|
||||
color: var(--primary) !important;
|
||||
font-weight: 600 !important;
|
||||
padding-left: 0 !important;
|
||||
-webkit-appearance: none !important;
|
||||
-moz-appearance: none !important;
|
||||
appearance: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 입력 필드 + 버튼 그룹 */
|
||||
.input-with-btn {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-with-btn input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Remote Info UI Extras */
|
||||
.ri-creds-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ri-field-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: var(--canvas-soft);
|
||||
padding: 0 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--hairline);
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.is-view-mode .ri-field-group {
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.ri-mini-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
color: var(--mute);
|
||||
text-transform: uppercase;
|
||||
user-select: none;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ri-field-group input {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
padding: 0 !important;
|
||||
height: 36px !important;
|
||||
box-shadow: none !important;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.ri-line {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.ri-connector {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-left: 2px solid var(--hairline);
|
||||
border-bottom: 2px solid var(--hairline);
|
||||
margin-left: 12px;
|
||||
margin-top: -12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.history-timeline {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding-right: 12px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
padding-bottom: 24px;
|
||||
border-left: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-left: 1px solid transparent;
|
||||
}
|
||||
|
||||
.history-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -5px;
|
||||
top: 0;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--canvas);
|
||||
border: 2px solid var(--primary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--mute);
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.history-details {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--primary);
|
||||
line-height: 1.6;
|
||||
background: var(--canvas-soft-2);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
/* Upload UI Refinement */
|
||||
.upload-sidebar {
|
||||
width: 260px;
|
||||
border-right: 1px solid var(--hairline);
|
||||
background-color: var(--canvas-soft);
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.upload-tab-btn {
|
||||
padding: 0.8rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 500;
|
||||
color: var(--body);
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.upload-tab-btn.active {
|
||||
background-color: var(--canvas);
|
||||
color: var(--primary);
|
||||
border-color: var(--hairline);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Image Picker Overlay (View Location) --- */
|
||||
.image-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.image-picker-window {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
height: 85vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--canvas);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.image-picker-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
background: var(--canvas);
|
||||
color: var(--primary);
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.image-picker-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.image-picker-header .btn-icon {
|
||||
color: var(--mute) !important;
|
||||
cursor: pointer;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.image-picker-content {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
background: var(--canvas-soft);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
|
||||
.image-picker-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--canvas);
|
||||
border-radius: 0 0 8px 8px;
|
||||
border-top: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.layout-map-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: crosshair;
|
||||
background-color: var(--canvas-soft-2);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-map-container.readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.image-marker-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.layout-map-img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
|
||||
.layout-marker {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background-color: var(--danger);
|
||||
border: 2px solid white;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
.pulse-marker::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
width: 100%; height: 100%;
|
||||
background-color: var(--danger);
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
animation: pulse 1.5s infinite;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: translate(-50%, -50%) scale(1); opacity: 0.8; }
|
||||
100% { transform: translate(-50%, -50%) scale(3); opacity: 0; }
|
||||
}
|
||||
|
||||
.digital-overlay-layer {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -3,15 +3,15 @@ import { state } from '../core/state';
|
||||
const MENU_CONFIG: any = {
|
||||
hw: {
|
||||
label: '하드웨어',
|
||||
tabs: ['서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비']
|
||||
tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '부품 마스터', '네트워크', '업무지원장비']
|
||||
},
|
||||
sw: {
|
||||
label: '소프트웨어',
|
||||
tabs: ['외부', '내부']
|
||||
tabs: ['외부SW', '내부SW']
|
||||
},
|
||||
ops: {
|
||||
label: '운영지원',
|
||||
tabs: ['클라우드', '도메인', '비용관리']
|
||||
tabs: ['클라우드', '도메인', '비용관리', '사용자']
|
||||
},
|
||||
vip: {
|
||||
label: '내빈/외빈',
|
||||
@@ -24,73 +24,96 @@ const MENU_CONFIG: any = {
|
||||
};
|
||||
|
||||
export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
const navContainer = document.getElementById('main-nav')!;
|
||||
const header = document.querySelector('.main-header') as HTMLElement;
|
||||
const headerContainer = document.querySelector('.header-container')!;
|
||||
if (!headerContainer) return;
|
||||
|
||||
const render = () => {
|
||||
navContainer.innerHTML = '';
|
||||
// 1. 헤더 구조 (Vercel Style: Clean Single Row)
|
||||
headerContainer.innerHTML = `
|
||||
<div class="brand" id="btn-home-logo" style="cursor: pointer;">
|
||||
<img src="img/image_92.png" class="main-logo" alt="HM Logo" />
|
||||
<h1>한맥자산관리시스템</h1>
|
||||
</div>
|
||||
|
||||
<nav class="integrated-nav" id="main-nav-list"></nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<div class="role-toggle-wrapper">
|
||||
<span class="role-label user ${state.currentUserRole === 'user' ? 'active' : ''}">실무자</span>
|
||||
<label class="role-toggle">
|
||||
<input type="checkbox" id="role-toggle-checkbox" ${state.currentUserRole === 'admin' ? 'checked' : ''}>
|
||||
<span class="role-slider"></span>
|
||||
</label>
|
||||
<span class="role-label admin ${state.currentUserRole === 'admin' ? 'active' : ''}">관리자</span>
|
||||
</div>
|
||||
<div class="notification-area">
|
||||
<button class="icon-btn" title="알림"><i data-lucide="bell" style="width:18px; height:18px;"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const navList = document.getElementById('main-nav-list')!;
|
||||
|
||||
// 기존 메뉴 렌더링
|
||||
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
|
||||
// 2. GNB 메뉴 렌더링 (Ghost Tab Style)
|
||||
Object.keys(MENU_CONFIG).forEach(catKey => {
|
||||
const config = MENU_CONFIG[catKey];
|
||||
const isActive = state.activeCategory === catKey;
|
||||
|
||||
const group = document.createElement('div');
|
||||
group.className = `nav-group ${isActive ? 'active is-showing-shelf' : ''}`;
|
||||
|
||||
const trigger = document.createElement('div');
|
||||
trigger.className = 'gnb-trigger';
|
||||
trigger.textContent = config.label;
|
||||
|
||||
trigger.addEventListener('click', () => {
|
||||
if (state.activeCategory !== catKey) {
|
||||
state.activeCategory = catKey as any;
|
||||
const firstTab = config.tabs[0];
|
||||
state.activeSubTab = firstTab;
|
||||
render();
|
||||
onTabChange(firstTab);
|
||||
}
|
||||
const visibleTabs = config.tabs.filter((tab: string) => {
|
||||
if (state.currentUserRole === 'admin') return tab === '대시보드';
|
||||
return tab !== '대시보드';
|
||||
});
|
||||
group.appendChild(trigger);
|
||||
|
||||
const shelf = document.createElement('div');
|
||||
shelf.className = 'lnb-shelf';
|
||||
|
||||
config.tabs.forEach((tab: string) => {
|
||||
if (visibleTabs.length === 0) return;
|
||||
|
||||
visibleTabs.forEach((tab: string) => {
|
||||
if (tab === '부품 마스터') return;
|
||||
const item = document.createElement('div');
|
||||
item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`;
|
||||
const isActive = state.activeSubTab === tab;
|
||||
item.className = `gnb-trigger ${isActive ? 'active' : ''}`;
|
||||
item.textContent = tab;
|
||||
item.style.fontSize = 'var(--fs-sm)'; // Ensure small but standard font
|
||||
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
state.activeCategory = catKey as any;
|
||||
state.activeSubTab = tab;
|
||||
render();
|
||||
render();
|
||||
onTabChange(tab);
|
||||
});
|
||||
shelf.appendChild(item);
|
||||
navList.appendChild(item);
|
||||
});
|
||||
group.appendChild(shelf);
|
||||
navContainer.appendChild(group);
|
||||
});
|
||||
|
||||
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일) ───
|
||||
const adminGroup = document.createElement('div');
|
||||
adminGroup.className = 'nav-group';
|
||||
// 3. 관리자 전용 '관리도구'
|
||||
if (state.currentUserRole === 'admin') {
|
||||
const adminTrigger = document.createElement('div');
|
||||
adminTrigger.className = 'gnb-trigger admin-trigger';
|
||||
adminTrigger.innerHTML = '관리도구';
|
||||
adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank'));
|
||||
navList.appendChild(adminTrigger);
|
||||
}
|
||||
|
||||
// 4. 이벤트 바인딩
|
||||
document.getElementById('btn-home-logo')?.addEventListener('click', () => location.reload());
|
||||
|
||||
const adminTrigger = document.createElement('div');
|
||||
adminTrigger.className = 'gnb-trigger';
|
||||
adminTrigger.innerHTML = '관리자';
|
||||
adminTrigger.style.color = 'var(--text-muted)';
|
||||
adminTrigger.style.borderLeft = '1px solid var(--border-color)';
|
||||
adminTrigger.style.marginLeft = '1rem';
|
||||
adminTrigger.style.paddingLeft = '1.5rem';
|
||||
|
||||
adminTrigger.addEventListener('click', () => {
|
||||
window.open('/map_editor.html', '_blank');
|
||||
const roleToggle = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
||||
roleToggle?.addEventListener('change', () => {
|
||||
state.currentUserRole = roleToggle.checked ? 'admin' : 'user';
|
||||
if (state.currentUserRole === 'admin') {
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '대시보드';
|
||||
} else {
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '서버';
|
||||
}
|
||||
render();
|
||||
onTabChange(state.activeSubTab);
|
||||
});
|
||||
|
||||
adminGroup.appendChild(adminTrigger);
|
||||
navContainer.appendChild(adminGroup);
|
||||
|
||||
// 아이콘 생성
|
||||
// @ts-ignore
|
||||
if (window.lucide) window.lucide.createIcons();
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
@@ -19,8 +19,8 @@
|
||||
|
||||
.guide-tab {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
@@ -72,7 +72,7 @@
|
||||
}
|
||||
|
||||
.guide-section h3 {
|
||||
font-size: 1rem;
|
||||
font-size: 1.73rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 2px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
@@ -83,7 +83,7 @@
|
||||
}
|
||||
|
||||
.guide-text {
|
||||
font-size: 13px;
|
||||
font-size: 24px;
|
||||
color: var(--text-main);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
@@ -127,8 +127,8 @@
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 23px;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -136,14 +136,14 @@
|
||||
}
|
||||
|
||||
.flow-step .step-label {
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
font-size: 24px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.flow-step .step-desc {
|
||||
font-size: 12px;
|
||||
font-size: 23px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
@@ -159,13 +159,13 @@
|
||||
.guide-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.guide-info-table th {
|
||||
background: #f8faf9;
|
||||
color: var(--primary-color);
|
||||
font-weight: 700;
|
||||
font-weight: 800;
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
@@ -182,7 +182,7 @@
|
||||
background: var(--primary-light);
|
||||
border-left: 4px solid var(--primary-color);
|
||||
padding: 1rem;
|
||||
font-size: 13px;
|
||||
font-size: 24px;
|
||||
color: var(--primary-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -1,213 +1,7 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import { ASSET_SCHEMA } from './schema';
|
||||
|
||||
/**
|
||||
* ITAM 엑셀 핸들러 (Database Synchronized Edition)
|
||||
* 데이터베이스 실제 스키마 컬럼과 엑셀 헤더를 1:1로 일치시킵니다.
|
||||
* ITAM 엑셀 핸들러 (지정 날짜 포맷팅 유틸리티)
|
||||
*/
|
||||
|
||||
export interface HardwareAsset {
|
||||
[key: string]: any;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SoftwareAsset {
|
||||
[key: string]: any;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface SWUser {
|
||||
id: string;
|
||||
sw_id: string;
|
||||
user_name: string;
|
||||
dept: string;
|
||||
corp: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface HardwareLog {
|
||||
id: string;
|
||||
assetId: string;
|
||||
date: string;
|
||||
details: string;
|
||||
user: string;
|
||||
}
|
||||
|
||||
export interface MasterAssetData {
|
||||
pc: HardwareAsset[];
|
||||
server: HardwareAsset[];
|
||||
storage: HardwareAsset[];
|
||||
network: HardwareAsset[];
|
||||
equipment: HardwareAsset[];
|
||||
survey: HardwareAsset[];
|
||||
pcParts: HardwareAsset[];
|
||||
swInternal: SoftwareAsset[];
|
||||
swExternal: SoftwareAsset[];
|
||||
cloud: SoftwareAsset[];
|
||||
domain: any[];
|
||||
vip: HardwareAsset[];
|
||||
officeSupplies: HardwareAsset[];
|
||||
cost: any[];
|
||||
swUsers: SWUser[];
|
||||
logs: HardwareLog[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/**
|
||||
* DB 컬럼 순서 및 구성 정의 (실제 DB 스키마 dump 기준)
|
||||
*/
|
||||
const DB_MAPPING: Record<string, (keyof typeof ASSET_SCHEMA)[]> = {
|
||||
pc: [
|
||||
'ASSET_TYPE', 'HW_STATUS', 'CURRENT_DEPT', 'PREV_DEPT', 'USER_POSITION',
|
||||
'EMP_NO', 'CURRENT_USER',
|
||||
'CPU', 'RAM', 'GPU', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'HDD3', 'HDD4', 'MAC_ADDR',
|
||||
'MANAGER_MAIN', 'MANAGER_SUB', 'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT',
|
||||
'PURCHASE_VENDOR', 'MEMO', 'MAINBOARD'
|
||||
],
|
||||
server: [
|
||||
'ASSET_TYPE', 'MODEL_NAME', 'ASSET_PURPOSE', 'HW_STATUS',
|
||||
'CURRENT_DEPT', 'CPU', 'RAM', 'GPU', 'SSD1', 'SSD2', 'HDD1', 'HDD2', 'IP_ADDR',
|
||||
'REMOTE_TOOL', 'REMOTE_ID', 'REMOTE_PW', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN',
|
||||
'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR',
|
||||
'MEMO', 'PREV_DEPT', 'MANAGER_SUB', 'IP_ADDR2', 'MONITORING', 'HDD3', 'HDD4', 'EMP_NO'
|
||||
],
|
||||
storage: [
|
||||
'ASSET_TYPE', 'HW_STATUS', 'VOLUME', 'MODEL_NAME',
|
||||
'EMP_NO', 'CURRENT_USER',
|
||||
'SERIAL_NUM', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN', 'MANAGER_SUB',
|
||||
'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR',
|
||||
'MEMO', 'CURRENT_DEPT', 'PREV_DEPT'
|
||||
],
|
||||
network: [
|
||||
'PURCHASE_CORP', 'HW_STATUS', 'CURRENT_DEPT', 'PREV_DEPT',
|
||||
'EMP_NO', 'CURRENT_USER',
|
||||
'ASSET_TYPE', 'ASSET_MFR', 'MODEL_NAME', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN',
|
||||
'MANAGER_SUB', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR', 'MEMO'
|
||||
],
|
||||
survey: [ // asset_survey (공간정보장비)
|
||||
'HW_STATUS', 'ASSET_NAME', 'LOCATION', 'LOC_DETAIL',
|
||||
'EMP_NO', 'CURRENT_USER',
|
||||
'MANAGER_MAIN', 'MANAGER_SUB', 'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT',
|
||||
'PURCHASE_VENDOR', 'MEMO'
|
||||
],
|
||||
pcParts: [
|
||||
'HW_STATUS', 'ASSET_TYPE', 'ASSET_MFR', 'MODEL_NAME', 'VOLUME',
|
||||
'EMP_NO', 'CURRENT_USER',
|
||||
'MONITOR_INCH', 'LOCATION', 'LOC_DETAIL', 'PURCHASE_CORP', 'PURCHASE_DATE',
|
||||
'PURCHASE_AMOUNT', 'PURCHASE_VENDOR', 'MEMO'
|
||||
],
|
||||
equipment: [
|
||||
'HW_STATUS', 'ASSET_STATUS', 'ASSET_TYPE', 'ASSET_MFR',
|
||||
'EMP_NO', 'CURRENT_USER',
|
||||
'MODEL_NAME', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN', 'MANAGER_SUB',
|
||||
'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR',
|
||||
'MEMO'
|
||||
],
|
||||
officeSupplies: [ // asset_office_supplies (시설자산)
|
||||
'HW_STATUS', 'ASSET_TYPE', 'ASSET_MFR', 'MODEL_NAME',
|
||||
'EMP_NO', 'CURRENT_USER',
|
||||
'ASSET_COUNT', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN', 'MANAGER_SUB',
|
||||
'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR',
|
||||
'MEMO'
|
||||
],
|
||||
swInternal: [
|
||||
'SW_FIELD', 'DEV_OBJ', 'SW_STATUS', 'SW_TYPE', 'MANAGER_MAIN',
|
||||
'DEV_MGR', 'PLANNING_MGR', 'SALES_MGR', 'PURCHASE_CORP', 'MEMO'
|
||||
],
|
||||
swExternal: [
|
||||
'PRODUCT_NAME', 'SW_TYPE', 'SW_STATUS', 'SW_FIELD', 'CURRENT_DEPT',
|
||||
'PREV_DEPT', 'MANAGER_MAIN', 'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT',
|
||||
'PURCHASE_VENDOR', 'EMAIL_ACCOUNT', 'MEMO', 'EMP_NO', 'CURRENT_USER'
|
||||
],
|
||||
cloud: [
|
||||
'ASSET_PURPOSE', 'PURCHASE_METHOD', 'PURCHASE_VENDOR', 'PURCHASE_CORP',
|
||||
'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'MANAGER_MAIN', 'MANAGER_SUB',
|
||||
'MEMO', 'SW_ID', 'SW_PW'
|
||||
],
|
||||
domain: [
|
||||
'DOMAIN_ADDR', 'ASSET_PURPOSE', 'PURCHASE_VENDOR', 'ASSET_TYPE',
|
||||
'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'MANAGER_MAIN', 'MANAGER_SUB',
|
||||
'MEMO'
|
||||
],
|
||||
cost: [
|
||||
'ASSET_TYPE', 'ASSET_PURPOSE', 'LOCATION', 'LOC_DETAIL', 'MANAGER_MAIN',
|
||||
'MANAGER_SUB', 'PURCHASE_CORP', 'PURCHASE_DATE', 'PURCHASE_AMOUNT', 'PURCHASE_VENDOR',
|
||||
'EMAIL_ACCOUNT', 'EMAIL_PW', 'MEMO', 'EMP_NO', 'CURRENT_USER'
|
||||
],
|
||||
vip: [ // asset_vip (선물)
|
||||
'ASSET_NAME', 'MODEL_NAME', 'LOCATION', 'LOC_DETAIL',
|
||||
'PURCHASE_CORP', 'PURCHASE_DATE', 'EXPIRED_DATE', 'PURCHASE_VENDOR', 'MEMO'
|
||||
]
|
||||
};
|
||||
|
||||
export function downloadTemplate() {
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
const tabConfigs = [
|
||||
{ name: 'PC', key: 'pc' },
|
||||
{ name: '서버', key: 'server' },
|
||||
{ name: '스토리지', key: 'storage' },
|
||||
{ name: '공간정보장비', key: 'survey' },
|
||||
{ name: 'PC부품', key: 'pcParts' },
|
||||
{ name: '네트워크', key: 'network' },
|
||||
{ name: '업무지원장비', key: 'equipment' },
|
||||
{ name: '내부SW', key: 'swInternal' },
|
||||
{ name: '외부SW', key: 'swExternal' },
|
||||
{ name: '클라우드', key: 'cloud' },
|
||||
{ name: '도메인', key: 'domain' },
|
||||
{ name: '비용관리', key: 'cost' },
|
||||
{ name: '선물', key: 'vip' },
|
||||
{ name: '시설자산', key: 'officeSupplies' }
|
||||
];
|
||||
|
||||
tabConfigs.forEach(config => {
|
||||
const keys = DB_MAPPING[config.key];
|
||||
const headers = keys.map(k => ASSET_SCHEMA[k].ui);
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers]);
|
||||
ws['!cols'] = Array(headers.length).fill({ wch: 20 });
|
||||
XLSX.utils.book_append_sheet(wb, ws, config.name);
|
||||
});
|
||||
|
||||
XLSX.writeFile(wb, 'itam_template_db_aligned.xlsx');
|
||||
}
|
||||
|
||||
export function exportToExcel(masterData: MasterAssetData) {
|
||||
const wb = XLSX.utils.book_new();
|
||||
|
||||
const exportConfigs = [
|
||||
{ name: 'PC', list: masterData.pc, key: 'pc' },
|
||||
{ name: '서버', list: masterData.server, key: 'server' },
|
||||
{ name: '스토리지', list: masterData.storage, key: 'storage' },
|
||||
{ name: '공간정보장비', list: masterData.survey || [], key: 'survey' },
|
||||
{ name: 'PC부품', list: masterData.pcParts || [], key: 'pcParts' },
|
||||
{ name: '네트워크', list: masterData.network || [], key: 'network' },
|
||||
{ name: '업무지원장비', list: masterData.equipment || [], key: 'equipment' },
|
||||
{ name: '내부SW', list: masterData.swInternal, key: 'swInternal' },
|
||||
{ name: '외부SW', list: masterData.swExternal, key: 'swExternal' },
|
||||
{ name: '클라우드', list: masterData.cloud || [], key: 'cloud' },
|
||||
{ name: '도메인', list: masterData.domain || [], key: 'domain' },
|
||||
{ name: '비용관리', list: masterData.cost || [], key: 'cost' },
|
||||
{ name: '선물', list: masterData.vip || [], key: 'vip' },
|
||||
{ name: '시설자산', list: masterData.officeSupplies || [], key: 'officeSupplies' }
|
||||
];
|
||||
|
||||
exportConfigs.forEach(config => {
|
||||
const schemaKeys = DB_MAPPING[config.key];
|
||||
const headers = schemaKeys.map(k => ASSET_SCHEMA[k].ui);
|
||||
const rows = config.list.map(asset =>
|
||||
schemaKeys.map(k => {
|
||||
const dbField = ASSET_SCHEMA[k].db;
|
||||
return asset[dbField] || asset[ASSET_SCHEMA[k].key] || '';
|
||||
})
|
||||
);
|
||||
|
||||
const ws = XLSX.utils.aoa_to_sheet([headers, ...rows]);
|
||||
XLSX.utils.book_append_sheet(wb, ws, config.name);
|
||||
});
|
||||
|
||||
XLSX.writeFile(wb, `itam_export_${new Date().toISOString().split('T')[0]}.xlsx`);
|
||||
}
|
||||
|
||||
export function formatExcelDate(val: any): string {
|
||||
if (!val) return '';
|
||||
if (typeof val === 'number') {
|
||||
@@ -219,54 +13,3 @@ export function formatExcelDate(val: any): string {
|
||||
}
|
||||
return String(val);
|
||||
}
|
||||
|
||||
export async function parseExcel(file: File): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const workbook = XLSX.read(e.target?.result, { type: 'array' });
|
||||
const parsedData: any = {};
|
||||
|
||||
workbook.SheetNames.forEach(sheetName => {
|
||||
const ws = workbook.Sheets[sheetName];
|
||||
const rows = XLSX.utils.sheet_to_json(ws, { defval: "" }) as any[];
|
||||
const list: any[] = [];
|
||||
|
||||
rows.forEach(r => {
|
||||
const data: any = { id: Math.random().toString(36).substring(2, 9) };
|
||||
|
||||
// Set default category based on sheet name
|
||||
data['category'] = sheetName;
|
||||
|
||||
Object.keys(r).forEach(label => {
|
||||
const schemaEntry = Object.values(ASSET_SCHEMA).find(s => s.ui === label);
|
||||
const key = schemaEntry ? schemaEntry.db : label;
|
||||
let val = r[label];
|
||||
|
||||
if (label.includes('일자') || label.includes('연월') || label.includes('만료일') || label.includes('시작일')) {
|
||||
val = formatExcelDate(val);
|
||||
}
|
||||
data[key] = val;
|
||||
});
|
||||
|
||||
list.push(data);
|
||||
});
|
||||
|
||||
// Sheet Name Mapping back to state keys
|
||||
const nameMap: Record<string, string> = {
|
||||
'PC': 'pc', '서버': 'server', '스토리지': 'storage', '공간정보장비': 'survey',
|
||||
'PC부품': 'pcParts', '네트워크': 'network', '업무지원장비': 'equipment',
|
||||
'내부SW': 'swInternal', '외부SW': 'swExternal', '클라우드': 'cloud',
|
||||
'도메인': 'domain', '비용관리': 'cost', '선물': 'vip', '시설자산': 'officeSupplies'
|
||||
};
|
||||
|
||||
const stateKey = nameMap[sheetName] || sheetName;
|
||||
if (list.length > 0) parsedData[stateKey] = list;
|
||||
});
|
||||
resolve(parsedData);
|
||||
} catch (err) { reject(err); }
|
||||
};
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ASSET_SCHEMA, UI_TEXT } from './schema';
|
||||
import { getActionButtonsHTML } from './utils';
|
||||
import { generateOptionsHTML } from '../components/Modal/ModalUtils';
|
||||
import { CORP_LIST } from '../components/Modal/SharedData';
|
||||
|
||||
@@ -15,23 +14,62 @@ export interface FilterOptions {
|
||||
showLoc?: boolean;
|
||||
showField?: boolean;
|
||||
showType?: boolean;
|
||||
showStatus?: boolean;
|
||||
extraHTML?: string;
|
||||
onFilterChange: (filters: any) => void;
|
||||
initialFilters?: any;
|
||||
fullList?: any[]; // For populating dynamic filters
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 액션 버튼 그룹 생성 (자산 추가 등)
|
||||
*/
|
||||
export function getActionButtonsHTML(): string {
|
||||
return `<div id="filter-bar-actions" class="header-action-group"></div>`;
|
||||
}
|
||||
|
||||
export function renderFilterBar(container: HTMLElement, options: FilterOptions) {
|
||||
const { keywordLabel = '통합 검색', showCorp = false, showDept = false, showLoc = false, showField = false, showType = false, extraHTML = '', onFilterChange } = options;
|
||||
const {
|
||||
keywordLabel = '통합 검색',
|
||||
showCorp = false,
|
||||
showDept = false,
|
||||
showLoc = false,
|
||||
showField = false,
|
||||
showType = false,
|
||||
showStatus = false,
|
||||
extraHTML = '',
|
||||
onFilterChange,
|
||||
initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' },
|
||||
fullList = []
|
||||
} = options;
|
||||
|
||||
container.classList.add('search-bar'); // Restored class
|
||||
|
||||
// Helper to get unique sorted values
|
||||
const getUnique = (key: keyof typeof ASSET_SCHEMA | string) => {
|
||||
const fieldKey = (ASSET_SCHEMA as any)[key]?.key || key;
|
||||
return Array.from(new Set(fullList.map(item => item[fieldKey] || item[(ASSET_SCHEMA as any)[key]?.db]).filter(Boolean))).sort();
|
||||
};
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="search-item flex-1">
|
||||
<label>${keywordLabel}</label>
|
||||
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
|
||||
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off" value="${initialFilters.keyword || ''}">
|
||||
</div>
|
||||
${showType ? `
|
||||
<div class="search-item">
|
||||
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
|
||||
<select id="filter-type">
|
||||
<option value="">전체 유형</option>
|
||||
${getUnique('ASSET_TYPE').map(v => `<option value="${v}" ${initialFilters.type === v ? 'selected' : ''}>${v}</option>`).join('')}
|
||||
</select>
|
||||
</div>` : ''}
|
||||
${showStatus ? `
|
||||
<div class="search-item">
|
||||
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
|
||||
<select id="filter-status">
|
||||
<option value="">전체 상태</option>
|
||||
${getUnique('HW_STATUS').map(v => `<option value="${v}" ${initialFilters.status === v ? 'selected' : ''}>${v}</option>`).join('')}
|
||||
</select>
|
||||
</div>` : ''}
|
||||
${showField ? `
|
||||
@@ -39,30 +77,36 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
||||
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
|
||||
<select id="filter-field">
|
||||
<option value="">전체 분야</option>
|
||||
<option value="업무공통">업무공통</option>
|
||||
<option value="개발S/W">개발S/W</option>
|
||||
<option value="디자인">디자인</option>
|
||||
<option value="설계S/W">설계S/W</option>
|
||||
<option value="업무공통" ${initialFilters.field === '업무공통' ? 'selected' : ''}>업무공통</option>
|
||||
<option value="개발S/W" ${initialFilters.field === '개발S/W' ? 'selected' : ''}>개발S/W</option>
|
||||
<option value="디자인" ${initialFilters.field === '디자인' ? 'selected' : ''}>디자인</option>
|
||||
<option value="설계S/W" ${initialFilters.field === '설계S/W' ? 'selected' : ''}>설계S/W</option>
|
||||
</select>
|
||||
</div>` : ''}
|
||||
${showCorp ? `
|
||||
<div class="search-item">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
|
||||
<select id="filter-corp">${generateOptionsHTML(CORP_LIST, '', true)}</select>
|
||||
<select id="filter-corp">${generateOptionsHTML(CORP_LIST, initialFilters.corp || '', true)}</select>
|
||||
</div>` : ''}
|
||||
${showLoc ? `
|
||||
<div class="search-item">
|
||||
<label>${ASSET_SCHEMA.LOCATION.ui}</label>
|
||||
<select id="filter-loc"><option value="">전체 위치</option></select>
|
||||
<select id="filter-loc">
|
||||
<option value="">전체 위치</option>
|
||||
${getUnique('LOCATION').map(v => `<option value="${v}" ${initialFilters.loc === v ? 'selected' : ''}>${v}</option>`).join('')}
|
||||
</select>
|
||||
</div>` : ''}
|
||||
${showDept ? `
|
||||
<div class="search-item">
|
||||
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
||||
<select id="filter-dept"><option value="">전체 조직</option></select>
|
||||
<select id="filter-dept">
|
||||
<option value="">전체 조직</option>
|
||||
${getUnique('CURRENT_DEPT').map(v => `<option value="${v}" ${initialFilters.dept === v ? 'selected' : ''}>${v}</option>`).join('')}
|
||||
</select>
|
||||
</div>` : ''}
|
||||
${extraHTML}
|
||||
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
|
||||
<i data-lucide="refresh-ccw"></i> ${UI_TEXT.ACTION.RESET_FILTER}
|
||||
<i data-lucide="refresh-ccw" class="icon-sm"></i> ${UI_TEXT.ACTION.RESET_FILTER}
|
||||
</button>
|
||||
${getActionButtonsHTML()}
|
||||
`;
|
||||
@@ -75,7 +119,8 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
||||
dept: (container.querySelector('#filter-dept') as HTMLSelectElement)?.value || '',
|
||||
loc: (container.querySelector('#filter-loc') as HTMLSelectElement)?.value || '',
|
||||
field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || '',
|
||||
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || ''
|
||||
type: (container.querySelector('#filter-type') as HTMLSelectElement)?.value || '',
|
||||
status: (container.querySelector('#filter-status') as HTMLSelectElement)?.value || ''
|
||||
};
|
||||
onFilterChange(filters);
|
||||
};
|
||||
@@ -86,9 +131,10 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
||||
container.querySelector('#filter-loc')?.addEventListener('change', triggerChange);
|
||||
container.querySelector('#filter-field')?.addEventListener('change', triggerChange);
|
||||
container.querySelector('#filter-type')?.addEventListener('change', triggerChange);
|
||||
container.querySelector('#filter-status')?.addEventListener('change', triggerChange);
|
||||
|
||||
container.querySelector('#btn-reset-filters')?.addEventListener('click', () => {
|
||||
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type'].forEach(id => {
|
||||
['filter-keyword', 'filter-corp', 'filter-dept', 'filter-loc', 'filter-field', 'filter-type', 'filter-status'].forEach(id => {
|
||||
const el = container.querySelector(`#${id}`);
|
||||
if (el) (el as any).value = '';
|
||||
});
|
||||
@@ -109,7 +155,8 @@ export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof
|
||||
const matchLoc = !filters.loc || (item[ASSET_SCHEMA.LOCATION.key] || item[ASSET_SCHEMA.LOCATION.db]) === filters.loc;
|
||||
const matchField = !filters.field || (item[ASSET_SCHEMA.SW_FIELD.key] || item[ASSET_SCHEMA.SW_FIELD.db]) === filters.field;
|
||||
const matchType = !filters.type || (item[ASSET_SCHEMA.ASSET_TYPE.key] || item[ASSET_SCHEMA.ASSET_TYPE.db]) === filters.type;
|
||||
const matchStatus = !filters.status || (item[ASSET_SCHEMA.HW_STATUS.key] || item[ASSET_SCHEMA.HW_STATUS.db]) === filters.status;
|
||||
|
||||
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType;
|
||||
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType && matchStatus;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const ASSET_SCHEMA = {
|
||||
PURCHASE_AMOUNT:{ key: 'purchase_amount', db: 'purchase_amount', ui: '구매금액' },
|
||||
PURCHASE_VENDOR:{ key: 'purchase_vendor', db: 'purchase_vendor', ui: '구매업체' },
|
||||
APPROVAL_DOC: { key: 'approval_document', db: 'approval_document', ui: '품의서' },
|
||||
SERVICE_TYPE: { key: 'service_type', db: 'service_type', ui: '서비스 구분' },
|
||||
MANAGER_MAIN: { key: 'manager_primary', db: 'manager_primary', ui: '담당자(정)' },
|
||||
MANAGER_SUB: { key: 'manager_secondary', db: 'manager_secondary', ui: '담당자(부)' },
|
||||
LOCATION: { key: 'location', db: 'location', ui: '자산위치' },
|
||||
@@ -120,12 +121,12 @@ export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: str
|
||||
description: '측량 및 공간 정보 수집에 사용되는 특수 정밀 장비들의 이력과 상태를 관리합니다.',
|
||||
icon: 'map'
|
||||
},
|
||||
'내부': {
|
||||
'내부SW': {
|
||||
title: '사내 개발 S/W 관리',
|
||||
description: '사내에서 자체 개발하거나 운영 중인 시스템 및 소프트웨어 서비스 현황을 관리합니다.',
|
||||
icon: 'code'
|
||||
},
|
||||
'외부': {
|
||||
'외부SW': {
|
||||
title: '외부 상용 S/W 관리',
|
||||
description: '상용 소프트웨어의 라이선스 보유 현황, 사용자 할당 및 만료 일정을 관리합니다.',
|
||||
icon: 'package'
|
||||
@@ -154,6 +155,21 @@ export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: str
|
||||
title: '사무용 가구 관리',
|
||||
description: '책상, 의자, 캐비닛 등 사무 환경 구성을 위한 가구 자산의 배치 현황을 관리합니다.',
|
||||
icon: 'armchair'
|
||||
},
|
||||
'사용자': {
|
||||
title: '임직원 사용자 관리',
|
||||
description: 'IT 자산 할당 및 관리의 기준이 되는 사내 임직원(사용자) 정보를 데이터베이스 기반으로 직접 등록하고 수정합니다.',
|
||||
icon: 'users'
|
||||
},
|
||||
'부품 마스터': {
|
||||
title: '부품 표준 정보 관리',
|
||||
description: 'PC 사양 적정성 평가의 기준이 되는 부품 표준 정보 및 등급별 감점 점수를 관리합니다.',
|
||||
icon: 'cpu'
|
||||
},
|
||||
'직무별 기준 사양': {
|
||||
title: '직무별 기준 사양 관리',
|
||||
description: 'BIM 모델러, 개발자, 엔지니어 등 사내 직무별 권장 하드웨어 기준 및 성능 합격 점수를 관리합니다.',
|
||||
icon: 'sliders'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,101 +1,69 @@
|
||||
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog } from './excelHandler';
|
||||
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog, MasterAssetData, SystemUser } from './types';
|
||||
import { API_BASE_URL } from './utils';
|
||||
|
||||
// --- State Definitions ---
|
||||
export interface MasterAssetData {
|
||||
users: any[];
|
||||
pc: any[];
|
||||
server: any[];
|
||||
storage: any[];
|
||||
network: any[];
|
||||
survey: any[];
|
||||
pcParts: any[];
|
||||
equipment: any[];
|
||||
officeSupplies: any[];
|
||||
swInternal: any[];
|
||||
swExternal: any[];
|
||||
cloud: any[];
|
||||
domain: any[];
|
||||
cost: any[];
|
||||
vip: any[];
|
||||
mobile?: any[]; // Legacy mobile support
|
||||
equip?: any[]; // Backward compat
|
||||
|
||||
// Backward compatibility
|
||||
subSw: any[];
|
||||
permSw: any[];
|
||||
|
||||
swUsers: SWUser[];
|
||||
logs: HardwareLog[];
|
||||
|
||||
// 통합 배열
|
||||
hw: any[];
|
||||
sw: any[];
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
activeCategory: 'dashboard' | 'hw' | 'sw' | 'ops' | 'vip' | 'fac' | 'users' | 'etc';
|
||||
activeSubTab: string;
|
||||
viewMode: 'location' | 'legacy' | 'list';
|
||||
masterData: MasterAssetData;
|
||||
activeCharts: any[];
|
||||
currentUserRole: 'admin' | 'user';
|
||||
listFilters?: Record<string, any>;
|
||||
}
|
||||
|
||||
// 초기 상태
|
||||
export const state: AppState = {
|
||||
activeCategory: 'hw',
|
||||
activeSubTab: '서버', // 대시보드 제거됨에 따라 기본값 변경
|
||||
activeSubTab: '대시보드',
|
||||
viewMode: 'location',
|
||||
activeCharts: [],
|
||||
currentUserRole: 'user',
|
||||
listFilters: {},
|
||||
masterData: {
|
||||
users: [],
|
||||
pc: [], server: [], storage: [], network: [],
|
||||
survey: [], pcParts: [], equipment: [], officeSupplies: [],
|
||||
survey: [], pcParts: [], partsMaster: [], equipment: [], officeSupplies: [],
|
||||
swInternal: [], swExternal: [], cloud: [], domain: [],
|
||||
cost: [], vip: [],
|
||||
subSw: [], permSw: [],
|
||||
hw: [], sw: [],
|
||||
swUsers: [], logs: []
|
||||
swUsers: [], logs: [],
|
||||
jobSpecs: [],
|
||||
mobile: []
|
||||
}
|
||||
};
|
||||
|
||||
(window as any).__itam_state = state;
|
||||
|
||||
/**
|
||||
* 신규 14개 테이블 구조에 맞춘 데이터 로드
|
||||
* 통합 V2 스키마에 맞춘 데이터 로드
|
||||
*/
|
||||
export async function loadMasterDataFromDB() {
|
||||
try {
|
||||
const endpoints = [
|
||||
{ key: 'users', url: '/api/users' },
|
||||
{ key: 'pc', url: '/api/pc' },
|
||||
{ key: 'server', url: '/api/server' },
|
||||
{ key: 'storage', url: '/api/storage' },
|
||||
{ key: 'network', url: '/api/network' },
|
||||
{ key: 'survey', url: '/api/survey' },
|
||||
{ key: 'pcParts', url: '/api/pc-parts' },
|
||||
{ key: 'equipment', url: '/api/equipment' },
|
||||
{ key: 'officeSupplies', url: '/api/office-supplies' },
|
||||
{ key: 'swInternal', url: '/api/sw/internal' },
|
||||
{ key: 'swExternal', url: '/api/sw/external' },
|
||||
{ key: 'cloud', url: '/api/cloud' },
|
||||
{ key: 'domain', url: '/api/domain' },
|
||||
{ key: 'cost', url: '/api/cost' },
|
||||
{ key: 'vip', url: '/api/vip' },
|
||||
{ key: 'swUsers', url: '/api/asset/software/assignment' },
|
||||
{ key: 'logs', url: '/api/asset/history' }
|
||||
];
|
||||
|
||||
const results = await Promise.all(endpoints.map(e => fetch(API_BASE_URL + e.url)));
|
||||
|
||||
for (let i = 0; i < endpoints.length; i++) {
|
||||
if (results[i].ok) {
|
||||
const data = await results[i].json();
|
||||
const key = endpoints[i].key;
|
||||
(state.masterData as any)[key] = Array.isArray(data) ? data : [];
|
||||
}
|
||||
}
|
||||
const response = await fetch(`${API_BASE_URL}/api/assets/master`);
|
||||
if (!response.ok) throw new Error('Failed to fetch master data');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// 전역 상태 업데이트
|
||||
state.masterData = {
|
||||
...state.masterData,
|
||||
...data,
|
||||
jobSpecs: data.jobSpecs || [],
|
||||
logs: (data.logs || []).map((l: any) => ({
|
||||
...l,
|
||||
assetId: l.asset_id || l.assetId,
|
||||
date: l.log_date || l.date,
|
||||
user: l.log_user || l.user,
|
||||
log_date: l.log_date || l.date,
|
||||
log_user: l.log_user || l.user
|
||||
}))
|
||||
};
|
||||
|
||||
// Mapping for backward compatibility
|
||||
state.masterData.equip = state.masterData.equipment;
|
||||
state.masterData.subSw = state.masterData.swExternal;
|
||||
state.masterData.permSw = state.masterData.swInternal;
|
||||
(state.masterData as any).equip = state.masterData.equipment;
|
||||
(state.masterData as any).subSw = state.masterData.swExternal;
|
||||
(state.masterData as any).permSw = state.masterData.swInternal;
|
||||
|
||||
// 하드웨어 통합 (대시보드 호환용)
|
||||
state.masterData.hw = [
|
||||
@@ -112,13 +80,13 @@ export async function loadMasterDataFromDB() {
|
||||
state.masterData.sw = [
|
||||
...state.masterData.swInternal,
|
||||
...state.masterData.swExternal,
|
||||
...state.masterData.cloud
|
||||
...(state.masterData.cloud || [])
|
||||
];
|
||||
|
||||
console.log('✅ All data (including users) loaded and unified');
|
||||
console.log('✅ V2 Normalized data loaded successfully');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('⚠️ 서버 연결 실패:', err);
|
||||
console.warn('⚠️ Dummy 로드 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -128,39 +96,15 @@ export function updateState(newState: Partial<AppState>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 자산 저장 (Generic API)
|
||||
* 자산 저장 (V2 Normalized API)
|
||||
*/
|
||||
export async function saveAsset(category: string, asset: any) {
|
||||
try {
|
||||
const endpointMap: Record<string, string> = {
|
||||
'users': '/api/users/batch',
|
||||
'pc': '/api/pc/batch',
|
||||
'server': '/api/server/batch',
|
||||
'storage': '/api/storage/batch',
|
||||
'network': '/api/network/batch',
|
||||
'survey': '/api/survey/batch',
|
||||
'pcParts': '/api/pc-parts/batch',
|
||||
'equipment': '/api/equipment/batch',
|
||||
'officeSupplies': '/api/office-supplies/batch',
|
||||
'swInternal': '/api/sw/internal/batch',
|
||||
'swExternal': '/api/sw/external/batch',
|
||||
'cloud': '/api/cloud/batch',
|
||||
'domain': '/api/domain/batch',
|
||||
'cost': '/api/cost/batch',
|
||||
'vip': '/api/vip/batch'
|
||||
};
|
||||
|
||||
const url = `${API_BASE_URL}${endpointMap[category]}`;
|
||||
const currentList = [...(state.masterData as any)[category]];
|
||||
const idx = currentList.findIndex(a => a.id === asset.id);
|
||||
|
||||
if (idx > -1) currentList[idx] = asset;
|
||||
else currentList.push(asset);
|
||||
|
||||
const url = `${API_BASE_URL}/api/asset/${category}/save`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(currentList)
|
||||
body: JSON.stringify(asset)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
@@ -174,37 +118,12 @@ export async function saveAsset(category: string, asset: any) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 자산 삭제 (Generic API - Batch 방식 활용)
|
||||
* 자산 삭제 (V2 API)
|
||||
*/
|
||||
export async function deleteAsset(category: string, assetId: string) {
|
||||
try {
|
||||
const endpointMap: Record<string, string> = {
|
||||
'users': '/api/users/batch',
|
||||
'pc': '/api/pc/batch',
|
||||
'server': '/api/server/batch',
|
||||
'storage': '/api/storage/batch',
|
||||
'network': '/api/network/batch',
|
||||
'survey': '/api/survey/batch',
|
||||
'pcParts': '/api/pc-parts/batch',
|
||||
'equipment': '/api/equipment/batch',
|
||||
'officeSupplies': '/api/office-supplies/batch',
|
||||
'swInternal': '/api/sw/internal/batch',
|
||||
'swExternal': '/api/sw/external/batch',
|
||||
'cloud': '/api/cloud/batch',
|
||||
'domain': '/api/domain/batch',
|
||||
'cost': '/api/cost/batch',
|
||||
'vip': '/api/vip/batch'
|
||||
};
|
||||
|
||||
const url = `${API_BASE_URL}${endpointMap[category]}`;
|
||||
const currentList = [...(state.masterData as any)[category]];
|
||||
const filteredList = currentList.filter(a => a.id !== assetId);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(filteredList)
|
||||
});
|
||||
const url = `${API_BASE_URL}/api/asset/${category}/${assetId}`;
|
||||
const response = await fetch(url, { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
@@ -215,3 +134,103 @@ export async function deleteAsset(category: string, assetId: string) {
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function savePartsMaster(component: any) {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/api/hardware-components/save`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(component)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('부품 마스터 저장 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function deletePartsMaster(id: number) {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/api/hardware-components/${id}`;
|
||||
const response = await fetch(url, { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('부품 마스터 삭제 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function saveSystemUser(user: any) {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/api/system-users/save`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(user)
|
||||
});
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('사용자 정보 저장 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function deleteSystemUser(id: string) {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/api/system-users/${id}`;
|
||||
const response = await fetch(url, { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('사용자 정보 삭제 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function saveJobSpec(spec: any) {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/api/job-specs/save`;
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(spec)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('직무별 기준 사양 저장 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function deleteJobSpec(id: number) {
|
||||
try {
|
||||
const url = `${API_BASE_URL}/api/job-specs/${id}`;
|
||||
const response = await fetch(url, { method: 'DELETE' });
|
||||
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('직무별 기준 사양 삭제 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
155
src/core/types.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* ITAM Global Type Definitions
|
||||
*/
|
||||
|
||||
export interface BaseAsset {
|
||||
id: string;
|
||||
asset_code?: string;
|
||||
category?: string;
|
||||
asset_type?: string;
|
||||
purchase_corp?: string;
|
||||
purchase_date?: string;
|
||||
purchase_amount?: number | string;
|
||||
purchase_vendor?: string;
|
||||
approval_document?: string;
|
||||
service_type?: string;
|
||||
manager_primary?: string;
|
||||
manager_secondary?: string;
|
||||
location?: string;
|
||||
location_detail?: string;
|
||||
location_photo?: string;
|
||||
loc_x?: number;
|
||||
loc_y?: number;
|
||||
memo?: string;
|
||||
updated_at?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface HardwareAsset extends BaseAsset {
|
||||
hw_status?: string;
|
||||
model_name?: string;
|
||||
asset_name?: string;
|
||||
asset_mfr?: string;
|
||||
current_dept?: string;
|
||||
previous_dept?: string;
|
||||
user_current?: string;
|
||||
emp_no?: string;
|
||||
user_position?: string;
|
||||
previous_user?: string;
|
||||
cpu?: string;
|
||||
ram?: string;
|
||||
gpu?: string;
|
||||
ssd_1?: string;
|
||||
ssd_2?: string;
|
||||
hdd_1?: string;
|
||||
hdd_2?: string;
|
||||
hdd_3?: string;
|
||||
hdd_4?: string;
|
||||
mainboard?: string;
|
||||
os?: string;
|
||||
ip_address?: string;
|
||||
ip_address_2?: string;
|
||||
mac_address?: string;
|
||||
remote_tool?: string;
|
||||
remote_id?: string;
|
||||
remote_pw?: string;
|
||||
monitoring?: string;
|
||||
volume?: string;
|
||||
monitor_inch?: string;
|
||||
asset_count?: number | string;
|
||||
serial_num?: string;
|
||||
// Normalized V3 fields
|
||||
volumes?: any[];
|
||||
remotes?: any[];
|
||||
}
|
||||
|
||||
export interface SoftwareAsset extends BaseAsset {
|
||||
sw_status?: string;
|
||||
sw_field?: string;
|
||||
sw_type?: string;
|
||||
dev_objective?: string;
|
||||
dev_manager?: string;
|
||||
planning_manager?: string;
|
||||
sales_manager?: string;
|
||||
product_name?: string;
|
||||
domain_address?: string;
|
||||
email_account?: string;
|
||||
email_pw?: string;
|
||||
sw_id?: string;
|
||||
sw_pw?: string;
|
||||
purchase_method?: string;
|
||||
asset_purpose?: string;
|
||||
asset_status?: string;
|
||||
start_date?: string;
|
||||
expired_date?: string;
|
||||
}
|
||||
|
||||
export interface SWUser {
|
||||
id: string;
|
||||
sw_id: string;
|
||||
user_name: string;
|
||||
dept: string;
|
||||
corp: string;
|
||||
emp_no?: string;
|
||||
created_at?: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export interface HardwareLog {
|
||||
id: string;
|
||||
asset_id: string;
|
||||
log_date: string;
|
||||
log_user: string;
|
||||
event_type: string;
|
||||
details: string;
|
||||
old_dept?: string;
|
||||
new_dept?: string;
|
||||
old_user?: string;
|
||||
new_user?: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface SystemUser {
|
||||
id: string;
|
||||
emp_no: string;
|
||||
user_name: string;
|
||||
dept_name: string;
|
||||
position: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface PartsMaster {
|
||||
id: number | string;
|
||||
category: string;
|
||||
component_name: string;
|
||||
score_tier: string;
|
||||
deduction: number;
|
||||
}
|
||||
|
||||
export interface MasterAssetData {
|
||||
users: SystemUser[];
|
||||
pc: HardwareAsset[];
|
||||
server: HardwareAsset[];
|
||||
storage: HardwareAsset[];
|
||||
network: HardwareAsset[];
|
||||
survey: HardwareAsset[];
|
||||
pcParts: HardwareAsset[];
|
||||
partsMaster: PartsMaster[];
|
||||
equipment: HardwareAsset[];
|
||||
officeSupplies: HardwareAsset[];
|
||||
swInternal: SoftwareAsset[];
|
||||
swExternal: SoftwareAsset[];
|
||||
cloud: SoftwareAsset[];
|
||||
domain: SoftwareAsset[];
|
||||
cost: any[];
|
||||
vip: HardwareAsset[];
|
||||
swUsers: SWUser[];
|
||||
logs: HardwareLog[];
|
||||
jobSpecs?: any[];
|
||||
mobile?: HardwareAsset[];
|
||||
// Integrated arrays
|
||||
hw: HardwareAsset[];
|
||||
sw: SoftwareAsset[];
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { PAGE_DESCRIPTIONS } from './schema';
|
||||
|
||||
export const API_BASE_URL = `http://${location.hostname}:3000`;
|
||||
export const API_BASE_URL = '';
|
||||
|
||||
/**
|
||||
* ITAM 공통 유틸리티 함수
|
||||
@@ -17,7 +17,7 @@ export function renderPageHeader(container: HTMLElement, pageId: string) {
|
||||
header.className = 'page-header';
|
||||
header.innerHTML = `
|
||||
<div class="page-title-group">
|
||||
<h2 class="page-title"><i data-lucide="${config.icon}"></i> ${config.title}</h2>
|
||||
<h2 class="page-title">${config.title}</h2>
|
||||
<p class="page-description">${config.description}</p>
|
||||
</div>
|
||||
`;
|
||||
@@ -153,14 +153,207 @@ export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 뷰용 액션 버튼 HTML 생성 (자산추가)
|
||||
* 목록 뷰용 액션 버튼 HTML 생성 (중복 제거를 위해 비워둠)
|
||||
*/
|
||||
export function getActionButtonsHTML(): string {
|
||||
return `
|
||||
<div class="search-actions">
|
||||
<button id="btn-add-asset" class="btn btn-primary">
|
||||
<i data-lucide="plus"></i> 자산추가
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 100점 만점 감점형 PC 성능 점수 계산 (CPU + RAM + GPU + 연식)
|
||||
*/
|
||||
export function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, purchaseDate: string): number {
|
||||
let score = 100;
|
||||
if (!cpu) cpu = '';
|
||||
if (!ram) ram = '';
|
||||
if (!gpu) gpu = '';
|
||||
|
||||
const cpuUpper = cpu.toUpperCase();
|
||||
const ramUpper = ram.toUpperCase();
|
||||
const gpuUpper = gpu.toUpperCase();
|
||||
|
||||
// 1. CPU 등급 감점 (최대 -30점)
|
||||
let cpuDeduction = 0;
|
||||
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
|
||||
cpuDeduction = 0;
|
||||
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
|
||||
cpuDeduction = 5;
|
||||
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
|
||||
cpuDeduction = 15;
|
||||
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
|
||||
cpuDeduction = 25;
|
||||
} else {
|
||||
cpuDeduction = 30;
|
||||
}
|
||||
score -= cpuDeduction;
|
||||
|
||||
// 2. CPU 세대 노후 감점 (최대 -15점)
|
||||
let genDeduction = 0;
|
||||
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
|
||||
let gen = 0;
|
||||
if (intelMatch && intelMatch[1]) {
|
||||
const numStr = intelMatch[1];
|
||||
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
|
||||
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
|
||||
}
|
||||
|
||||
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
|
||||
let amdGen = 0;
|
||||
if (amdMatch && amdMatch[1] && !intelMatch) {
|
||||
const numStr = amdMatch[1];
|
||||
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
|
||||
}
|
||||
|
||||
if (intelMatch) {
|
||||
if (gen >= 12) genDeduction = 0;
|
||||
else if (gen >= 10) genDeduction = 5;
|
||||
else if (gen >= 8) genDeduction = 10;
|
||||
else genDeduction = 15;
|
||||
} else if (amdMatch) {
|
||||
if (amdGen >= 5) genDeduction = 0;
|
||||
else if (amdGen >= 3) genDeduction = 5;
|
||||
else genDeduction = 10;
|
||||
} else {
|
||||
genDeduction = 15;
|
||||
}
|
||||
score -= genDeduction;
|
||||
|
||||
// 3. RAM 용량 감점 (최대 -25점)
|
||||
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
|
||||
let ramDeduction = 25;
|
||||
if (ramMatch && ramMatch[1]) {
|
||||
const ramVal = parseInt(ramMatch[1], 10);
|
||||
if (ramVal >= 32) ramDeduction = 0;
|
||||
else if (ramVal >= 16) ramDeduction = 10;
|
||||
else if (ramVal >= 8) ramDeduction = 20;
|
||||
else ramDeduction = 25;
|
||||
}
|
||||
score -= ramDeduction;
|
||||
|
||||
// 4. GPU 성능 감점 (최대 -25점)
|
||||
let gpuDeduction = 25;
|
||||
if (!gpuUpper || gpuUpper === '-' || gpuUpper.trim() === '') {
|
||||
gpuDeduction = 25;
|
||||
} else if (
|
||||
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
|
||||
gpuUpper.includes('RTX 3090') || gpuUpper.includes('RTX 3080') ||
|
||||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
|
||||
) {
|
||||
gpuDeduction = 0;
|
||||
} else if (
|
||||
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
|
||||
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
|
||||
) {
|
||||
gpuDeduction = 5;
|
||||
} else if (
|
||||
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
|
||||
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
|
||||
) {
|
||||
gpuDeduction = 15;
|
||||
} else {
|
||||
gpuDeduction = 25;
|
||||
}
|
||||
score -= gpuDeduction;
|
||||
|
||||
// 5. 연식(노후도) 감점 (최대 -15점)
|
||||
let age = 0;
|
||||
if (purchaseDate && purchaseDate !== '-') {
|
||||
let normalized = purchaseDate.replace(/\./g, '-').trim();
|
||||
if (/^\d{6}$/.test(normalized)) {
|
||||
normalized = `${normalized.substring(0, 4)}-${normalized.substring(4, 6)}`;
|
||||
}
|
||||
const purchase = new Date(normalized);
|
||||
if (!isNaN(purchase.getTime())) {
|
||||
// 2026년 5월 31일 기준 경과연수 계산
|
||||
const mockToday = new Date('2026-05-31');
|
||||
const diffMs = mockToday.getTime() - purchase.getTime();
|
||||
age = diffMs / (1000 * 60 * 60 * 24 * 365.25);
|
||||
age = Math.max(0, parseFloat(age.toFixed(1)));
|
||||
}
|
||||
}
|
||||
|
||||
let ageDeduction = 0;
|
||||
if (age < 1) ageDeduction = 0;
|
||||
else if (age < 2) ageDeduction = 3;
|
||||
else if (age < 3) ageDeduction = 6;
|
||||
else if (age < 4) ageDeduction = 9;
|
||||
else if (age < 5) ageDeduction = 12;
|
||||
else ageDeduction = 15;
|
||||
|
||||
score -= ageDeduction;
|
||||
|
||||
return Math.max(10, score);
|
||||
}
|
||||
|
||||
/**
|
||||
* 성능 점수 기준 등급 뱃지 메타 정보 가져오기
|
||||
*/
|
||||
export function getPcGrade(score: number, isWin11Incompatible?: boolean): { name: string; class: string; color: string } {
|
||||
if (score >= 85) return { name: '최상급', class: 'b-purple', color: '#7C3AED' };
|
||||
if (score >= 70) return { name: '상급', class: 'b-primary', color: '#4F46E5' };
|
||||
if (score >= 40) return { name: '중급', class: 'b-green', color: '#10B981' };
|
||||
if (score >= 20 && !isWin11Incompatible) return { name: '보급', class: 'b-yellow', color: '#F59E0B' };
|
||||
return { name: '교체 대상', class: 'badge-danger', color: '#EF4444' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Windows 11 업그레이드 지원 불가능한 하드웨어 조건인지 판별
|
||||
*/
|
||||
export function isWindows11Incompatible(cpu: string, ram: string): boolean {
|
||||
if (!cpu) return true;
|
||||
const cpuUpper = cpu.toUpperCase();
|
||||
|
||||
// 1. RAM 4GB 미만은 공식 미지원
|
||||
if (ram) {
|
||||
const ramMatch = ram.toUpperCase().match(/(\d+)\s*GB/);
|
||||
if (ramMatch && ramMatch[1]) {
|
||||
const ramVal = parseInt(ramMatch[1], 10);
|
||||
if (ramVal < 4) return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. CPU 세대 검사
|
||||
// Intel CPU 세대 판정
|
||||
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
|
||||
if (intelMatch && intelMatch[1]) {
|
||||
const numStr = intelMatch[1];
|
||||
let gen = 0;
|
||||
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
|
||||
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
|
||||
else if (numStr.length === 3) gen = parseInt(numStr.substring(0, 1), 10); // 3자리수 구형 세대 (예: i5-750)
|
||||
|
||||
if (gen > 0 && gen < 8) return true; // 8세대 미만 불가
|
||||
return false;
|
||||
}
|
||||
|
||||
// AMD Ryzen CPU 세대 판정
|
||||
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
|
||||
if (amdMatch && amdMatch[1]) {
|
||||
const numStr = amdMatch[1];
|
||||
let amdGen = 0;
|
||||
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10); // 1xxx, 2xxx 등
|
||||
|
||||
if (amdGen > 0 && amdGen < 2) return true; // Ryzen 1세대 이하는 불가
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apple Silicon은 지원
|
||||
if (cpuUpper.includes('APPLE') || cpuUpper.includes('M1') || cpuUpper.includes('M2') || cpuUpper.includes('M3') || cpuUpper.includes('M4')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 그 외 확실한 구형 CPU 제품군
|
||||
const knownOldCpus = ['CORE2', 'CORE 2', 'PENTIUM', 'CELERON', 'ATHLON', 'PHENOM', 'XEON'];
|
||||
if (knownOldCpus.some(name => cpuUpper.includes(name))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// 세대 매칭은 안되었으나 Intel Core i 시리즈 구조이면 구형(1세대 등)으로 간주
|
||||
if (cpuUpper.includes('I3') || cpuUpper.includes('I5') || cpuUpper.includes('I7') || cpuUpper.includes('I9')) {
|
||||
// i5-620M 처럼 옛날 구형 모바일 칩 등
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
186
src/main.ts
@@ -1,66 +1,56 @@
|
||||
import './styles/common.css';
|
||||
import './styles/login.css';
|
||||
import { state, loadMasterDataFromDB, saveAsset } from './core/state';
|
||||
import { renderNavigation } from './components/Navigation';
|
||||
import { renderDashboard } from './views/DashboardView';
|
||||
import { renderSWTable } from './views/SW_Table';
|
||||
import { renderLocationView } from './views/LocationView';
|
||||
import { initBaseModal } from './components/Modal/BaseModal';
|
||||
import { initHwModal, openHwModal } from './components/Modal/HWModal';
|
||||
import { initSwModal, openSwModal } from './components/Modal/SWModal';
|
||||
import { initSwUserModal } from './components/Modal/SWUserModal';
|
||||
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
|
||||
import { initPartsMasterModal, openPartsMasterModal } from './components/Modal/PartsMasterModal';
|
||||
import { initJobSpecModal, openJobSpecModal } from './components/Modal/JobSpecModal';
|
||||
import { initUserModal, openUserModal } from './components/Modal/UserModal';
|
||||
import { activePartsMasterSubTab } from './views/List/PartsMasterListView';
|
||||
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
|
||||
import { initGuide } from './components/Guide';
|
||||
import { pcFlowModal } from './components/Modal/PCFlowModal';
|
||||
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide';
|
||||
|
||||
// --- DB 저장을 위한 세분화된 헬퍼 함수들 ---
|
||||
async function apiBatchSave(url: string, data: any[], label: string) {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(`${label} DB 저장 실패: ${errorData.error || response.statusText}`);
|
||||
}
|
||||
console.log(`✅ ${label} DB 저장 완료`);
|
||||
} catch (err) {
|
||||
console.error(`❌ ${label} DB 저장 오류:`, err);
|
||||
alert(`${label} 저장 중 오류가 발생했습니다: ${(err as any).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const savePcToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/pc/batch`, state.masterData.pc, '개인PC');
|
||||
const saveServerToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/server/batch`, state.masterData.server, '서버');
|
||||
const saveStorageToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/storage/batch`, state.masterData.storage, '스토리지');
|
||||
const saveNetworkToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/network/batch`, state.masterData.network, '네트워크');
|
||||
const saveEquipToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/equipment/batch`, state.masterData.equipment, '업무지원장비');
|
||||
const saveSwInternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/internal/batch`, state.masterData.swInternal, '내부SW');
|
||||
const saveSwExternalToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/sw/external/batch`, state.masterData.swExternal, '외부SW');
|
||||
const saveCloudToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/cloud/batch`, state.masterData.cloud, '클라우드');
|
||||
const saveSwUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/software/assignment/batch`, state.masterData.swUsers, 'SW사용자');
|
||||
const saveLogsToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/asset/history/batch`, state.masterData.logs, '자산 로그');
|
||||
const saveUsersToDB = () => apiBatchSave(`http://${location.hostname}:3000/api/users/batch`, state.masterData.users, '사용자마스터');
|
||||
|
||||
// 화면 갱신 통합 핸들러
|
||||
function refreshView() {
|
||||
function refreshView(tab?: string) {
|
||||
const mainContent = document.getElementById('main-content')!;
|
||||
if (!mainContent) return;
|
||||
|
||||
if (state.activeSubTab === '대시보드') {
|
||||
|
||||
const activeTab = tab || state.activeSubTab;
|
||||
|
||||
if (activeTab === '대시보드') {
|
||||
renderDashboard(mainContent);
|
||||
return;
|
||||
}
|
||||
|
||||
// 서버 탭이 아닐 경우에는 state.viewMode가 location이더라도 강제로 목록(list) 뷰를 그리도록 함
|
||||
// (state.viewMode의 원래 상태는 보존하여, 서버 탭 복귀 시 최근 보던 모드를 유지함)
|
||||
const isServerTab = activeTab === '서버';
|
||||
const effectiveViewMode = isServerTab ? state.viewMode : 'list';
|
||||
|
||||
mainContent.innerHTML = `
|
||||
<div id="view-body" class="view-container"></div>
|
||||
`;
|
||||
|
||||
const viewBody = document.getElementById('view-body')!;
|
||||
if (effectiveViewMode === 'location') {
|
||||
renderLocationView(viewBody);
|
||||
} else {
|
||||
renderSWTable(mainContent);
|
||||
renderSWTable(viewBody); // 리스트 형식
|
||||
}
|
||||
}
|
||||
|
||||
// 통합 저장 및 갱신
|
||||
async function saveAllDataToDB() {
|
||||
await Promise.all([
|
||||
savePcToDB(), saveServerToDB(), saveStorageToDB(), saveNetworkToDB(),
|
||||
saveEquipToDB(), saveSwInternalToDB(), saveSwExternalToDB(),
|
||||
saveCloudToDB(), saveSwUsersToDB(), saveLogsToDB(), saveUsersToDB()
|
||||
]);
|
||||
// 통합 갱신 (저장은 이미 개별 모달에서 처리됨)
|
||||
async function refreshAllData() {
|
||||
await loadMasterDataFromDB();
|
||||
refreshView();
|
||||
}
|
||||
@@ -74,28 +64,29 @@ function initApp() {
|
||||
|
||||
try {
|
||||
renderNavigation((tab) => {
|
||||
if (tab === '대시보드') {
|
||||
renderDashboard(mainContent);
|
||||
} else {
|
||||
renderSWTable(mainContent);
|
||||
}
|
||||
refreshView();
|
||||
});
|
||||
|
||||
initHwModal(() => saveAllDataToDB(), closeAllModals);
|
||||
initSwModal(() => saveAllDataToDB(), closeAllModals);
|
||||
initHwModal(() => refreshAllData(), closeAllModals);
|
||||
initSwModal(() => refreshAllData(), closeAllModals);
|
||||
initSwUserModal(() => {
|
||||
saveSwUsersToDB().then(() => {
|
||||
loadMasterDataFromDB().then(() => refreshView());
|
||||
});
|
||||
}, closeAllModals);
|
||||
initDomainModal(() => saveAllDataToDB(), closeAllModals);
|
||||
initDomainModal(() => refreshAllData(), closeAllModals);
|
||||
initPartsMasterModal(() => refreshAllData(), closeAllModals);
|
||||
initJobSpecModal(() => refreshAllData(), closeAllModals);
|
||||
initUserModal(() => refreshAllData(), closeAllModals);
|
||||
|
||||
initDashboardDetailModal();
|
||||
initGuide();
|
||||
pcFlowModal.init(() => {
|
||||
loadMasterDataFromDB().then(() => refreshView());
|
||||
});
|
||||
|
||||
loadMasterDataFromDB().then((success) => {
|
||||
if (success) {
|
||||
refreshView();
|
||||
initRoleSwitcher(); // [추가] 역할 전환 토글 초기화
|
||||
}
|
||||
});
|
||||
} catch (e) { console.error('❌ Initialization failed:', e); }
|
||||
@@ -112,22 +103,40 @@ function initApp() {
|
||||
const cat = state.activeCategory;
|
||||
const newId = Math.random().toString(36).substring(2, 9);
|
||||
|
||||
if (cat === 'users') {
|
||||
// 사용자 추가는 renderUserList 내부에서 별도로 처리하거나 여기서 호출 가능
|
||||
// 현재 renderUserList에서 별도로 핸들링하고 있으므로 중복 실행 방지
|
||||
return;
|
||||
}
|
||||
|
||||
if (cat === 'hw') {
|
||||
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
|
||||
if (tab === '부품 마스터') {
|
||||
if (activePartsMasterSubTab === 'job-spec') {
|
||||
openJobSpecModal({ id: '' } as any, 'add');
|
||||
} else {
|
||||
openPartsMasterModal({ id: '' } as any, 'add');
|
||||
}
|
||||
} else {
|
||||
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
|
||||
}
|
||||
} else if (cat === 'sw') {
|
||||
const swType = tab === '외부' ? '외부SW' : (tab === '내부' ? '내부SW' : '외부SW');
|
||||
const swType = tab === '외부SW' ? '외부SW' : (tab === '내부SW' ? '내부SW' : '외부SW');
|
||||
openSwModal({ id: newId, asset_type: swType } as any, 'add');
|
||||
} else if (cat === 'ops') {
|
||||
if (tab === '도메인') openDomainModal(null);
|
||||
else if (tab === '사용자') openUserModal({ id: '' }, 'add');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 부품 마스터 탭으로 바로가기 연동
|
||||
if (target.closest('#btn-goto-parts-master')) {
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '부품 마스터';
|
||||
renderNavigation((tab) => { refreshView(); });
|
||||
refreshView();
|
||||
return;
|
||||
}
|
||||
|
||||
// PC 이동/반납 모달 열기
|
||||
if (target.closest('#btn-pc-flow')) {
|
||||
pcFlowModal.open();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
createIcons({
|
||||
@@ -136,4 +145,61 @@ function initApp() {
|
||||
window.addEventListener('refresh-view', () => refreshView());
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
/**
|
||||
* 헤더 역할 전환 토글 로직
|
||||
*/
|
||||
function initRoleSwitcher() {
|
||||
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
||||
const userLabel = document.querySelector('.role-label.user');
|
||||
const adminLabel = document.querySelector('.role-label.admin');
|
||||
|
||||
if (!checkbox || !userLabel || !adminLabel) return;
|
||||
|
||||
checkbox.addEventListener('change', () => {
|
||||
if (checkbox.checked) {
|
||||
state.currentUserRole = 'admin';
|
||||
userLabel.classList.remove('active');
|
||||
adminLabel.classList.add('active');
|
||||
document.body.classList.add('admin-mode');
|
||||
|
||||
// 관리자 모드 전환 시 대시보드로 이동
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '대시보드';
|
||||
} else {
|
||||
state.currentUserRole = 'user';
|
||||
adminLabel.classList.remove('active');
|
||||
userLabel.classList.add('active');
|
||||
document.body.classList.remove('admin-mode');
|
||||
|
||||
// 실무자 모드 전환 시 서버 목록으로 이동
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '서버';
|
||||
}
|
||||
// 모든 렌더링을 refreshView 하나로 통합하여 규격 유지
|
||||
renderNavigation(() => refreshView());
|
||||
refreshView();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 앱 초기화 (로그인 과정 없이 즉시 시작)
|
||||
*/
|
||||
function initializeAppDirectly() {
|
||||
const loginContainer = document.getElementById('login-container');
|
||||
const appLayout = document.getElementById('app-layout');
|
||||
|
||||
// 기본 권한 설정: 실무자 (User)
|
||||
state.currentUserRole = 'user';
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '서버'; // 실무자 기본 탭
|
||||
|
||||
// 화면 전환
|
||||
if (loginContainer) loginContainer.style.display = 'none';
|
||||
if (appLayout) appLayout.style.display = 'flex';
|
||||
|
||||
// 앱 초기화 및 내비게이션(헤더 포함) 렌더링
|
||||
initApp();
|
||||
renderNavigation((tab) => refreshView(tab));
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initializeAppDirectly);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import './styles/common.css';
|
||||
import './styles/map-editor.css';
|
||||
import './views/map-editor.css';
|
||||
import { MapEditor } from './views/MapEditor';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
[
|
||||
{
|
||||
"법인": "(주)회사1",
|
||||
"자산코드": "ASSET-100",
|
||||
"명칭": "서버 모델A",
|
||||
"위치": "본사 1층",
|
||||
"관리자": "관리자A",
|
||||
"IP주소": "192.168.0.1",
|
||||
"MACaddress": "00:00:00:00:00:01",
|
||||
"HW사양": "Core i7, 16GB RAM",
|
||||
"OS": "Windows 10"
|
||||
}
|
||||
]
|
||||
@@ -1,107 +1,78 @@
|
||||
:root {
|
||||
/* --- System Colors (Added) --- */
|
||||
--color-red: #F21D0D;
|
||||
--color-pink: #E8175E;
|
||||
--color-magenta: #B92ED1;
|
||||
--color-purple: #6D3DC2;
|
||||
--color-navy: #4255bd;
|
||||
--color-blue: #0D8DF2;
|
||||
--color-cyan: #03AEFC;
|
||||
--color-green: #4DB251;
|
||||
--color-yellow: #FFBF00;
|
||||
--color-orange: #FF9800;
|
||||
--color-dahong: #FF3D00;
|
||||
--color-brown: #A0705F;
|
||||
--color-iron: #7F7F7F;
|
||||
--color-steel: #688897;
|
||||
/* --- Vercel Stark Palette --- */
|
||||
--primary: #171717;
|
||||
--on-primary: #ffffff;
|
||||
--body: #4d4d4d;
|
||||
--mute: #71717a;
|
||||
--hairline: #ebebeb;
|
||||
--hairline-strong: #a1a1a1;
|
||||
--canvas: #ffffff;
|
||||
--canvas-soft: #fafafa;
|
||||
--canvas-soft-2: #f5f5f5;
|
||||
|
||||
/* --- Brand Accents --- */
|
||||
--color-blue: #0070f3;
|
||||
--color-cyan: #50e3c2;
|
||||
--color-pink: #ff0080;
|
||||
--color-violet: #7928ca;
|
||||
--color-orange: #f5a623;
|
||||
|
||||
--color-red-light: #FEE9E7;
|
||||
--color-pink-light: #FDE8EF;
|
||||
--color-magenta-light: #F8EBFB;
|
||||
--color-purple-light: #F1ECF9;
|
||||
--color-navy-light: #EDEEF9;
|
||||
--color-blue-light: #E7F4FE;
|
||||
--color-cyan-light: #E6F7FF;
|
||||
--color-green-light: #EEF8EE;
|
||||
--color-yellow-light: #FFF9E6;
|
||||
--color-orange-light: #FFF5E6;
|
||||
--color-dahong-light: #FFECE6;
|
||||
--color-brown-light: #F6F1EF;
|
||||
--color-iron-light: #F3F3F3;
|
||||
--color-steel-light: #F0F4F5;
|
||||
|
||||
--color-red-medium: #FAA59E;
|
||||
--color-pink-medium: #F6A2BF;
|
||||
--color-magenta-medium: #E3ABEC;
|
||||
--color-purple-medium: #C5B1E7;
|
||||
--color-navy-medium: #B3BBE5;
|
||||
--color-blue-medium: #9ED1FA;
|
||||
--color-cyan-medium: #9ADFFE;
|
||||
--color-green-medium: #B8E0B9;
|
||||
--color-yellow-medium: #FFE599;
|
||||
--color-orange-medium: #FFD699;
|
||||
--color-dahong-medium: #FFB199;
|
||||
--color-dahong: #FF3D00;
|
||||
--color-dahong-light: #FFECE6;
|
||||
--color-dahong-medium: #FFB199;
|
||||
--color-dahong-dark: #cc3100;
|
||||
|
||||
/* --- Primary Brand Levels --- */
|
||||
--primary-lv-0: #E9EEED;
|
||||
--primary-lv-1: #D2DCDB;
|
||||
--primary-lv-2: #A5B9B6;
|
||||
--primary-lv-3: #789792;
|
||||
--primary-lv-4: #4B746D;
|
||||
--primary-lv-5: #35635C;
|
||||
--primary-lv-6: #1E5149;
|
||||
--primary-lv-7: #1B443D;
|
||||
--primary-lv-8: #193833;
|
||||
--primary-lv-9: #162A27;
|
||||
|
||||
/* --- Semantic Colors --- */
|
||||
--primary-color: var(--primary-lv-6);
|
||||
--primary-hover: var(--primary-lv-5);
|
||||
--primary-light: var(--primary-lv-0);
|
||||
|
||||
--edit-mode-color: var(--color-dahong);
|
||||
--edit-mode-light: var(--color-dahong-light);
|
||||
--edit-mode-focus: var(--color-dahong-medium);
|
||||
--edit-mode-dark: var(--color-dahong-dark);
|
||||
|
||||
--text-main: #111827;
|
||||
--text-muted: #6B7280;
|
||||
--border-color: #E5E7EB;
|
||||
--bg-color: #F9FAFB;
|
||||
--bg-light: #FAFAFA;
|
||||
--sidebar-bg: #ffffff;
|
||||
/* --- Semantic Alignment --- */
|
||||
--primary-color: var(--primary);
|
||||
--primary-hover: #000000;
|
||||
--primary-light: var(--canvas-soft-2);
|
||||
--text-main: var(--primary);
|
||||
--text-muted: var(--body);
|
||||
--border-color: var(--hairline);
|
||||
--bg-color: var(--canvas-soft);
|
||||
--bg-light: var(--canvas-soft-2);
|
||||
--white: #FFFFFF;
|
||||
--danger: var(--color-red);
|
||||
--info: var(--color-blue);
|
||||
--success: var(--color-green);
|
||||
--warning: var(--color-orange);
|
||||
--danger: #ee0000;
|
||||
--success: #0070f3;
|
||||
--header-height: 64px;
|
||||
|
||||
--dash-primary: #6cc020;
|
||||
--dash-light: #f2f9ec;
|
||||
--dash-danger: #cf222e;
|
||||
/* --- Global Typography Scale (No Upper Limit) --- */
|
||||
--fs-xs: max(10px, 1vmin + 0.1vw);
|
||||
--fs-sm: max(12px, 1.2vmin + 0.2vw);
|
||||
--fs-base: max(13px, 1.4vmin + 0.2vw);
|
||||
--fs-md: max(16px, 2vmin + 0.3vw);
|
||||
--fs-lg: max(20px, 3vmin + 0.4vw);
|
||||
--fs-xl: max(28px, 5vmin + 0.6vw);
|
||||
|
||||
--header-height: 52px;
|
||||
}
|
||||
/* --- Layout Units --- */
|
||||
--header-height: 64px;
|
||||
--spacing-base: 1.5rem;
|
||||
--radius-base: 8px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
letter-spacing: -0.02em;
|
||||
/* 모든 요소에 자간 규칙 일괄 적용 */
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Pretendard Variable', Pretendard, -apple-system, BlinkMacSystemFont, system-ui, Roboto, 'Helvetica Neue', 'Segoe UI', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', sans-serif;
|
||||
font-family: 'Pretendard Variable', 'Pretendard', -apple-system, sans-serif;
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-color);
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
font-size: var(--fs-base);
|
||||
height: 100vh;
|
||||
width: 100vw;
|
||||
overflow: hidden;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
input, textarea {
|
||||
-webkit-user-select: text;
|
||||
-moz-user-select: text;
|
||||
-ms-user-select: text;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.app-layout {
|
||||
@@ -109,180 +80,52 @@ body {
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* --- Main Header & GNB/LNB --- */
|
||||
/* --- Header --- */
|
||||
.main-header {
|
||||
background-color: var(--white);
|
||||
background-color: var(--canvas);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
z-index: 100;
|
||||
height: var(--header-height);
|
||||
}
|
||||
|
||||
.header-container {
|
||||
height: 100%;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.brand {
|
||||
.header-container {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.main-logo {
|
||||
height: 34px;
|
||||
width: auto;
|
||||
.brand { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.main-logo { height: clamp(28px, 4vmin, 40px); width: auto; }
|
||||
.brand h1 { font-size: clamp(0.85rem, 1.4vmin, 1.05rem); font-weight: 600; color: var(--text-main); }
|
||||
|
||||
.integrated-nav { flex: 1; display: flex; align-items: center; margin-left: 2rem; gap: 0.5rem; }
|
||||
.gnb-trigger {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
padding: 0.4rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-radius: 9999px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.gnb-trigger:hover { color: var(--text-main); background: var(--canvas-soft-2); }
|
||||
.gnb-trigger.active { color: var(--text-main); font-weight: 600; background: var(--canvas-soft-2); }
|
||||
|
||||
.brand h1 {
|
||||
font-size: 1.1rem;
|
||||
/* 전체적으로 살짝 축소 */
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.brand h1 .sub-title {
|
||||
font-size: 0.85rem;
|
||||
/* 영문 제목은 더 작게 */
|
||||
color: var(--primary-color);
|
||||
font-weight: 600;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.integrated-nav {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.gnb-trigger {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
padding: 0 1rem;
|
||||
cursor: pointer;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lnb-shelf {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.75rem;
|
||||
height: 60%;
|
||||
border-left: 1px solid var(--border-color);
|
||||
margin-left: 0.25rem;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
.nav-group:hover .lnb-shelf,
|
||||
.nav-group.is-showing-shelf .lnb-shelf {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.lnb-item {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.lnb-item:hover {
|
||||
color: var(--primary-color);
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
.lnb-item.active {
|
||||
color: var(--primary-color);
|
||||
background-color: var(--primary-light);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(-5px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Global Actions & Buttons --- */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.3rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0 0.8rem;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
height: 28px;
|
||||
line-height: 1;
|
||||
white-space: nowrap; /* 텍스트 줄바꿈 방지 */
|
||||
flex-shrink: 0; /* 크기 찌그러짐 방지 */
|
||||
}
|
||||
|
||||
.btn i,
|
||||
.btn svg {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--white);
|
||||
border: 1px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background-color: transparent;
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
color: var(--danger) !important;
|
||||
border-color: var(--danger) !important;
|
||||
}
|
||||
|
||||
/* --- Layout Frame --- */
|
||||
/* --- Layout Content --- */
|
||||
.content-area {
|
||||
flex: 1;
|
||||
padding: 1.25rem 2rem 0; /* 상단 여백 1.25rem 추가 */
|
||||
overflow: hidden;
|
||||
/* 전체 스크롤 차단 */
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.view-container {
|
||||
@@ -291,93 +134,509 @@ body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
/* 내부 스크롤을 유도하기 위해 설정 */
|
||||
}
|
||||
|
||||
/* --- Footer --- */
|
||||
.main-footer {
|
||||
height: 40px;
|
||||
background-color: var(--white);
|
||||
border-top: 1px solid var(--border-color);
|
||||
.view-content-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* --- View Toggle (Vercel Tab Style) --- */
|
||||
.view-toggle {
|
||||
display: inline-flex;
|
||||
background: var(--canvas-soft-2);
|
||||
padding: 0.2rem;
|
||||
border: 1px solid var(--hairline);
|
||||
gap: 0.1rem;
|
||||
border-radius: var(--radius-base);
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
padding: 0.35rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
border-radius: calc(var(--radius-base) - 2px);
|
||||
}
|
||||
|
||||
.toggle-btn:hover { color: var(--text-main); }
|
||||
.toggle-btn.active {
|
||||
background: var(--canvas);
|
||||
color: var(--text-main);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,0.1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Role Toggle Switch --- */
|
||||
.role-toggle-wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 1.5rem;
|
||||
flex-shrink: 0;
|
||||
gap: 0.75rem;
|
||||
background: var(--canvas-soft-2);
|
||||
padding: 0.35rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.main-footer p {
|
||||
font-family: 'Pretendard Variable', Pretendard, sans-serif;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 300;
|
||||
line-height: 1.25rem;
|
||||
letter-spacing: -0.0175rem;
|
||||
color: var(--text-muted);
|
||||
user-select: none;
|
||||
pointer-events: all;
|
||||
-webkit-user-drag: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
.role-label {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 500;
|
||||
color: var(--mute);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.role-label.active {
|
||||
color: var(--primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.role-toggle {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.role-toggle input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.role-slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--hairline-strong);
|
||||
transition: .4s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.role-slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
input:checked + .role-slider {
|
||||
background-color: var(--primary);
|
||||
}
|
||||
|
||||
input:checked + .role-slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* --- Utility Styles (The Standard) --- */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0 1.25rem;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 500;
|
||||
border-radius: 9999px;
|
||||
cursor: pointer;
|
||||
height: clamp(32px, 4.5vmin, 44px);
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn-primary { background-color: var(--primary); color: var(--on-primary); }
|
||||
.btn-primary:hover { background-color: #000; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
|
||||
.btn-outline { background-color: var(--canvas); color: var(--text-main); border: 1px solid var(--hairline); }
|
||||
.btn-outline:hover { border-color: var(--hairline-strong); background: var(--canvas-soft); }
|
||||
|
||||
.btn-sm { height: clamp(28px, 3.5vmin, 36px); padding: 0 1rem; font-size: var(--fs-xs); }
|
||||
.btn-danger { color: var(--danger) !important; border-color: var(--danger) !important; }
|
||||
|
||||
/* --- Form Elements --- */
|
||||
.form-select-sm {
|
||||
height: clamp(28px, 3.5vmin, 36px);
|
||||
padding: 0 0.5rem;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 6px;
|
||||
font-size: var(--fs-xs);
|
||||
outline: none;
|
||||
background-color: var(--canvas);
|
||||
color: var(--primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.form-select-sm:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border-radius: 9999px;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
}
|
||||
.badge-primary { background-color: var(--primary); color: var(--on-primary); }
|
||||
.badge.b-green { background-color: #e6f4ea; color: #137333; } /* 운영/중급 */
|
||||
.badge.b-yellow { background-color: #fffbeb; color: #b45309; } /* 재고/보급 */
|
||||
.badge.b-purple { background-color: #f3e8ff; color: #6b21a8; } /* 수리/최상급 */
|
||||
.badge.b-primary { background-color: #e0e7ff; color: #3730a3; } /* GNB/상급 */
|
||||
.badge.badge-danger { background-color: #fce8e6; color: #c5221f; } /* 폐기/교체대상 */
|
||||
.badge.badge-muted { background-color: #f1f3f4; color: #5f6368; } /* 폐기 */
|
||||
.badge.badge-light { background-color: var(--canvas-soft-2); color: var(--mute); border: 1px solid var(--hairline); } /* 재고기본 */
|
||||
|
||||
/* --- Form Elements Extra --- */
|
||||
.input-with-icon {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-with-icon input {
|
||||
padding-left: 2.5rem !important;
|
||||
}
|
||||
|
||||
.input-with-icon i,
|
||||
.input-with-icon .icon-sm {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--mute);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.autocomplete-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.12);
|
||||
z-index: 1100;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.autocomplete-item {
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--hairline-soft, #f5f5f5);
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.autocomplete-item:hover {
|
||||
background: var(--canvas-soft-2);
|
||||
}
|
||||
|
||||
.autocomplete-item-empty {
|
||||
padding: 1rem;
|
||||
color: var(--mute);
|
||||
font-size: var(--fs-xs);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.suggestion-name {
|
||||
font-weight: 600;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.suggestion-meta {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--mute);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* --- Summary & Selection Cards --- */
|
||||
.summary-info-card {
|
||||
padding: 1.25rem;
|
||||
background: var(--canvas-soft);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.user-pc-selection-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 250px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.user-pc-item {
|
||||
padding: 12px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: var(--canvas);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.user-pc-item:hover {
|
||||
border-color: var(--hairline-strong);
|
||||
background: var(--canvas-soft);
|
||||
}
|
||||
|
||||
.user-pc-item.selected {
|
||||
border-color: var(--primary);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
.pc-item-code {
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.pc-item-meta {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--mute);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.empty-list-message {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--mute);
|
||||
padding: 1rem 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* --- Global Utilities --- */
|
||||
.hidden { display: none !important; }
|
||||
.clickable { cursor: pointer; transition: opacity 0.2s; }
|
||||
.clickable:hover { opacity: 0.8; }
|
||||
|
||||
/* Flexbox & Grid Utilities */
|
||||
.flex { display: flex; }
|
||||
.flex-col { display: flex; flex-direction: column; }
|
||||
.flex-row { display: flex; flex-direction: row; }
|
||||
.items-center { align-items: center; }
|
||||
.justify-between { justify-content: space-between; }
|
||||
.justify-center { justify-content: center; }
|
||||
.gap-1 { gap: 0.25rem; }
|
||||
.gap-2 { gap: 0.5rem; }
|
||||
.gap-3 { gap: 0.75rem; }
|
||||
.gap-4 { gap: 1rem; }
|
||||
.gap-6 { gap: 1.5rem; }
|
||||
.gap-y-3 { row-gap: 0.75rem; }
|
||||
.gap-x-4 { column-gap: 1rem; }
|
||||
.mb-0 { margin-bottom: 0 !important; }
|
||||
.mb-4 { margin-bottom: 1rem !important; }
|
||||
.mb-6 { margin-bottom: 1.5rem !important; }
|
||||
.pb-4 { padding-bottom: 1rem !important; }
|
||||
.p-4 { padding: 1rem !important; }
|
||||
.p-2 { padding: 0.5rem !important; }
|
||||
.p-8 { padding: 2rem !important; }
|
||||
.ml-auto { margin-left: auto !important; }
|
||||
.self-end { align-self: flex-end !important; }
|
||||
.font-medium { font-weight: 500; }
|
||||
.text-muted { color: var(--mute) !important; }
|
||||
.mt-12 { margin-top: 3rem !important; }
|
||||
.icon-sm { width: 16px; height: 16px; }
|
||||
.h-90vh { height: 90vh !important; }
|
||||
.pt-0 { padding-top: 0 !important; }
|
||||
.font-semibold { font-weight: 600; }
|
||||
|
||||
|
||||
|
||||
.w-full { width: 100%; }
|
||||
.h-full { height: 100%; }
|
||||
|
||||
/* Text Utilities */
|
||||
.text-center { text-align: center !important; }
|
||||
.text-right { text-align: right !important; }
|
||||
.text-left { text-align: left !important; }
|
||||
.font-bold { font-weight: 700; }
|
||||
.bg-primary-light { background-color: var(--primary-light) !important; }
|
||||
.text-success { color: var(--success) !important; }
|
||||
.text-danger { color: var(--danger) !important; }
|
||||
.text-blue { color: var(--color-blue) !important; }
|
||||
.text-orange { color: var(--color-orange) !important; }
|
||||
/* --- Unified Search & Filter Bar --- */
|
||||
.search-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-base);
|
||||
padding: 1.25rem var(--spacing-base);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
align-items: flex-end;
|
||||
background: var(--canvas);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
.search-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.text-nowrap {
|
||||
.search-item.flex-1 {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
}
|
||||
|
||||
.search-item label {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.search-item input,
|
||||
.search-item select {
|
||||
height: clamp(34px, 4.5vmin, 44px);
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 6px;
|
||||
font-size: var(--fs-sm);
|
||||
outline: none;
|
||||
background-color: var(--canvas);
|
||||
color: var(--primary);
|
||||
transition: border-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.search-item select {
|
||||
cursor: pointer;
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.search-item input:focus,
|
||||
.search-item select:focus {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.header-action-group {
|
||||
margin-left: auto;
|
||||
align-self: flex-end;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-view-toggle-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
height: clamp(34px, 4.5vmin, 44px);
|
||||
padding: 0 0.5rem;
|
||||
font-size: var(--fs-sm);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.list-view-toggle-label input[type="checkbox"] {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.map-pagination-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-left: 0.5rem;
|
||||
padding-left: 1rem;
|
||||
border-left: 1px solid var(--hairline);
|
||||
height: clamp(34px, 4.5vmin, 44px);
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--mute);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Utility Styles --- */
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
/* --- Modal & View Header Layouts --- */
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
/* --- Asset Identity & Header Styling (Global) --- */
|
||||
.header-identity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.badge-muted {
|
||||
background-color: #9CA3AF;
|
||||
color: white;
|
||||
.asset-code-title {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.text-tag {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
background-color: var(--bg-light);
|
||||
.service-type-badge {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--on-primary);
|
||||
background: var(--primary);
|
||||
padding: 4px 8px;
|
||||
border-radius: 9999px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
.asset-type-label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
color: var(--mute);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* --- Responsive Design (Tablet & Mobile) --- */
|
||||
@media (max-width: 1200px) {
|
||||
.header-container { gap: 0.75rem; padding: 0 1rem; }
|
||||
.brand h1 { font-size: 1rem; }
|
||||
.brand h1 .sub-title { font-size: 0.75rem; }
|
||||
.main-footer {
|
||||
border-top: 1px solid var(--border-color);
|
||||
background-color: var(--canvas);
|
||||
color: var(--mute);
|
||||
padding: 1rem 2rem;
|
||||
text-align: right;
|
||||
font-size: var(--fs-xs);
|
||||
flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.main-header { height: auto; padding: 0.5rem 0; }
|
||||
.header-container { flex-direction: column; align-items: flex-start; gap: 0.5rem; }
|
||||
.integrated-nav { width: 100%; justify-content: flex-start; border-top: 1px solid var(--border-color); padding-top: 0.5rem; }
|
||||
.header-actions { width: 100%; justify-content: flex-end; padding-top: 0.5rem; }
|
||||
.content-area { padding: 0 1rem; }
|
||||
.main-footer p {
|
||||
margin: 0;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.brand h1 .sub-title { display: none; } /* 아주 좁은 화면에선 영문명 숨김 */
|
||||
.header-actions .btn span { display: none; } /* 버튼 텍스트 숨기고 아이콘만 표시 */
|
||||
.header-actions .btn { padding: 0 0.5rem; }
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
/* --- Dashboard View Specific Styles --- */
|
||||
|
||||
.dashboard-section-title {
|
||||
padding: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background-color: var(--white);
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.stat-card .title {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.stat-card .value {
|
||||
font-size: 2.2rem;
|
||||
font-weight: 800;
|
||||
color: var(--primary-color);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-layout-2col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background-color: var(--white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.dashboard-card canvas {
|
||||
flex: 1;
|
||||
width: 100% !important;
|
||||
max-height: 280px;
|
||||
}
|
||||
115
src/styles/login.css
Normal file
@@ -0,0 +1,115 @@
|
||||
/* Login Screen Styles */
|
||||
|
||||
.login-layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-color);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
background-color: var(--white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 3rem;
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.08);
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
height: 52px;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
font-size: 2.33rem;
|
||||
font-weight: 900;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-selection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
border: 2px solid var(--bg-light);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.role-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--white);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 20px rgba(30, 81, 73, 0.08);
|
||||
}
|
||||
|
||||
.role-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background-color: var(--white);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.25rem;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.role-card:hover .role-icon {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--white);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.role-card h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.role-card p {
|
||||
font-size: 1.08rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 3rem;
|
||||
text-align: center;
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -1,693 +0,0 @@
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.4);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.2s ease, visibility 0.2s ease;
|
||||
}
|
||||
|
||||
.modal-overlay:not(.hidden) { opacity: 1; visibility: visible; }
|
||||
|
||||
.modal-content {
|
||||
background-color: var(--white);
|
||||
width: 100%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1);
|
||||
transform: translateY(20px);
|
||||
transition: transform 0.2s ease;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-overlay:not(.hidden) .modal-content { transform: translateY(0); }
|
||||
|
||||
.modal-header {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--white);
|
||||
padding: 1rem 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.modal-header .btn-icon {
|
||||
color: var(--white) !important;
|
||||
cursor: pointer;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--primary-color);
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.grid-form {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.form-group.full-width {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
/* Section Title for Grouping */
|
||||
.form-section-title {
|
||||
grid-column: span 2;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
padding: 1.5rem 0 0.5rem 0; /* 패딩 조정 */
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
margin-bottom: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Modal Readonly/Edit Mode Interaction */
|
||||
.grid-form.is-view-mode input,
|
||||
.grid-form.is-view-mode select,
|
||||
.grid-form.is-view-mode textarea {
|
||||
border: none !important;
|
||||
background-color: transparent !important;
|
||||
padding-left: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
pointer-events: none !important;
|
||||
color: var(--text-main) !important;
|
||||
font-weight: 500 !important;
|
||||
appearance: none !important;
|
||||
-webkit-appearance: none !important;
|
||||
-moz-appearance: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.grid-form.is-view-mode input[type="file"] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.grid-form.is-view-mode .btn-helper {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.grid-form.is-view-mode button:not(.btn-loc-action) {
|
||||
pointer-events: none !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.grid-form.is-view-mode select::-ms-expand {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.grid-form.is-edit-mode input,
|
||||
.grid-form.is-edit-mode select,
|
||||
.grid-form.is-edit-mode textarea {
|
||||
color: var(--edit-mode-color); /* 수정 시 글자색 변경 */
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* 수동 수정 불가 필드 (자산번호 등) 전용 스타일 */
|
||||
.grid-form input[readonly] {
|
||||
border-color: transparent !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: none !important;
|
||||
color: var(--text-main) !important;
|
||||
font-weight: 500 !important;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.grid-form.is-edit-mode input:focus,
|
||||
.grid-form.is-edit-mode select:focus,
|
||||
.grid-form.is-edit-mode textarea:focus {
|
||||
border-color: var(--edit-mode-color);
|
||||
box-shadow: 0 0 0 2px var(--edit-mode-focus);
|
||||
}
|
||||
|
||||
.form-section-title:first-child {
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
padding: 0.625rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #FAFAFA;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.footer-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Wide Modal for History/Detail */
|
||||
.modal-content.wide {
|
||||
max-width: 950px;
|
||||
}
|
||||
|
||||
/* Upload Preview Specific */
|
||||
.upload-sidebar {
|
||||
width: 240px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
background-color: var(--bg-light);
|
||||
padding: 1.5rem 1rem;
|
||||
overflow-y: auto;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.upload-tab-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.upload-tab-btn {
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
transition: all 0.2s;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.upload-tab-btn:hover {
|
||||
background-color: var(--white);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.upload-tab-btn.active {
|
||||
background-color: var(--white);
|
||||
color: var(--primary-color);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
border-color: var(--border-color);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.preview-table-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--white);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.preview-stats-bar {
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.preview-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
min-width: max-content;
|
||||
}
|
||||
|
||||
.preview-table thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
background-color: var(--bg-light);
|
||||
box-shadow: 0 1px 0 var(--border-color);
|
||||
}
|
||||
|
||||
.preview-table th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.preview-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.preview-table tr:hover {
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.modal-body-split {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
min-height: 480px;
|
||||
}
|
||||
|
||||
.modal-form-area {
|
||||
flex: 1.2;
|
||||
}
|
||||
|
||||
.modal-history-area {
|
||||
flex: 0.8;
|
||||
border-left: 1px solid var(--border-color);
|
||||
padding-left: 1.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.history-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.history-header h3 {
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
/* 읽기 전용 필드 (자산번호 등) 통일 스타일 */
|
||||
.is-readonly-field {
|
||||
border-color: transparent !important;
|
||||
background-color: transparent !important;
|
||||
pointer-events: none !important;
|
||||
color: var(--text-main) !important;
|
||||
font-weight: 600 !important;
|
||||
cursor: default;
|
||||
padding-left: 0 !important;
|
||||
}
|
||||
|
||||
/* 입력 필드 + 버튼 그룹 (자산번호 생성 등) */
|
||||
.input-with-btn {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-with-btn input {
|
||||
flex: 1;
|
||||
min-width: 0; /* flex 컨테이너 안에서 너비 압축 방지 */
|
||||
}
|
||||
|
||||
.input-with-btn .btn {
|
||||
flex-shrink: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.history-timeline {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 500px;
|
||||
padding-right: 0.5rem;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
position: relative;
|
||||
padding-left: 1.25rem;
|
||||
padding-bottom: 1.5rem;
|
||||
border-left: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.history-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -7px;
|
||||
top: 0;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--white);
|
||||
border: 2px solid var(--primary-color);
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.history-user {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.history-details {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-main);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.empty-history {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
/* Dashboard Detail Modal Table Fixed Header */
|
||||
#dashboard-detail-modal .modal-body {
|
||||
overflow-y: auto;
|
||||
max-height: calc(80vh - 120px);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 모달 내부의 table-container 기존 전역 스타일 무력화 */
|
||||
#dashboard-detail-modal .table-container {
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
#dashboard-detail-modal table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
}
|
||||
|
||||
#dashboard-detail-modal thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: var(--bg-light);
|
||||
z-index: 10;
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
box-shadow: none;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#dashboard-detail-modal tbody td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
#dashboard-detail-modal tbody tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
#dashboard-detail-modal tbody tr:hover {
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
/* 뱃지 스타일 (대시보드 상세 목록용) */
|
||||
#dashboard-detail-modal .badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
#dashboard-detail-modal .badge-outline {
|
||||
border: 1px solid var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
background: var(--primary-light);
|
||||
}
|
||||
|
||||
#dashboard-detail-modal .badge-sw {
|
||||
border: 1px solid #3b82f6;
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* Layout Map & Image Picker Styles */
|
||||
.layout-map-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: crosshair;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-map-container.readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.layout-map-container.readonly .map-seat-obj {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.digital-overlay-layer {
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.digital-map-svg {
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
|
||||
.map-seat-obj {
|
||||
fill: rgba(30, 81, 73, 0.02);
|
||||
stroke: rgba(30, 81, 73, 0.15); /* 평상시에도 아주 연하게 보이게 수정 */
|
||||
stroke-width: 0.2;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.map-seat-obj:hover {
|
||||
fill: rgba(30, 81, 73, 0.3);
|
||||
stroke: rgba(30, 81, 73, 0.6);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.layout-map-img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.layout-marker {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: rgba(30, 81, 73, 0.7);
|
||||
border: 2px solid #FFFFFF;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.pulse-marker {
|
||||
background-color: rgba(255, 61, 0, 0.8) !important;
|
||||
border-color: #FFFFFF !important;
|
||||
animation: marker-pulse 1.2s infinite;
|
||||
}
|
||||
|
||||
@keyframes marker-pulse {
|
||||
0% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0.6); }
|
||||
70% { transform: translate(-50%, -50%) scale(1.6); box-shadow: 0 0 0 10px rgba(255, 61, 0, 0); }
|
||||
100% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0); }
|
||||
}
|
||||
|
||||
.image-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
z-index: 2500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.image-picker-header {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.image-picker-header h3 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.image-picker-content {
|
||||
background: white;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
max-width: 95vw;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.picker-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 40px;
|
||||
height: 60px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.picker-nav:hover { background: rgba(0,0,0,0.8); }
|
||||
.picker-nav.disabled { opacity: 0.2; cursor: not-allowed; }
|
||||
.picker-nav.prev { left: 10px; border-radius: 0 4px 4px 0; }
|
||||
.picker-nav.next { right: 10px; border-radius: 4px 0 0 4px; }
|
||||
|
||||
.image-picker-footer {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-loc-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
font-size: 10px !important;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
height: 24px;
|
||||
min-width: 52px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-loc-view {
|
||||
background-color: var(--primary-color);
|
||||
color: white !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-loc-view:hover {
|
||||
background-color: #163d37;
|
||||
}
|
||||
|
||||
.location-detail-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.location-detail-container select {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
/* --- Page Header for Description --- */
|
||||
.page-header {
|
||||
padding: 1rem 0 0.2rem 0;
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-title i {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.page-title svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* --- Table View & Filter Styles --- */
|
||||
|
||||
.search-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem; /* 간격 축소 및 통일 */
|
||||
padding: 1.2rem 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
align-items: flex-end;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.search-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.search-item.flex-1 {
|
||||
flex: 1; /* 검색창이 남은 공간을 채우도록 설정 */
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.search-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem; /* 버튼들 간의 간격 */
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-actions .btn {
|
||||
height: 38px;
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.search-item label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.search-item input,
|
||||
.search-item select {
|
||||
height: 38px;
|
||||
padding: 0 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
/* 셀렉트 박스 화살표 여백 절대 고정 (수정 금지) */
|
||||
.search-item select {
|
||||
padding-right: 2.5rem !important;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.search-item input:focus,
|
||||
.search-item select:focus {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
/* 필터 초기화 버튼 크기 조정 (입력창 높이 38px에 맞춤) */
|
||||
.btn-reset {
|
||||
height: 38px !important;
|
||||
color: var(--text-muted) !important;
|
||||
padding: 0 1.2rem !important;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 0; /* 불필요한 마진 제거 */
|
||||
}
|
||||
|
||||
.table-container {
|
||||
flex: 1;
|
||||
background-color: var(--white);
|
||||
border-top: 1px solid var(--border-color);
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.8rem 1.2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: left; /* 기본은 좌측 정렬 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--bg-light) !important;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
box-shadow: inset 0 1px 0 var(--border-color), inset 0 -1px 0 var(--border-color); /* 상하 테두리 보정 */
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 13px;
|
||||
color: var(--text-main);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: var(--bg-color);
|
||||
}
|
||||
|
||||
/* 정렬 클래스 강제 적용 */
|
||||
.text-center { text-align: center !important; }
|
||||
.text-right { text-align: right !important; }
|
||||
.text-left { text-align: left !important; }
|
||||
|
||||
/* 메모 컬럼 전용: 가장 길게 표시되도록 너비 조정 및 줄바꿈 허용 */
|
||||
.col-memo {
|
||||
width: 20%;
|
||||
min-width: 250px;
|
||||
white-space: normal !important;
|
||||
word-break: break-all;
|
||||
line-height: 1.4;
|
||||
text-align: left !important;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
padding: 0.25rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.btn-icon:hover {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.btn-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* --- Table Sorting --- */
|
||||
th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
transition: background-color 0.2s;
|
||||
position: relative;
|
||||
padding-right: 1.8rem !important; /* 아이콘 공간 확보 */
|
||||
}
|
||||
|
||||
th.sortable:hover {
|
||||
background-color: #F3F4F6;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
th.sortable::after {
|
||||
content: '↕';
|
||||
position: absolute;
|
||||
right: 0.6rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 11px;
|
||||
opacity: 0.3;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
th.sortable.asc::after {
|
||||
content: '▲';
|
||||
opacity: 1;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
th.sortable.desc::after {
|
||||
content: '▼';
|
||||
opacity: 1;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
@@ -13,13 +13,13 @@ export function renderSwDashboard(container: HTMLElement) {
|
||||
// 통합 SW 데이터
|
||||
const allSw = [...state.masterData.swExternal, ...state.masterData.swInternal];
|
||||
|
||||
allSw.forEach(sw => {
|
||||
allSw.forEach((sw: any) => {
|
||||
const assigned = state.masterData.swUsers.filter(u => u.sw_id === sw.id).length;
|
||||
const qty = typeof sw[ASSET_SCHEMA.ASSET_COUNT.key] === 'number' ? sw[ASSET_SCHEMA.ASSET_COUNT.key] : parseInt(sw[ASSET_SCHEMA.ASSET_COUNT.key]||'0', 10);
|
||||
const priceStr = sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key] ? String(sw[ASSET_SCHEMA.PURCHASE_AMOUNT.key]).replace(/,/g, '') : '0';
|
||||
const price = parseInt(priceStr, 10) || 0;
|
||||
|
||||
if (sw.asset_type === '외부SW' || sw.type === '외부SW') {
|
||||
if (sw.asset_type === '외부SW') {
|
||||
extQty += qty; extUsed += assigned; extTotal++;
|
||||
if (isSWExpiring(sw)) extExp++;
|
||||
if (sw[ASSET_SCHEMA.PURCHASE_DATE.key]?.startsWith('2026')) extCost2026 += price;
|
||||
@@ -33,38 +33,38 @@ export function renderSwDashboard(container: HTMLElement) {
|
||||
const intPer = intQty > 0 ? Math.round((intUsed/intQty)*100) : 0;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="view-container">
|
||||
<div class="view-container bg-soft">
|
||||
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
|
||||
|
||||
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
|
||||
<div class="dashboard-card" data-action="ext-usage" style="cursor:pointer; min-height:auto;">
|
||||
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">외부 소프트웨어 사용율</span>
|
||||
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">${extQty}카피 중 ${extUsed}개 할당</div>
|
||||
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${extPer}%</div>
|
||||
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
|
||||
<div style="width: ${extPer}%; height: 100%; background-color: var(--dash-primary);"></div>
|
||||
<div class="dashboard-layout-2col mb-6">
|
||||
<div class="dashboard-card clickable" data-action="ext-usage">
|
||||
<div class="stat-label">외부 소프트웨어 사용율</div>
|
||||
<div class="stat-sub">${extQty}카피 중 ${extUsed}개 할당</div>
|
||||
<div class="stat-value text-primary">${extPer}%</div>
|
||||
<div class="stat-progress-bar">
|
||||
<div class="progress-fill" style="width: ${extPer}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-card" data-action="int-usage" style="cursor:pointer; min-height:auto;">
|
||||
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">내부 소프트웨어 현황</span>
|
||||
<div style="font-size: 0.8125rem; color:var(--text-muted); margin-bottom: 1rem;">등록된 내부 솔루션: ${intTotal}개</div>
|
||||
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">${intPer}%</div>
|
||||
<div style="width: 100%; height: 4px; background-color: var(--border-color); border-radius: 2px; overflow: hidden; margin-top: 0.5rem;">
|
||||
<div style="width: ${intPer}%; height: 100%; background-color: var(--dash-primary);"></div>
|
||||
<div class="dashboard-card clickable" data-action="int-usage">
|
||||
<div class="stat-label">내부 소프트웨어 현황</div>
|
||||
<div class="stat-sub">등록된 내부 솔루션: ${intTotal}개</div>
|
||||
<div class="stat-value text-primary">${intPer}%</div>
|
||||
<div class="stat-progress-bar">
|
||||
<div class="progress-fill" style="width: ${intPer}%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
|
||||
|
||||
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:1.5rem; margin-bottom:1.5rem;">
|
||||
<div class="dashboard-card" style="min-height:auto;">
|
||||
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">외부 SW 누적 비용 (2026)</span>
|
||||
<div style="font-size: 2rem; font-weight:700; color:var(--dash-primary);">₩ ${extCost2026.toLocaleString()}</div>
|
||||
<div class="dashboard-layout-2col">
|
||||
<div class="dashboard-card">
|
||||
<div class="stat-label">외부 SW 누적 비용 (2026)</div>
|
||||
<div class="stat-value text-primary">₩ ${extCost2026.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="dashboard-card" style="min-height:auto;">
|
||||
<span style="font-size:1rem; font-weight:700; color:var(--text-main);">내부 SW 누적 비용 (2026)</span>
|
||||
<div style="font-size: 2rem; font-weight:700; color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
|
||||
<div class="dashboard-card">
|
||||
<div class="stat-label">내부 SW 누적 비용 (2026)</div>
|
||||
<div class="stat-value text-blue">₩ ${intCost2026.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
505
src/views/Dashboard/dashboard.css
Normal file
@@ -0,0 +1,505 @@
|
||||
/* --- Vercel Inspired Premium Dashboard --- */
|
||||
.dashboard-section-title {
|
||||
padding: 0;
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: clamp(0.5rem, 1.5vmin, 1.5rem);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* Background Mesh Gradient for Stats Row */
|
||||
.dashboard-stats-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding: 0;
|
||||
margin-bottom: clamp(1rem, 2vmin, 2rem);
|
||||
background: radial-gradient(at 0% 0%, rgba(80, 227, 194, 0.05) 0px, transparent 50%),
|
||||
radial-gradient(at 100% 0%, rgba(121, 40, 202, 0.05) 0px, transparent 50%);
|
||||
}
|
||||
|
||||
.stat-group-item {
|
||||
flex: 1;
|
||||
min-width: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--spacing-base);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stat-group-item.bordered {
|
||||
border-left: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.stat-group-item .stat-label {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 500;
|
||||
color: var(--mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-group-item .stat-value {
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
line-height: 1;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.stat-group-item .stat-value span {
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 400;
|
||||
margin-left: 6px;
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
.stat-group-item .stat-sub {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--body);
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* --- Technical Data Alignment --- */
|
||||
.text-primary {
|
||||
color: var(--color-blue) !important;
|
||||
}
|
||||
|
||||
.detail-stat-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.stat-title {
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.detail-stat-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.loc-summary {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.loc-summary span {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
.loc-summary span strong {
|
||||
color: var(--primary);
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.type-summary {
|
||||
display: flex;
|
||||
gap: 0.8rem;
|
||||
flex-wrap: wrap;
|
||||
opacity: 0.9;
|
||||
border-top: 1px dashed var(--hairline);
|
||||
padding-top: 8px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.type-summary span {
|
||||
cursor: help;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
.type-summary span strong {
|
||||
color: var(--primary);
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* --- Enhanced Location View Layout --- */
|
||||
.location-view-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background: var(--canvas);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-filter-bar {
|
||||
/* Inherit from .search-bar in common.css */
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.location-main-content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
|
||||
background: var(--canvas);
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.map-container-section {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--canvas);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-frame-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.map-overlay {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.no-map-message {
|
||||
padding: 5rem;
|
||||
text-align: center;
|
||||
color: var(--mute);
|
||||
font-size: var(--fs-base);
|
||||
}
|
||||
|
||||
.location-box-point {
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
/* --- Asset Detail Sidebar --- */
|
||||
.asset-list-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: var(--canvas);
|
||||
}
|
||||
|
||||
.section-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
background: var(--canvas);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mini-table-wrapper {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
margin: 0;
|
||||
font-size: var(--fs-base);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.detail-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.header-identity {
|
||||
display: flex;
|
||||
align-items: center; /* Changed from baseline to center for perfect vertical alignment */
|
||||
gap: 8px;
|
||||
flex: 1;
|
||||
flex-wrap: wrap; /* Allow wrapping on very small screens */
|
||||
}
|
||||
|
||||
.asset-code-title {
|
||||
font-size: var(--fs-md);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
letter-spacing: -0.02em;
|
||||
line-height: 1; /* Reset line-height to prevent baseline shifts */
|
||||
}
|
||||
|
||||
.service-type-badge {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--on-primary);
|
||||
background: var(--primary);
|
||||
padding: 4px 8px; /* Adjusted padding for better vertical centering */
|
||||
border-radius: 9999px;
|
||||
text-transform: uppercase;
|
||||
line-height: 1; /* Match line-height */
|
||||
}
|
||||
|
||||
.asset-type-label {
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 500;
|
||||
color: var(--mute);
|
||||
line-height: 1; /* Match line-height */
|
||||
}
|
||||
|
||||
.asset-detail-sidebar {
|
||||
padding: 1.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: 2rem;
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.detail-section-title {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--mute);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
padding-bottom: 8px;
|
||||
margin-bottom: 1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.detail-grid-2col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem 1.5rem;
|
||||
}
|
||||
|
||||
.detail-item.full-width {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.detail-label-sm {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--mute);
|
||||
font-weight: 500;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.dashboard-layout-2col {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 2rem;
|
||||
padding: 0 2rem 2rem;
|
||||
}
|
||||
|
||||
.dashboard-card {
|
||||
background: var(--canvas);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.dashboard-card.clickable:hover {
|
||||
border-color: var(--primary);
|
||||
box-shadow: 0 12px 30px rgba(0,0,0,0.08);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.stat-progress-bar {
|
||||
height: 8px;
|
||||
background: var(--canvas-soft-2);
|
||||
border-radius: 9999px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: var(--primary);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.dashboard-card .stat-label {
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 600;
|
||||
color: var(--mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.dashboard-card .stat-value {
|
||||
font-size: var(--fs-xl);
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.dashboard-card .stat-sub {
|
||||
font-size: var(--fs-sm);
|
||||
color: var(--body);
|
||||
}
|
||||
|
||||
.bg-soft {
|
||||
background-color: var(--canvas-soft) !important;
|
||||
}
|
||||
|
||||
.chart-placeholder {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.circular-progress {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border-radius: 50%;
|
||||
background: conic-gradient(var(--primary) calc(var(--val) * 1%), var(--hairline) 0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.circular-progress::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
background: var(--canvas);
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.circular-progress::after {
|
||||
content: attr(style); /* This is a hack to get the value, but we'll use innerHTML in TS if needed */
|
||||
position: absolute;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.system-dashboard {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.warning-badge-orange { background-color: var(--color-orange); color: var(--white); padding: 2px 8px; border-radius: 9999px; font-size: var(--fs-xs); font-weight: 600; }
|
||||
.warning-badge { background-color: var(--danger); color: var(--white); padding: 2px 8px; border-radius: 9999px; font-size: var(--fs-xs); font-weight: 600; }
|
||||
|
||||
.list-section {
|
||||
flex: 1.3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 1rem 1.5rem 0 0;
|
||||
border-right: 1px solid var(--hairline);
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
flex: 0.7;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
padding: 1rem 0 0 1.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.detail-empty-state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
.detail-photo-wrapper {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
border: 1px solid var(--hairline);
|
||||
background: #f0f0f0;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.no-photo-state {
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
color: var(--mute);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/* Responsive Overrides */
|
||||
@media (max-width: 1440px) {
|
||||
.location-main-content {
|
||||
grid-template-columns: 1.5fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.location-main-content {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto 1fr;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.map-container-section {
|
||||
height: 400px;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,14 @@
|
||||
import { state } from '../core/state';
|
||||
import { renderHwDashboard } from './Dashboard/HwDashboard';
|
||||
import { renderSwDashboard } from './Dashboard/SwDashboard';
|
||||
import './Dashboard/dashboard.css';
|
||||
|
||||
/**
|
||||
* 대시보드 렌더링 통합 허브
|
||||
* 대시보드 렌더링 통합 허브 (Vercel Style Normalized)
|
||||
*/
|
||||
export function renderDashboard(mainContent: HTMLElement) {
|
||||
if (!mainContent) return;
|
||||
mainContent.innerHTML = '';
|
||||
|
||||
|
||||
// 기존 차트 리소스 해제
|
||||
if (state.activeCharts) {
|
||||
state.activeCharts.forEach((c: any) => {
|
||||
@@ -17,11 +17,21 @@ export function renderDashboard(mainContent: HTMLElement) {
|
||||
}
|
||||
state.activeCharts = [];
|
||||
|
||||
mainContent.innerHTML = `
|
||||
<div class="view-content-wrapper">
|
||||
<div id="dashboard-scroll-container" class="table-container" style="padding: 0;">
|
||||
<div id="dashboard-inner-content"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const innerContent = document.getElementById('dashboard-inner-content')!;
|
||||
|
||||
if (state.activeCategory === 'hw') {
|
||||
renderHwDashboard(mainContent);
|
||||
renderHwDashboard(innerContent);
|
||||
} else if (state.activeCategory === 'sw') {
|
||||
renderSwDashboard(mainContent);
|
||||
renderSwDashboard(innerContent);
|
||||
} else {
|
||||
mainContent.innerHTML = `<div class="dashboard-section-title" style="padding:2rem;">운영 서비스 대시보드는 준비 중입니다.</div>`;
|
||||
innerContent.innerHTML = `<div class="dashboard-section-title" style="padding:2rem;">해당 카테고리의 대시보드는 준비 중입니다.</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
||||
import { dynamicSort, renderPageHeader } from '../../core/utils';
|
||||
import { dynamicSort, renderPageHeader, calculateAssetAge, formatInline, isWindows11Incompatible, calculatePcScoreDeductive } from '../../core/utils';
|
||||
import { setupTableSorting, SortState } from '../../core/tableHandler';
|
||||
import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
|
||||
import { createIcons, RefreshCcw, Plus, Edit2, Trash2, Users, Cloud, CreditCard, DollarSign, Paperclip } from 'lucide';
|
||||
import { state } from '../../core/state';
|
||||
import { IMAGE_LOCATIONS } from '../../components/Modal/SharedData';
|
||||
import './table.css';
|
||||
|
||||
declare var Chart: any;
|
||||
let pcFlowChartInstance: any = null;
|
||||
|
||||
export interface ColumnDef {
|
||||
header: string;
|
||||
@@ -24,136 +29,739 @@ export interface ListViewConfig {
|
||||
showLoc?: boolean;
|
||||
showField?: boolean;
|
||||
showType?: boolean;
|
||||
showStatus?: boolean;
|
||||
showPosition?: boolean;
|
||||
};
|
||||
columns: ColumnDef[];
|
||||
onRowClick?: (asset: any) => void;
|
||||
emptyMessage?: string;
|
||||
persistentSortState?: SortState; // Allow passing external sort state (like DomainListView)
|
||||
persistentSortState?: SortState;
|
||||
}
|
||||
|
||||
export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
renderPageHeader(container, config.title);
|
||||
// 1. 컨테이너 초기화
|
||||
container.innerHTML = '';
|
||||
|
||||
const fullList = config.dataSource();
|
||||
let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' };
|
||||
|
||||
// Initialize currentFilters with all possible keys to avoid undefined issues
|
||||
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
|
||||
if (!(state as any).listFilters) {
|
||||
(state as any).listFilters = {};
|
||||
}
|
||||
const filterKey = config.title;
|
||||
if (!(state as any).listFilters[filterKey]) {
|
||||
(state as any).listFilters[filterKey] = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' };
|
||||
}
|
||||
let currentFilters: any = (state as any).listFilters[filterKey];
|
||||
|
||||
const isServer = config.title === '서버';
|
||||
|
||||
// 1. 컨텐츠 영역 생성 (먼저 생성하여 참조 가능하게 함)
|
||||
const contentWrapper = document.createElement('div');
|
||||
contentWrapper.className = 'view-content-wrapper';
|
||||
|
||||
// 2. 필터 바 생성 (자산 목록에서만 사용)
|
||||
const filterBar = document.createElement('div');
|
||||
filterBar.className = 'search-bar';
|
||||
|
||||
// 자산 추가 버튼 및 목록 보기 체크박스 추가 로직
|
||||
const showPcFlowBtn = config.title === 'PC';
|
||||
|
||||
container.appendChild(filterBar);
|
||||
container.appendChild(contentWrapper);
|
||||
|
||||
// --- 내부 상태 ---
|
||||
let selectedLocation: string | null = null;
|
||||
let selectedDetailLocation: string | null = null;
|
||||
let dynamicMapConfig: Record<string, any[]> = {};
|
||||
|
||||
const fetchMapConfig = async () => {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||||
dynamicMapConfig = await res.json();
|
||||
} catch (err) { console.error('Failed to fetch map config:', err); }
|
||||
};
|
||||
fetchMapConfig();
|
||||
|
||||
// [자산 현황] 대시보드 렌더러
|
||||
const renderSystemStatus = () => {
|
||||
const isPcView = config.title === 'PC';
|
||||
|
||||
// 실제 보유 자산이 존재하는 위치 목록만 추출 (0대인 곳 제외)
|
||||
const validLocations = Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정')))
|
||||
.filter(l => {
|
||||
const count = fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === l).length;
|
||||
return count > 0;
|
||||
})
|
||||
.sort();
|
||||
|
||||
// 초기값이나 유효하지 않은 값이 지정되어 있다면 첫 번째 유효 위치로 동적 갱신
|
||||
if (!selectedLocation || !validLocations.includes(selectedLocation)) {
|
||||
selectedLocation = validLocations[0] || '';
|
||||
}
|
||||
|
||||
// 동적 통계 수집 객체
|
||||
const extStats = {
|
||||
total: 0,
|
||||
locCounts: {} as Record<string, number>,
|
||||
typeCounts: {} as Record<string, number>,
|
||||
typeLocMap: {} as Record<string, Record<string, number>>,
|
||||
locWarning: 0,
|
||||
typeWarning: 0
|
||||
};
|
||||
const intStats = {
|
||||
total: 0,
|
||||
locCounts: {} as Record<string, number>,
|
||||
typeCounts: {} as Record<string, number>,
|
||||
typeLocMap: {} as Record<string, Record<string, number>>
|
||||
};
|
||||
|
||||
const checkAnomaly = (serviceType: string, loc: string, type: string) => {
|
||||
if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false };
|
||||
const isLocWarning = loc !== 'IDC' && loc !== '미지정' && loc !== '';
|
||||
const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
return { isWarning: isLocWarning || isTypeWarning, isLocWarning, isTypeWarning };
|
||||
};
|
||||
|
||||
fullList.forEach(asset => {
|
||||
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정';
|
||||
const serviceType = asset.service_type || '외부';
|
||||
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
||||
|
||||
const targetStat = serviceType === '내부' ? intStats : extStats;
|
||||
targetStat.total++;
|
||||
if (loc) targetStat.locCounts[loc] = (targetStat.locCounts[loc] || 0) + 1;
|
||||
if (type) {
|
||||
targetStat.typeCounts[type] = (targetStat.typeCounts[type] || 0) + 1;
|
||||
if (!targetStat.typeLocMap[type]) targetStat.typeLocMap[type] = {};
|
||||
targetStat.typeLocMap[type][loc] = (targetStat.typeLocMap[type][loc] || 0) + 1;
|
||||
}
|
||||
|
||||
if (serviceType === '외부') {
|
||||
const anomaly = checkAnomaly(serviceType, loc, type);
|
||||
if (anomaly.isLocWarning) extStats.locWarning++;
|
||||
if (anomaly.isTypeWarning) extStats.typeWarning++;
|
||||
}
|
||||
});
|
||||
|
||||
const generateDetailStatHTML = (title: string, stats: any) => `
|
||||
<div class="detail-stat-header">
|
||||
<span class="stat-title">${title}</span>
|
||||
<div class="stat-badges">
|
||||
${stats.locWarning ? `<span class="warning-badge-orange">위치부적절: ${stats.locWarning}</span>` : ''}
|
||||
${stats.typeWarning ? `<span class="warning-badge">형식부적절: ${stats.typeWarning}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-stat-body">
|
||||
<div class="loc-summary">
|
||||
${Object.entries(stats.locCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `<span>${l}: <strong>${c}</strong></span>`).join('')}
|
||||
</div>
|
||||
<div class="type-summary">
|
||||
${Object.entries(stats.typeCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => {
|
||||
const locDist = stats.typeLocMap[t] || {};
|
||||
const locHint = Object.entries(locDist).sort((a: any, b: any) => b[1] - a[1]).map(([l, count]) => `${l}: ${count}대`).join('\n');
|
||||
return `<span title="${locHint}">${t}: <strong>${c}</strong></span>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
contentWrapper.innerHTML = `
|
||||
<div class="system-dashboard">
|
||||
<div class="dashboard-stats-row">
|
||||
<div class="stat-group-item">
|
||||
<div class="stat-label">총 보유 자산</div>
|
||||
<div class="stat-value">${fullList.length}<span>대</span></div>
|
||||
<div class="stat-sub">
|
||||
<span>외부: <strong class="text-primary">${extStats.total}</strong></span>
|
||||
<span>내부: <strong class="text-muted">${intStats.total}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-group-item bordered">${generateDetailStatHTML('외부 (운영) 상세', extStats)}</div>
|
||||
<div class="stat-group-item bordered">${generateDetailStatHTML('내부 (테스트) 상세', intStats)}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 min-h-0 border-t border-hairline">
|
||||
<!-- 좌측: 자산 현황 목록 -->
|
||||
<div class="list-section">
|
||||
<div class="flex justify-between items-center mb-4 flex-shrink-0">
|
||||
<h4 id="list-section-title" class="sidebar-title">
|
||||
${isPcView ? `🔄 PC 유동 이력 (${new Date().getMonth() + 1}월)` : '자산 현황 목록'}
|
||||
</h4>
|
||||
${!isPcView ? `
|
||||
<div class="filter-row">
|
||||
<span class="detail-label-sm">위치:</span>
|
||||
<select id="select-loc" class="form-select-sm">
|
||||
<option value="">전체</option>
|
||||
${validLocations.map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select>
|
||||
<select id="select-detail-loc" class="form-select-sm"></select>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<table class="compact-table">
|
||||
<thead>
|
||||
${isPcView ? `
|
||||
<tr>
|
||||
<th class="text-center" style="width: 120px;">일자</th>
|
||||
<th class="text-center" style="width: 100px;">담당자</th>
|
||||
<th class="text-center" style="width: 60px;">구분</th>
|
||||
<th class="text-center" style="width: 90px;">사용자</th>
|
||||
<th class="text-center" style="width: 90px;">인수자</th>
|
||||
<th class="text-center" style="width: 160px;">자산번호</th>
|
||||
<th class="text-center">상세</th>
|
||||
</tr>
|
||||
` : `
|
||||
<tr>
|
||||
<th class="text-center" style="width: 80px;">분류</th>
|
||||
<th class="text-center">용도/자산명</th>
|
||||
<th class="text-center" style="width: 90px;">관리자(정)</th>
|
||||
<th class="text-center" style="width: 90px;">관리자(부)</th>
|
||||
<th class="text-center" style="width: 100px;">상세위치</th>
|
||||
</tr>
|
||||
`}
|
||||
</thead>
|
||||
<tbody id="system-status-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 우측: 상세 정보 패널 -->
|
||||
<div id="system-detail-panel" class="detail-panel">
|
||||
<div id="detail-empty-state" class="detail-empty-state">
|
||||
${isPcView ? `
|
||||
<div class="flex-col h-full text-left">
|
||||
<div class="flex justify-between items-center mb-4 flex-shrink-0">
|
||||
<h4 class="sidebar-title text-danger">
|
||||
⚠️ 사양 주의 장비 현황 (부족/오버스펙)
|
||||
</h4>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<table class="compact-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 65px;">사용자</th>
|
||||
<th style="width: 115px;">부서 (직무)</th>
|
||||
<th class="text-center" style="width: 65px;">상태</th>
|
||||
<th>자산코드</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="spec-mismatch-tbody">
|
||||
<tr><td colspan="4" class="empty-cell">사양 주의 자산이 없습니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<p class="empty-list-message">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
|
||||
`}
|
||||
</div>
|
||||
<div id="detail-content" class="detail-content hidden flex-col flex-1 overflow-hidden">
|
||||
<div class="detail-header-actions bg-canvas p-4 border-b border-hairline">
|
||||
<div class="header-identity">
|
||||
<span class="asset-code-title" id="detail-asset-code"></span>
|
||||
<span class="asset-type-label" id="detail-asset-type"></span>
|
||||
</div>
|
||||
<button id="btn-view-full-detail" class="btn btn-primary btn-sm">상세 보기</button>
|
||||
</div>
|
||||
|
||||
<!-- 메인 배치도 영역 -->
|
||||
<div class="flex-col flex-1 overflow-hidden p-4">
|
||||
<div id="detail-photo-wrapper" class="detail-photo-wrapper">
|
||||
<div class="layout-map-container readonly w-full h-full justify-center">
|
||||
<img id="detail-photo" src="" class="layout-map-img pointer-events-none" />
|
||||
<iframe id="detail-html-map" src="" class="hidden w-full h-full border-none"></iframe>
|
||||
<div id="detail-marker" class="layout-marker pulse-marker hidden absolute z-20"></div>
|
||||
<div id="detail-overlay-layer" class="absolute pointer-events-none"></div>
|
||||
</div>
|
||||
<div id="detail-no-photo" class="no-photo-state hidden">
|
||||
<span>등록된 배치도가 없습니다.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const updateFlowLogsSection = () => {
|
||||
if (!isPcView) return;
|
||||
|
||||
// 사양 주의 장비 현황 (부족/오버스펙) 계산 및 바인딩
|
||||
const specMismatchTbody = document.getElementById('spec-mismatch-tbody');
|
||||
if (specMismatchTbody) {
|
||||
// fullList 중 개인 PC 관련 장비 필터링
|
||||
const pcs = fullList.filter((a: any) => {
|
||||
const type = a[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
||||
const job = a[ASSET_SCHEMA.USER_POSITION.key] || '';
|
||||
const status = a[ASSET_SCHEMA.HW_STATUS.key] || '';
|
||||
const user = a[ASSET_SCHEMA.CURRENT_USER.key] || '';
|
||||
|
||||
// 운영 중이고 사용자가 할당되어 있으며, 직무가 재고PC가 아닌 실사용 기기 대상
|
||||
return job !== '재고PC' && status === '운영' && user.trim() !== '';
|
||||
});
|
||||
|
||||
// 직무별 평균 점수 산출
|
||||
const jobScores: Record<string, { totalScore: number; count: number; avg: number }> = {};
|
||||
pcs.forEach((pc: any) => {
|
||||
const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||
const cpu = pc[ASSET_SCHEMA.CPU.key] || '';
|
||||
const ram = pc[ASSET_SCHEMA.RAM.key] || '';
|
||||
const gpu = pc[ASSET_SCHEMA.GPU.key] || '';
|
||||
const pDate = pc[ASSET_SCHEMA.PURCHASE_DATE.key] || '';
|
||||
const score = calculatePcScoreDeductive(cpu, ram, gpu, pDate);
|
||||
pc['_pc_score'] = score;
|
||||
|
||||
if (!jobScores[job]) jobScores[job] = { totalScore: 0, count: 0, avg: 0 };
|
||||
jobScores[job].totalScore += score;
|
||||
jobScores[job].count += 1;
|
||||
});
|
||||
|
||||
Object.keys(jobScores).forEach(job => {
|
||||
jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0;
|
||||
});
|
||||
|
||||
// DB 기준 사양 데이터 맵핑 (state.masterData.jobSpecs 이용)
|
||||
const jobSpecsMap: Record<string, string> = {};
|
||||
if (state.masterData.jobSpecs) {
|
||||
state.masterData.jobSpecs.forEach((s: any) => {
|
||||
jobSpecsMap[s.job_name] = s.required_grade || '중급';
|
||||
});
|
||||
}
|
||||
|
||||
// 사용자 이름 → 세부 직무 맵 생성 (system_users.position 기준)
|
||||
const userPositionMap: Record<string, string> = {};
|
||||
if (state.masterData.users) {
|
||||
state.masterData.users.forEach((u: any) => {
|
||||
if (u.user_name && u.position) {
|
||||
userPositionMap[u.user_name.trim()] = u.position.trim();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const GRADE_RANK: Record<string, number> = {
|
||||
'premium': 4, '최상급': 4,
|
||||
'high': 3, '상급': 3,
|
||||
'normal': 2, '중급': 2,
|
||||
'entry': 1, '보급': 1,
|
||||
'replace': 0, '교체 대상': 0
|
||||
};
|
||||
|
||||
// 기준 대비 사양 부족/오버스펙 분류
|
||||
const criticalPcList: any[] = [];
|
||||
pcs.forEach((pc: any) => {
|
||||
const userName = (pc[ASSET_SCHEMA.CURRENT_USER.key] || '').trim();
|
||||
const job = userPositionMap[userName] || pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||
const score = pc['_pc_score'];
|
||||
const requiredGrade = jobSpecsMap[job] || jobSpecsMap[pc[ASSET_SCHEMA.USER_POSITION.key]] || '중급';
|
||||
|
||||
const cpu = pc[ASSET_SCHEMA.CPU.key] || '';
|
||||
const ram = pc[ASSET_SCHEMA.RAM.key] || '';
|
||||
const win11Incompatible = isWindows11Incompatible(cpu, ram);
|
||||
|
||||
let actualGrade = 'replace';
|
||||
if (score >= 85) actualGrade = 'premium';
|
||||
else if (score >= 70) actualGrade = 'high';
|
||||
else if (score >= 40) actualGrade = 'normal';
|
||||
else if (score >= 20) actualGrade = 'entry';
|
||||
|
||||
const reqRank = GRADE_RANK[requiredGrade] !== undefined ? GRADE_RANK[requiredGrade] : 2;
|
||||
const actRank = GRADE_RANK[actualGrade] !== undefined ? GRADE_RANK[actualGrade] : 0;
|
||||
|
||||
let isUnder = false;
|
||||
if (job !== '재고PC') {
|
||||
if (win11Incompatible) {
|
||||
isUnder = true;
|
||||
pc['_spec_status'] = '사양 부족';
|
||||
} else if (actRank < reqRank) {
|
||||
isUnder = true;
|
||||
pc['_spec_status'] = '사양 부족';
|
||||
} else if (actRank > reqRank) {
|
||||
pc['_spec_status'] = '오버스펙';
|
||||
criticalPcList.push(pc);
|
||||
} else {
|
||||
pc['_spec_status'] = '적정';
|
||||
}
|
||||
} else {
|
||||
if (win11Incompatible) {
|
||||
isUnder = true;
|
||||
pc['_spec_status'] = '사양 부족';
|
||||
} else {
|
||||
pc['_spec_status'] = '적정';
|
||||
}
|
||||
}
|
||||
|
||||
if (isUnder) {
|
||||
criticalPcList.push(pc);
|
||||
}
|
||||
});
|
||||
|
||||
// 정렬: 요구 등급 대비 실제 성능이 많이 부족한 순(등급 편차가 큰 순)으로 정렬
|
||||
criticalPcList.sort((a: any, b: any) => {
|
||||
const userNameA = (a[ASSET_SCHEMA.CURRENT_USER.key] || '').trim();
|
||||
const userNameB = (b[ASSET_SCHEMA.CURRENT_USER.key] || '').trim();
|
||||
const jobA = userPositionMap[userNameA] || a[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||
const jobB = userPositionMap[userNameB] || b[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||
|
||||
const reqA = jobSpecsMap[jobA] || jobSpecsMap[a[ASSET_SCHEMA.USER_POSITION.key]] || '중급';
|
||||
const reqB = jobSpecsMap[jobB] || jobSpecsMap[b[ASSET_SCHEMA.USER_POSITION.key]] || '중급';
|
||||
|
||||
const scoreA = a['_pc_score'];
|
||||
const scoreB = b['_pc_score'];
|
||||
|
||||
let actA = 'replace';
|
||||
if (scoreA >= 85) actA = 'premium';
|
||||
else if (scoreA >= 70) actA = 'high';
|
||||
else if (scoreA >= 40) actA = 'normal';
|
||||
else if (scoreA >= 20) actA = 'entry';
|
||||
|
||||
let actB = 'replace';
|
||||
if (scoreB >= 85) actB = 'premium';
|
||||
else if (scoreB >= 70) actB = 'high';
|
||||
else if (scoreB >= 40) actB = 'normal';
|
||||
else if (scoreB >= 20) actB = 'entry';
|
||||
|
||||
const devA = (GRADE_RANK[reqA] || 2) - (GRADE_RANK[actA] || 0);
|
||||
const devB = (GRADE_RANK[reqB] || 2) - (GRADE_RANK[actB] || 0);
|
||||
|
||||
if (devA !== devB) {
|
||||
return devB - devA; // 편차가 큰 것(더 많이 부족한 것)이 먼저 정렬됨
|
||||
}
|
||||
return scoreA - scoreB; // 편차가 같으면 성능 점수가 낮은 순
|
||||
});
|
||||
|
||||
if (criticalPcList.length === 0) {
|
||||
specMismatchTbody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding:1.5rem; color:#94A3B8;">사양 주의 자산이 없습니다.</td></tr>';
|
||||
} else {
|
||||
specMismatchTbody.innerHTML = criticalPcList.map((pc: any) => {
|
||||
const user = pc[ASSET_SCHEMA.CURRENT_USER.key] || '-';
|
||||
const dept = pc[ASSET_SCHEMA.CURRENT_DEPT.key] || '-';
|
||||
const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '-';
|
||||
const status = pc['_spec_status'];
|
||||
const assetCode = pc.asset_code || '-';
|
||||
|
||||
const badgeColor = status === '사양 부족'
|
||||
? 'background:#FFF1F2; color:#E11D48; border: 1px solid #FDA4AF;'
|
||||
: 'background:#F0FDF4; color:#16A34A; border: 1px solid #BBF7D0;';
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid var(--border-color); cursor: pointer;" class="spec-row" data-id="${pc.id}">
|
||||
<td style="padding: 10px 0; font-weight: 600; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${user}">${user}</td>
|
||||
<td style="padding: 10px 0; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${dept} (${job})">${dept} (${job})</td>
|
||||
<td style="padding: 10px 0; white-space: nowrap; text-align: center;">
|
||||
<span style="padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; ${badgeColor}">${status === '오버스펙' ? '오버 스펙' : status}</span>
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-family: monospace; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${assetCode}">${assetCode}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 클릭 시 해당 자산 상세 페이지로 전환
|
||||
specMismatchTbody.querySelectorAll('.spec-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
specMismatchTbody.querySelectorAll('.spec-row').forEach(r => {
|
||||
(r as HTMLElement).style.backgroundColor = 'transparent';
|
||||
});
|
||||
(row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트
|
||||
const assetId = row.getAttribute('data-id');
|
||||
const found = fullList.find(a => String(a.id) === String(assetId));
|
||||
if (found) {
|
||||
updateDetailPanel(found);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const updateDetailPanel = (asset: any) => {
|
||||
const emptyState = document.getElementById('detail-empty-state');
|
||||
const content = document.getElementById('detail-content');
|
||||
if (!emptyState || !content) return;
|
||||
|
||||
emptyState.classList.add('hidden');
|
||||
content.classList.remove('hidden');
|
||||
|
||||
const codeEl = document.getElementById('detail-asset-code');
|
||||
const typeEl = document.getElementById('detail-asset-type');
|
||||
const viewBtn = document.getElementById('btn-view-full-detail') as HTMLButtonElement;
|
||||
|
||||
if (codeEl) codeEl.textContent = asset.asset_code || '미지정';
|
||||
if (typeEl) typeEl.textContent = asset.asset_type || '-';
|
||||
if (viewBtn) viewBtn.onclick = () => config.onRowClick && config.onRowClick(asset);
|
||||
|
||||
const photo = document.getElementById('detail-photo') as HTMLImageElement;
|
||||
const marker = document.getElementById('detail-marker');
|
||||
const overlayLayer = document.getElementById('detail-overlay-layer');
|
||||
const noPhoto = document.getElementById('detail-no-photo');
|
||||
|
||||
const bldg = asset.location || '';
|
||||
const detail = asset.location_detail || '';
|
||||
const x = asset.loc_x; const y = asset.loc_y;
|
||||
const hasCoords = (x !== null && x !== undefined && x !== '' && x !== 'null');
|
||||
|
||||
const savedImg = asset.location_photo || asset.loc_img;
|
||||
const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
|
||||
const imgPath = (savedImg && locImgs?.includes(savedImg)) ? savedImg : (locImgs ? locImgs[0] : null);
|
||||
|
||||
const htmlMap = document.getElementById('detail-html-map') as HTMLIFrameElement;
|
||||
const isHtmlMap = imgPath?.toLowerCase().endsWith('.html');
|
||||
|
||||
if (imgPath && hasCoords) {
|
||||
if (isHtmlMap) {
|
||||
photo.style.display = 'none';
|
||||
if (marker) marker.style.display = 'none';
|
||||
if (overlayLayer) overlayLayer.innerHTML = '';
|
||||
if (htmlMap) {
|
||||
htmlMap.src = `${imgPath}?markerX=${x}&markerY=${y}`;
|
||||
htmlMap.classList.remove('hidden');
|
||||
htmlMap.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
if (htmlMap) {
|
||||
htmlMap.src = '';
|
||||
htmlMap.classList.add('hidden');
|
||||
htmlMap.style.display = 'none';
|
||||
}
|
||||
photo.src = imgPath;
|
||||
photo.style.display = 'block';
|
||||
}
|
||||
|
||||
if (noPhoto) noPhoto.style.display = 'none';
|
||||
|
||||
if (!isHtmlMap) {
|
||||
photo.onload = () => {
|
||||
const updateMarkerPos = () => {
|
||||
const imgW = photo.clientWidth;
|
||||
const imgH = photo.clientHeight;
|
||||
|
||||
if (marker) {
|
||||
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(x as string) * imgW) / 100 }px)`;
|
||||
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(y as string) * imgH) / 100 }px)`;
|
||||
marker.style.display = 'block';
|
||||
}
|
||||
|
||||
if (overlayLayer) {
|
||||
overlayLayer.style.width = `${imgW}px`;
|
||||
overlayLayer.style.height = `${imgH}px`;
|
||||
overlayLayer.style.left = `calc(50% - ${imgW/2}px)`;
|
||||
overlayLayer.style.top = `calc(50% - ${imgH/2}px)`;
|
||||
|
||||
const boxes = dynamicMapConfig[imgPath] || [];
|
||||
if (boxes.length > 0) {
|
||||
overlayLayer.innerHTML = `
|
||||
<svg viewBox="0 0 100 100" preserveAspectRatio="none" style="width:100%; height:100%;">
|
||||
<g class="seat-group">
|
||||
${boxes.map((b, i) => {
|
||||
const isSelected = b.x === x && b.y === y;
|
||||
const fill = isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)';
|
||||
const stroke = isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)';
|
||||
const strokeWidth = isSelected ? '0.8' : '0.2';
|
||||
|
||||
if (isSelected && marker) {
|
||||
marker.style.left = `calc(50% - ${imgW/2}px + ${ (parseFloat(b.x) + parseFloat(b.w)/2) * imgW / 100 }px)`;
|
||||
marker.style.top = `calc(50% - ${imgH/2}px + ${ (parseFloat(b.y) + parseFloat(b.h)/2) * imgH / 100 }px)`;
|
||||
}
|
||||
|
||||
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${fill}; stroke:${stroke}; stroke-width:${strokeWidth};" />`;
|
||||
}).join('')}
|
||||
</g>
|
||||
</svg>
|
||||
`;
|
||||
} else {
|
||||
overlayLayer.innerHTML = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
updateMarkerPos();
|
||||
window.addEventListener('resize', updateMarkerPos);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
photo.style.display = 'none';
|
||||
if (htmlMap) {
|
||||
htmlMap.src = '';
|
||||
htmlMap.style.display = 'none';
|
||||
}
|
||||
if (marker) marker.style.display = 'none';
|
||||
if (overlayLayer) overlayLayer.innerHTML = '';
|
||||
if (noPhoto) { noPhoto.classList.remove('hidden'); noPhoto.style.display = 'flex'; }
|
||||
}
|
||||
};
|
||||
|
||||
// [자산 현황] 테이블 렌더러
|
||||
const updateTableOnly = () => {
|
||||
let filtered = selectedLocation
|
||||
? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation)
|
||||
: fullList;
|
||||
const currentDetailLocs = Array.from(new Set(filtered.map(a => a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정'))).sort();
|
||||
if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation);
|
||||
const finalDisplayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 20) : filtered;
|
||||
|
||||
const titleEl = document.getElementById('list-section-title');
|
||||
if (titleEl) titleEl.textContent = selectedLocation ? `${selectedLocation} 자산 현황 (${finalDisplayList.length}대)` : '위치별 자산등록현황 (최근 등록)';
|
||||
|
||||
const selectEl = document.getElementById('select-detail-loc') as HTMLSelectElement;
|
||||
if (selectEl && !selectedDetailLocation) {
|
||||
selectEl.innerHTML = `<option value="">전체보기</option>` + currentDetailLocs.map(dl => `<option value="${dl}">${dl}</option>`).join('');
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('system-status-tbody');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = finalDisplayList.length === 0
|
||||
? `<tr><td colspan="5" class="empty-cell text-center">조회된 자산이 없습니다.</td></tr>`
|
||||
: finalDisplayList.map(asset => {
|
||||
const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
|
||||
const serviceType = asset.service_type || '외부';
|
||||
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
||||
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
|
||||
|
||||
const isWarning = serviceType === '외부' && (loc !== 'IDC' || type.toLowerCase().includes('서버pc'));
|
||||
const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-';
|
||||
const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
|
||||
|
||||
return `
|
||||
<tr class="mini-row clickable-row ${isWarning ? 'warning' : ''}" data-id="${asset.id}">
|
||||
<td class="text-center">
|
||||
<span class="badge ${isWarning ? 'badge-danger' : 'badge-primary'}">${serviceType}</span>
|
||||
</td>
|
||||
<td class="font-bold">${purpose || '-'}</td>
|
||||
<td class="text-center">${managerMain}</td>
|
||||
<td class="text-center">${managerSub}</td>
|
||||
<td class="text-center">${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
tbody.querySelectorAll('.mini-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
tbody.querySelectorAll('.mini-row').forEach(r => r.classList.remove('active'));
|
||||
row.classList.add('active');
|
||||
const asset = fullList.find(a => a.id === row.getAttribute('data-id'));
|
||||
if (asset) updateDetailPanel(asset);
|
||||
});
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('select-loc')?.addEventListener('change', (e) => {
|
||||
selectedLocation = (e.target as HTMLSelectElement).value || null; selectedDetailLocation = null; updateTableOnly();
|
||||
});
|
||||
document.getElementById('select-detail-loc')?.addEventListener('change', (e) => {
|
||||
selectedDetailLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly();
|
||||
});
|
||||
updateTableOnly();
|
||||
updateFlowLogsSection();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const tableWrapper = document.createElement('div');
|
||||
tableWrapper.className = 'table-container';
|
||||
const table = document.createElement('table');
|
||||
|
||||
// 1. 헤더 생성
|
||||
const thead = document.createElement('thead');
|
||||
const trHead = document.createElement('tr');
|
||||
config.columns.forEach(col => {
|
||||
const th = document.createElement('th');
|
||||
th.innerHTML = col.header;
|
||||
if (col.sortKey) th.setAttribute('data-sort', col.sortKey);
|
||||
if (col.width) th.style.width = col.width;
|
||||
if (col.align) th.style.textAlign = col.align;
|
||||
if (col.className) th.className = col.className;
|
||||
trHead.appendChild(th);
|
||||
});
|
||||
thead.appendChild(trHead);
|
||||
table.appendChild(thead);
|
||||
|
||||
// 2. 본문 생성
|
||||
const tbody = document.createElement('tbody');
|
||||
tbody.id = 'dynamic-tbody';
|
||||
table.appendChild(tbody);
|
||||
table.appendChild(thead); table.appendChild(tbody); tableWrapper.appendChild(table);
|
||||
|
||||
tableWrapper.appendChild(table);
|
||||
container.appendChild(tableWrapper);
|
||||
|
||||
// 3. 테이블 업데이트 로직
|
||||
const updateTable = () => {
|
||||
let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]);
|
||||
|
||||
if (sortState.key) {
|
||||
filtered = dynamicSort(filtered, sortState.key, sortState.direction);
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
if (filtered.length === 0) {
|
||||
const emptyMsg = config.emptyMessage || UI_TEXT.MESSAGES.NO_DATA;
|
||||
tbody.innerHTML = `<tr><td colspan="${config.columns.length}" class="text-center" style="padding: 3rem; color: var(--text-muted);">${emptyMsg}</td></tr>`;
|
||||
return;
|
||||
}
|
||||
|
||||
filtered.forEach((asset) => {
|
||||
const tr = document.createElement('tr');
|
||||
if (config.onRowClick) {
|
||||
tr.style.cursor = 'pointer';
|
||||
tr.addEventListener('click', () => config.onRowClick!(asset));
|
||||
}
|
||||
|
||||
config.columns.forEach(col => {
|
||||
const td = document.createElement('td');
|
||||
if (col.align) td.style.textAlign = col.align;
|
||||
if (col.className) td.className = col.className;
|
||||
td.innerHTML = col.render(asset);
|
||||
tr.appendChild(td);
|
||||
});
|
||||
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
setupTableSorting(table, sortState, (key, dir) => {
|
||||
sortState = { key, direction: dir };
|
||||
// If external state was provided, sync it back
|
||||
if (config.persistentSortState) {
|
||||
config.persistentSortState.key = key;
|
||||
config.persistentSortState.direction = dir;
|
||||
}
|
||||
updateTable();
|
||||
});
|
||||
|
||||
// 모든 가능한 아이콘 로드 (안전하게)
|
||||
createIcons({ icons: { RefreshCcw, Plus, Edit2, Trash2, Users, Cloud, CreditCard, DollarSign, Paperclip } });
|
||||
if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction);
|
||||
|
||||
thead.innerHTML = `<tr>${config.columns.map(col => {
|
||||
const isDateCol = col.header.includes('일') || col.header.includes('날짜') || col.header.includes('연월');
|
||||
const alignmentClass = col.align ? `text-${col.align}` : (isDateCol ? 'text-center' : '');
|
||||
return `<th class="${alignmentClass}" ${col.sortKey ? `data-sort="${col.sortKey}"` : ''} style="${col.width ? `width:${col.width};` : ''}">${col.header}</th>`;
|
||||
}).join('')}</tr>`;
|
||||
|
||||
tbody.innerHTML = filtered.length === 0 ? `<tr><td colspan="${config.columns.length}" class="text-center empty-cell">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`
|
||||
: filtered.map(asset => `<tr class="asset-row clickable" data-id="${asset.id}">${config.columns.map(col => {
|
||||
const isDateCol = col.header.includes('일') || col.header.includes('날짜') || col.header.includes('연월');
|
||||
const alignmentClass = col.align ? `text-${col.align}` : (isDateCol ? 'text-center' : '');
|
||||
const customClass = col.className || '';
|
||||
const rendered = col.render(asset);
|
||||
const rawText = rendered.replace(/<[^>]*>/g, '').trim();
|
||||
const titleAttr = rawText && rawText !== '-' ? `title="${rawText.replace(/"/g, '"')}"` : '';
|
||||
return `<td class="${alignmentClass} ${customClass}" style="${col.width ? `width:${col.width};` : ''}" ${titleAttr}>${rendered}</td>`;
|
||||
}).join('')}</tr>`).join('');
|
||||
|
||||
tbody.querySelectorAll('.asset-row').forEach((tr, idx) => { tr.addEventListener('click', () => config.onRowClick && config.onRowClick(filtered[idx])); });
|
||||
setupTableSorting(table, sortState, (key, dir) => { sortState = { key, direction: dir }; updateTable(); });
|
||||
};
|
||||
|
||||
// 4. 필터 바 렌더링
|
||||
renderFilterBar(filterBar, {
|
||||
...config.filterOptions,
|
||||
onFilterChange: (filters) => {
|
||||
currentFilters = { ...currentFilters, ...filters };
|
||||
updateTable();
|
||||
const switchView = () => {
|
||||
contentWrapper.innerHTML = '';
|
||||
const isAssetMode = !isServer || state.viewMode === 'list';
|
||||
if (isAssetMode) {
|
||||
filterBar.style.display = 'flex'; contentWrapper.style.overflowY = 'hidden';
|
||||
contentWrapper.appendChild(tableWrapper); updateTable();
|
||||
} else {
|
||||
filterBar.style.display = 'none'; contentWrapper.style.overflowY = 'hidden';
|
||||
renderSystemStatus();
|
||||
}
|
||||
};
|
||||
|
||||
// 2. 필터 바 렌더링
|
||||
renderFilterBar(filterBar, {
|
||||
...config.filterOptions,
|
||||
initialFilters: currentFilters,
|
||||
fullList: fullList, // Added for dynamic options
|
||||
extraHTML: isServer ? `
|
||||
<div class="search-item">
|
||||
<label class="list-view-toggle-label">
|
||||
<input type="checkbox" id="chk-list-view" ${state.viewMode === 'list' ? 'checked' : ''} />
|
||||
목록보기
|
||||
</label>
|
||||
</div>
|
||||
` : '',
|
||||
onFilterChange: (filters) => { Object.assign(currentFilters, filters); updateTable(); }
|
||||
});
|
||||
|
||||
// 5. 동적 Select 박스 데이터 채우기
|
||||
const populateSelect = (selector: string, dataKey: string) => {
|
||||
const select = container.querySelector(selector) as HTMLSelectElement;
|
||||
if (select) {
|
||||
// Handle multiple possible keys for department names due to legacy data
|
||||
const getVal = (a: any) => {
|
||||
if (dataKey === ASSET_SCHEMA.CURRENT_DEPT.key) {
|
||||
return a[dataKey] || a['현사용부서'] || a['현사용조직'];
|
||||
// 3. 필터 바 내 액션 버튼 배치
|
||||
const actionContainer = filterBar.querySelector('#filter-bar-actions');
|
||||
if (actionContainer) {
|
||||
actionContainer.className = "header-action-group";
|
||||
actionContainer.innerHTML = `
|
||||
${showPcFlowBtn ? `
|
||||
<button id="btn-goto-parts-master" class="btn btn-outline">
|
||||
<i data-lucide="settings" class="icon-sm"></i> 부품 마스터
|
||||
</button>
|
||||
<button id="btn-pc-flow" class="btn btn-outline">
|
||||
PC 이동/반납
|
||||
</button>
|
||||
` : ''}
|
||||
<button id="btn-add-asset" class="btn btn-primary">
|
||||
<i data-lucide="plus" class="icon-sm"></i> ${config.title === '직무별 기준 사양' ? '기준 사양 추가' : (config.title === '부품 마스터' ? '표준 부품 추가' : '자산 추가')}
|
||||
</button>
|
||||
`;
|
||||
|
||||
actionContainer.querySelector('#btn-add-asset')?.addEventListener('click', () => {
|
||||
const dummyAsset = { id: '', category: config.title };
|
||||
config.onRowClick && config.onRowClick(dummyAsset);
|
||||
});
|
||||
actionContainer.querySelector('#btn-pc-flow')?.addEventListener('click', () => {
|
||||
window.dispatchEvent(new CustomEvent('open-pc-flow'));
|
||||
});
|
||||
actionContainer.querySelector('#btn-goto-parts-master')?.addEventListener('click', () => {
|
||||
state.activeSubTab = '부품 마스터';
|
||||
window.dispatchEvent(new Event('refresh-view'));
|
||||
});
|
||||
}
|
||||
|
||||
// 서버 탭 전용 목록보기 체크박스 이벤트
|
||||
if (isServer) {
|
||||
const chkBox = filterBar.querySelector('#chk-list-view') as HTMLInputElement;
|
||||
|
||||
const handleToggle = () => {
|
||||
const isListMode = chkBox.checked;
|
||||
if (isListMode) {
|
||||
state.viewMode = 'list';
|
||||
} else {
|
||||
state.viewMode = 'location';
|
||||
}
|
||||
return a[dataKey];
|
||||
}
|
||||
|
||||
const uniqueValues = Array.from(new Set(fullList.map(getVal))).filter(Boolean).sort();
|
||||
uniqueValues.forEach(val => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = String(val);
|
||||
opt.textContent = String(val);
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
};
|
||||
window.dispatchEvent(new Event('refresh-view'));
|
||||
};
|
||||
chkBox?.addEventListener('change', handleToggle);
|
||||
}
|
||||
|
||||
if (config.filterOptions.showLoc) populateSelect('#filter-loc', ASSET_SCHEMA.LOCATION.key);
|
||||
if (config.filterOptions.showDept) populateSelect('#filter-dept', ASSET_SCHEMA.CURRENT_DEPT.key);
|
||||
if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key);
|
||||
if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key);
|
||||
|
||||
// 6. 초기 렌더링
|
||||
updateTable();
|
||||
switchView();
|
||||
}
|
||||
|
||||
174
src/views/List/PartsMasterListView.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
import { state } from '../../core/state';
|
||||
import { openPartsMasterModal } from '../../components/Modal/PartsMasterModal';
|
||||
import { openJobSpecModal } from '../../components/Modal/JobSpecModal';
|
||||
import { formatInline } from '../../core/utils';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export let activePartsMasterSubTab: 'parts-master' | 'job-spec' = 'parts-master';
|
||||
|
||||
export function renderPartsMasterList(container: HTMLElement) {
|
||||
if (activePartsMasterSubTab === 'parts-master') {
|
||||
createListView(container, {
|
||||
title: '부품 마스터',
|
||||
dataSource: () => state.masterData.partsMaster || [],
|
||||
searchKeys: ['component_name', 'category', 'score_tier'],
|
||||
filterOptions: {
|
||||
keywordLabel: '부품명 / 등급 검색',
|
||||
showLoc: false,
|
||||
showDept: false,
|
||||
showType: false
|
||||
},
|
||||
onRowClick: (component) => openPartsMasterModal(component, 'view'),
|
||||
columns: [
|
||||
{
|
||||
header: 'ID',
|
||||
sortKey: 'id',
|
||||
align: 'center',
|
||||
width: '5%',
|
||||
render: c => c.id.toString()
|
||||
},
|
||||
{
|
||||
header: '분류',
|
||||
sortKey: 'category',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: c => {
|
||||
let badgeClass = 'badge-primary';
|
||||
if (c.category === 'CPU') badgeClass = 'badge-primary';
|
||||
else if (c.category === 'GPU') badgeClass = 'badge-success';
|
||||
else if (c.category === 'RAM') badgeClass = 'badge-warning';
|
||||
return `<span class="badge ${badgeClass}">${c.category}</span>`;
|
||||
}
|
||||
},
|
||||
{
|
||||
header: '부품 표준 명칭',
|
||||
sortKey: 'component_name',
|
||||
render: c => formatInline(c.component_name || '-')
|
||||
},
|
||||
{
|
||||
header: '성능 등급',
|
||||
sortKey: 'score_tier',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: c => c.score_tier || '-'
|
||||
},
|
||||
{
|
||||
header: '감점 점수',
|
||||
sortKey: 'deduction',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: c => {
|
||||
const score = c.deduction || 0;
|
||||
let color = '#3b82f6'; // blue
|
||||
if (score >= 20) color = '#ef4444'; // red
|
||||
else if (score >= 10) color = '#f59e0b'; // orange
|
||||
return `<strong style="color: ${color}; font-size: 14px;">-${score}점</strong>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
} else {
|
||||
createListView(container, {
|
||||
title: '직무별 기준 사양',
|
||||
dataSource: () => state.masterData.jobSpecs || [],
|
||||
searchKeys: ['job_name', 'cpu_standard', 'ram_standard', 'gpu_standard', 'remarks'],
|
||||
filterOptions: {
|
||||
keywordLabel: '직무명 / 사양 검색',
|
||||
showLoc: false,
|
||||
showDept: false,
|
||||
showType: false
|
||||
},
|
||||
onRowClick: (jobSpec) => openJobSpecModal(jobSpec, 'view'),
|
||||
columns: [
|
||||
{
|
||||
header: 'ID',
|
||||
sortKey: 'id',
|
||||
align: 'center',
|
||||
width: '5%',
|
||||
render: j => j.id.toString()
|
||||
},
|
||||
{
|
||||
header: '직무명',
|
||||
sortKey: 'job_name',
|
||||
width: '15%',
|
||||
render: j => `<strong style="color: var(--primary-color); font-size: 14px;">${formatInline(j.job_name || '-')}</strong>`
|
||||
},
|
||||
{
|
||||
header: '권장 CPU 사양',
|
||||
sortKey: 'cpu_standard',
|
||||
render: j => formatInline(j.cpu_standard || '-')
|
||||
},
|
||||
{
|
||||
header: '권장 RAM 사양',
|
||||
sortKey: 'ram_standard',
|
||||
width: '12%',
|
||||
render: j => formatInline(j.ram_standard || '-')
|
||||
},
|
||||
{
|
||||
header: '권장 GPU 사양',
|
||||
sortKey: 'gpu_standard',
|
||||
render: j => formatInline(j.gpu_standard || '-')
|
||||
},
|
||||
{
|
||||
header: '기준 점수',
|
||||
sortKey: 'min_score',
|
||||
align: 'center',
|
||||
width: '10%',
|
||||
render: j => `<span style="font-weight: 700;">${j.min_score || 0}점 이상</span>`
|
||||
},
|
||||
{
|
||||
header: '비고',
|
||||
sortKey: 'remarks',
|
||||
width: '20%',
|
||||
render: j => formatInline(j.remarks || '-')
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
renderSubTabs(container);
|
||||
}
|
||||
|
||||
function renderSubTabs(container: HTMLElement) {
|
||||
const header = container.querySelector('.page-header');
|
||||
if (!header) return;
|
||||
|
||||
// 기존에 생성된 탭 바가 있다면 제거하여 중복 방지 (스타일만 수정하는 최소 침습 방식)
|
||||
const existingTabs = container.querySelector('.sub-tab-container');
|
||||
if (existingTabs) existingTabs.remove();
|
||||
|
||||
const tabContainer = document.createElement('div');
|
||||
tabContainer.className = 'sub-tab-container';
|
||||
tabContainer.style.cssText = 'display: flex; gap: 1rem; padding: 0 2rem; border-bottom: 1px solid var(--hairline); background: var(--canvas);';
|
||||
|
||||
const tab1Active = activePartsMasterSubTab === 'parts-master';
|
||||
const tab2Active = activePartsMasterSubTab === 'job-spec';
|
||||
|
||||
tabContainer.innerHTML = `
|
||||
<button id="tab-parts-master" class="sub-tab-btn ${tab1Active ? 'active' : ''}" style="padding: 1rem 0.5rem; border: none; background: none; font-size: var(--fs-sm); font-weight: 600; cursor: pointer; color: ${tab1Active ? 'var(--primary)' : 'var(--mute)'}; position: relative; border-bottom: 2px solid ${tab1Active ? 'var(--primary)' : 'transparent'}; margin-bottom: -1px;">
|
||||
부품 표준 등급
|
||||
</button>
|
||||
<button id="tab-job-spec" class="sub-tab-btn ${tab2Active ? 'active' : ''}" style="padding: 1rem 0.5rem; border: none; background: none; font-size: var(--fs-sm); font-weight: 600; cursor: pointer; color: ${tab2Active ? 'var(--primary)' : 'var(--mute)'}; position: relative; border-bottom: 2px solid ${tab2Active ? 'var(--primary)' : 'transparent'}; margin-bottom: -1px;">
|
||||
직무별 기준 사양
|
||||
</button>
|
||||
`;
|
||||
|
||||
header.parentNode!.insertBefore(tabContainer, header.nextSibling);
|
||||
|
||||
const tabPartsMaster = tabContainer.querySelector('#tab-parts-master')!;
|
||||
const tabJobSpec = tabContainer.querySelector('#tab-job-spec')!;
|
||||
|
||||
tabPartsMaster.addEventListener('click', () => {
|
||||
if (activePartsMasterSubTab !== 'parts-master') {
|
||||
activePartsMasterSubTab = 'parts-master';
|
||||
renderPartsMasterList(container);
|
||||
}
|
||||
});
|
||||
|
||||
tabJobSpec.addEventListener('click', () => {
|
||||
if (activePartsMasterSubTab !== 'job-spec') {
|
||||
activePartsMasterSubTab = 'job-spec';
|
||||
renderPartsMasterList(container);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,23 +1,57 @@
|
||||
import { state } from '../../core/state';
|
||||
import { openHwModal } from '../../components/Modal/HWModal';
|
||||
import { sortAssets, formatInline } from '../../core/utils';
|
||||
import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade, isWindows11Incompatible } from '../../core/utils';
|
||||
import { ASSET_SCHEMA } from '../../core/schema';
|
||||
import { createListView } from './ListFactory';
|
||||
import { SortState } from '../../core/tableHandler';
|
||||
|
||||
let persistentSortState: SortState = { key: 'updated_at', direction: 'desc' };
|
||||
|
||||
export function renderPcList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: 'PC',
|
||||
dataSource: () => sortAssets((state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC')),
|
||||
persistentSortState,
|
||||
dataSource: () => {
|
||||
const list = (state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC');
|
||||
list.forEach((a: any) => {
|
||||
a['_pc_score'] = calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date);
|
||||
});
|
||||
// 변경일시(updated_at) 내림차순 정렬 (최신 변경 항목이 맨 위로)
|
||||
return list.sort((a: any, b: any) => {
|
||||
const dateA = a.updated_at || a.created_at || '';
|
||||
const dateB = b.updated_at || b.created_at || '';
|
||||
if (dateA < dateB) return 1;
|
||||
if (dateA > dateB) return -1;
|
||||
return 0;
|
||||
});
|
||||
},
|
||||
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
|
||||
showLoc: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showType: true,
|
||||
showStatus: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
{
|
||||
header: ASSET_SCHEMA.HW_STATUS.ui,
|
||||
sortKey: ASSET_SCHEMA.HW_STATUS.key,
|
||||
align: 'center',
|
||||
width: '8%',
|
||||
render: a => {
|
||||
const status = a[ASSET_SCHEMA.HW_STATUS.key] || '재고';
|
||||
let badgeClass = 'badge-light';
|
||||
if (status === '운영') badgeClass = 'b-green';
|
||||
else if (status === '재고') badgeClass = 'b-yellow';
|
||||
else if (status === '수리') badgeClass = 'b-purple';
|
||||
else if (status === '폐기') badgeClass = 'badge-muted';
|
||||
return `<span class="badge ${badgeClass}">${status}</span>`;
|
||||
}
|
||||
},
|
||||
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.USER_POSITION.ui, sortKey: ASSET_SCHEMA.USER_POSITION.key, align: 'center', render: a => a[ASSET_SCHEMA.USER_POSITION.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.CPU.ui, sortKey: ASSET_SCHEMA.CPU.key, align: 'center', render: a => a[ASSET_SCHEMA.CPU.key] || '' },
|
||||
{ header: ASSET_SCHEMA.MAINBOARD.ui, sortKey: ASSET_SCHEMA.MAINBOARD.key, align: 'center', render: a => a[ASSET_SCHEMA.MAINBOARD.key] || '-' },
|
||||
@@ -27,13 +61,35 @@ export function renderPcList(container: HTMLElement) {
|
||||
header: 'SSD',
|
||||
align: 'center',
|
||||
width: '8%',
|
||||
render: a => [a[ASSET_SCHEMA.SSD1.key], a[ASSET_SCHEMA.SSD2.key]].filter(Boolean).join(' / ') || '-'
|
||||
render: a => {
|
||||
try {
|
||||
const vols = a.volumes ? (typeof a.volumes === 'string' ? JSON.parse(a.volumes) : a.volumes) : [];
|
||||
if (Array.isArray(vols)) {
|
||||
const ssds = vols.filter((v: any) => v && String(v.type).toUpperCase() === 'SSD');
|
||||
if (ssds.length > 0) {
|
||||
return ssds.map((v: any) => `${v.capacity || ''}${v.unit || 'GB'}`).join(' / ');
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
header: 'HDD',
|
||||
align: 'center',
|
||||
width: '12%',
|
||||
render: a => [a[ASSET_SCHEMA.HDD1.key], a[ASSET_SCHEMA.HDD2.key], a[ASSET_SCHEMA.HDD3.key], a[ASSET_SCHEMA.HDD4.key]].filter(Boolean).join(' / ') || '-'
|
||||
render: a => {
|
||||
try {
|
||||
const vols = a.volumes ? (typeof a.volumes === 'string' ? JSON.parse(a.volumes) : a.volumes) : [];
|
||||
if (Array.isArray(vols)) {
|
||||
const hdds = vols.filter((v: any) => v && String(v.type).toUpperCase() === 'HDD');
|
||||
if (hdds.length > 0) {
|
||||
return hdds.map((v: any) => `${v.capacity || ''}${v.unit || 'GB'}`).join(' / ');
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return '-';
|
||||
}
|
||||
},
|
||||
{
|
||||
header: ASSET_SCHEMA.MAC_ADDR.ui,
|
||||
@@ -41,7 +97,18 @@ export function renderPcList(container: HTMLElement) {
|
||||
align: 'center',
|
||||
render: a => `<span style="font-family:monospace; font-size:11px;">${a[ASSET_SCHEMA.MAC_ADDR.key] || '-'}</span>`
|
||||
},
|
||||
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', width: '30%', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
|
||||
{
|
||||
header: '성능 등급',
|
||||
sortKey: '_pc_score',
|
||||
align: 'center',
|
||||
width: '8%',
|
||||
render: a => {
|
||||
const score = a._pc_score !== undefined ? a._pc_score : calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date);
|
||||
const isWin11Incompatible = isWindows11Incompatible(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key]);
|
||||
const grade = getPcGrade(score, isWin11Incompatible);
|
||||
return `<span class="badge ${grade.class}" title="성능 점수: ${score}점">${grade.name}</span>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,10 +22,11 @@ export function renderServerList(container: HTMLElement) {
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
{ header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, width: '15%', render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '-') },
|
||||
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, align: 'center', width: '15%', render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '-') },
|
||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||
{
|
||||
header: '모델/메인보드',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || a[ASSET_SCHEMA.MAINBOARD.key] || '-')
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ export function renderStorageList(container: HTMLElement) {
|
||||
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') },
|
||||
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, align: 'center', render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') },
|
||||
{ header: ASSET_SCHEMA.SERIAL_NUM.ui, sortKey: ASSET_SCHEMA.SERIAL_NUM.key, align: 'center', render: a => a[ASSET_SCHEMA.SERIAL_NUM.key] || '-' },
|
||||
{
|
||||
header: ASSET_SCHEMA.LOCATION.ui,
|
||||
|
||||
@@ -5,10 +5,10 @@ import { ASSET_SCHEMA } from '../../core/schema';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export function renderSwList(container: HTMLElement) {
|
||||
const isInternal = state.activeSubTab === '내부';
|
||||
|
||||
const isInternal = state.activeSubTab === '내부SW';
|
||||
|
||||
createListView(container, {
|
||||
title: isInternal ? '내부' : '외부',
|
||||
title: isInternal ? '내부SW' : '외부SW',
|
||||
dataSource: () => sortAssets(isInternal ? state.masterData.swInternal : state.masterData.swExternal),
|
||||
searchKeys: ['PRODUCT_NAME', 'CURRENT_USER', 'CURRENT_DEPT', 'ASSET_TYPE'],
|
||||
emptyMessage: '검색 결과가 없습니다.',
|
||||
|
||||
60
src/views/List/UserListView.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { state } from '../../core/state';
|
||||
import { openUserModal } from '../../components/Modal/UserModal';
|
||||
import { formatInline } from '../../core/utils';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export function renderUserList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '사용자',
|
||||
dataSource: () => state.masterData.users || [],
|
||||
searchKeys: ['emp_no', 'user_name', 'dept_name', 'position', 'status'],
|
||||
filterOptions: {
|
||||
keywordLabel: '사번/이름/부서/직급 검색',
|
||||
showCorp: false,
|
||||
showDept: true,
|
||||
showType: false
|
||||
},
|
||||
onRowClick: (user) => openUserModal(user, 'view'),
|
||||
columns: [
|
||||
{
|
||||
header: '사번',
|
||||
sortKey: 'emp_no',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: u => formatInline(u.emp_no || '-')
|
||||
},
|
||||
{
|
||||
header: '이름',
|
||||
sortKey: 'user_name',
|
||||
align: 'center',
|
||||
width: '15%',
|
||||
render: u => formatInline(u.user_name || '-')
|
||||
},
|
||||
{
|
||||
header: '조직 (부서)',
|
||||
sortKey: 'dept_name',
|
||||
align: 'left',
|
||||
width: '25%',
|
||||
render: u => formatInline(u.dept_name || '-')
|
||||
},
|
||||
{
|
||||
header: '직급 (직무)',
|
||||
sortKey: 'position',
|
||||
align: 'left',
|
||||
width: '25%',
|
||||
render: u => formatInline(u.position || '-')
|
||||
},
|
||||
{
|
||||
header: '상태',
|
||||
sortKey: 'status',
|
||||
align: 'center',
|
||||
width: '10%',
|
||||
render: u => {
|
||||
const status = u.status || '재직';
|
||||
const badgeClass = status === '퇴직' ? 'badge-danger' : 'badge-success';
|
||||
return `<span class="badge ${badgeClass}">${status}</span>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
150
src/views/List/table.css
Normal file
@@ -0,0 +1,150 @@
|
||||
/* --- Page Header for Description --- */
|
||||
.page-header {
|
||||
padding: 1.5rem 2rem 0.5rem; /* Padding added for better whitespace */
|
||||
}
|
||||
|
||||
.page-title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--fs-lg);
|
||||
font-weight: 600;
|
||||
color: var(--primary);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
line-height: 1.1;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: var(--fs-base);
|
||||
color: var(--mute);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* --- Table View Styles --- */
|
||||
.table-container {
|
||||
flex: 1;
|
||||
background-color: var(--canvas);
|
||||
overflow-x: auto;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed; /* Force fixed layout to prevent horizontal scroll */
|
||||
}
|
||||
|
||||
th, td {
|
||||
padding: 0.8rem 1rem;
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
text-align: left;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis; /* Show ... for long text */
|
||||
}
|
||||
|
||||
thead {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--canvas-soft) !important;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--mute);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
box-shadow: inset 0 -1px 0 var(--hairline);
|
||||
text-align: center; /* Set default header alignment to center */
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: var(--fs-base);
|
||||
color: var(--primary);
|
||||
font-weight: 400;
|
||||
text-align: left; /* Set default data alignment to left */
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: var(--canvas-soft-2);
|
||||
}
|
||||
|
||||
/* 정렬 클래스 */
|
||||
.text-center { text-align: center !important; }
|
||||
.text-right { text-align: right !important; }
|
||||
.text-left { text-align: left !important; }
|
||||
|
||||
/* 메모 컬럼 전용 */
|
||||
.col-memo {
|
||||
white-space: nowrap !important; /* Keep as one line */
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* --- Table Sorting --- */
|
||||
th.sortable {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
position: relative;
|
||||
padding-right: 1.8rem !important;
|
||||
}
|
||||
|
||||
th.sortable:hover {
|
||||
background-color: var(--canvas-soft-2) !important;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
th.sortable::after {
|
||||
content: '↕';
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: var(--fs-xs);
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
th.sortable.asc::after { content: '▲'; opacity: 1; color: var(--primary); }
|
||||
th.sortable.desc::after { content: '▼'; opacity: 1; color: var(--primary); }
|
||||
|
||||
/* --- Compact Table (Used in Dashboards/Modals) --- */
|
||||
.compact-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.compact-table th {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: var(--fs-sm);
|
||||
font-weight: 600;
|
||||
color: var(--mute);
|
||||
border-bottom: 1px solid var(--hairline);
|
||||
background: var(--canvas-soft);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.compact-table td {
|
||||
padding: 0.75rem 0.5rem;
|
||||
font-size: var(--fs-sm);
|
||||
border-bottom: 1px solid var(--hairline-soft, #f5f5f5);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
|
||||
.compact-table tr.clickable-row:hover {
|
||||
background: var(--canvas-soft);
|
||||
cursor: pointer;
|
||||
}
|
||||
251
src/views/LocationView.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { state } from '../core/state';
|
||||
import { openHwModal } from '../components/Modal/HWModal';
|
||||
import { ASSET_SCHEMA } from '../core/schema';
|
||||
import { LOCATION_DATA, IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
||||
|
||||
/**
|
||||
* 위치 중심 자산 현황 뷰 (Vercel Integrated)
|
||||
*/
|
||||
export async function renderLocationView(container: HTMLElement) {
|
||||
if (!container) return;
|
||||
|
||||
let currentLoc = '기술개발센터';
|
||||
let currentDetail = '서버실';
|
||||
let currentPage = 0;
|
||||
let mapConfig: any = {};
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/maps');
|
||||
mapConfig = await res.json();
|
||||
} catch (err) { console.error('Failed to load map config', err); }
|
||||
|
||||
const render = () => {
|
||||
const locImages = (IMAGE_LOCATIONS[currentLoc] && IMAGE_LOCATIONS[currentLoc][currentDetail])
|
||||
? IMAGE_LOCATIONS[currentLoc][currentDetail]
|
||||
: [];
|
||||
const mapPath = locImages[currentPage] || '';
|
||||
|
||||
// 조회 모드: 설정 파일에 정의된 asset_id를 기준으로 자산 데이터 매핑
|
||||
const allBoxes = mapConfig[mapPath] || [];
|
||||
const boxes = allBoxes.filter((box: any) => box.asset_id != null);
|
||||
|
||||
// 모든 하드웨어 카테고리에서 자산 검색
|
||||
const allHwAssets = [
|
||||
...state.masterData.pc,
|
||||
...state.masterData.server,
|
||||
...state.masterData.storage,
|
||||
...state.masterData.network,
|
||||
...state.masterData.equipment,
|
||||
...state.masterData.survey,
|
||||
...state.masterData.officeSupplies,
|
||||
...state.masterData.pcParts
|
||||
];
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="location-view-wrapper">
|
||||
<!-- 상단 통합 바 (Unified Search Bar) -->
|
||||
<div class="location-filter-bar search-bar">
|
||||
<div class="search-item">
|
||||
<label class="list-view-toggle-label">
|
||||
<input type="checkbox" id="chk-list-view-loc" />
|
||||
목록보기
|
||||
</label>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<label>건물/위치</label>
|
||||
<select id="sel-loc-main">
|
||||
${Object.keys(LOCATION_DATA).map(loc => `<option value="${loc}" ${loc === currentLoc ? 'selected' : ''}>${loc}</option>`).join('')}
|
||||
</select>
|
||||
</div>
|
||||
<div class="search-item">
|
||||
<label>상세 위치</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="sel-loc-detail">
|
||||
${(LOCATION_DATA[currentLoc] || []).map(det => `<option value="${det}" ${det === currentDetail ? 'selected' : ''}>${det}</option>`).join('')}
|
||||
</select>
|
||||
|
||||
<!-- 페이지네이션 -->
|
||||
${locImages.length > 1 ? `
|
||||
<div class="map-pagination-group">
|
||||
<div class="page-btns flex gap-1">
|
||||
<button id="btn-prev-page" class="btn btn-outline btn-sm" ${currentPage === 0 ? 'disabled' : ''}>이전</button>
|
||||
<button id="btn-next-page" class="btn btn-outline btn-sm" ${currentPage === locImages.length - 1 ? 'disabled' : ''}>다음</button>
|
||||
</div>
|
||||
<span class="page-info">(${currentPage + 1} / ${locImages.length})</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="location-main-content">
|
||||
<!-- 지도 섹션 -->
|
||||
<div class="map-container-section">
|
||||
<div class="map-frame-wrapper">
|
||||
${mapPath ? `
|
||||
<img src="${mapPath}" id="main-map-img" class="map-image">
|
||||
<div id="box-overlay" class="map-overlay">
|
||||
${boxes.map((box: any, idx: number) => {
|
||||
const asset = allHwAssets.find(a => a.id === box.asset_id);
|
||||
const name = asset ? ((asset as any).asset_purpose || asset.asset_code) : (box.name || `#${idx+1}`);
|
||||
// w, h가 없거나 너무 작으면 최소 크기(3%) 보장하여 영역으로 표시
|
||||
const width = Math.max(parseFloat(box.w || '3'), 3);
|
||||
const height = Math.max(parseFloat(box.h || '3'), 3);
|
||||
return `
|
||||
<div class="location-box-area"
|
||||
data-asset-id="${box.asset_id}"
|
||||
data-name="${name}"
|
||||
style="left:${box.x}%; top:${box.y}%; width:${width}%; height:${height}%;
|
||||
border: 2px solid var(--primary-color); background: rgba(30, 81, 73, 0.1); cursor:pointer; pointer-events: auto; position: absolute;">
|
||||
</div>
|
||||
`}).join('')}
|
||||
</div>
|
||||
` : '<div class="no-map-message">해당 위치의 도면이 등록되지 않았습니다.</div>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 정보 섹션 -->
|
||||
<div class="asset-list-section">
|
||||
<div class="section-header">
|
||||
<h4 id="loc-list-title" class="sidebar-title">구역을 선택하세요</h4>
|
||||
</div>
|
||||
<div id="loc-asset-table-container" class="mini-table-wrapper">
|
||||
<div class="empty-state">지도에서 자산 위치를 클릭하세요.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const syncOverlaySize = () => {
|
||||
const img = container.querySelector('#main-map-img') as HTMLImageElement;
|
||||
const overlay = container.querySelector('#box-overlay') as HTMLElement;
|
||||
|
||||
if (img && overlay && img.complete) {
|
||||
overlay.style.width = img.clientWidth + 'px';
|
||||
overlay.style.height = img.clientHeight + 'px';
|
||||
overlay.style.left = img.offsetLeft + 'px';
|
||||
overlay.style.top = img.offsetTop + 'px';
|
||||
}
|
||||
};
|
||||
|
||||
const img = container.querySelector('#main-map-img') as HTMLImageElement;
|
||||
if (img) {
|
||||
if (img.complete) {
|
||||
syncOverlaySize();
|
||||
setTimeout(syncOverlaySize, 50);
|
||||
} else {
|
||||
img.onload = syncOverlaySize;
|
||||
}
|
||||
}
|
||||
|
||||
window.removeEventListener('resize', syncOverlaySize);
|
||||
window.addEventListener('resize', syncOverlaySize);
|
||||
|
||||
const selMain = container.querySelector('#sel-loc-main') as HTMLSelectElement;
|
||||
selMain?.addEventListener('change', () => {
|
||||
currentLoc = selMain.value;
|
||||
currentDetail = LOCATION_DATA[currentLoc][0];
|
||||
currentPage = 0;
|
||||
render();
|
||||
});
|
||||
|
||||
const selDetail = container.querySelector('#sel-loc-detail') as HTMLSelectElement;
|
||||
selDetail?.addEventListener('change', () => {
|
||||
currentDetail = selDetail.value;
|
||||
currentPage = 0;
|
||||
render();
|
||||
});
|
||||
|
||||
container.querySelector('#btn-prev-page')?.addEventListener('click', () => { currentPage--; render(); });
|
||||
container.querySelector('#btn-next-page')?.addEventListener('click', () => { currentPage++; render(); });
|
||||
|
||||
const chkBox = container.querySelector('#chk-list-view-loc') as HTMLInputElement;
|
||||
|
||||
if (chkBox) {
|
||||
chkBox.checked = state.viewMode === 'list';
|
||||
const handleToggle = () => {
|
||||
const isListMode = chkBox.checked;
|
||||
if (isListMode) {
|
||||
state.viewMode = 'list';
|
||||
} else {
|
||||
state.viewMode = 'location';
|
||||
}
|
||||
window.dispatchEvent(new Event('refresh-view'));
|
||||
};
|
||||
chkBox.addEventListener('change', handleToggle);
|
||||
}
|
||||
|
||||
container.querySelectorAll('.location-box-area').forEach(box => {
|
||||
box.addEventListener('click', () => {
|
||||
const assetId = box.getAttribute('data-asset-id');
|
||||
if (!assetId) return;
|
||||
|
||||
const targetAsset = allHwAssets.find(a => a.id === assetId);
|
||||
|
||||
if (targetAsset) renderAssetDetail(targetAsset);
|
||||
container.querySelectorAll('.location-box-area').forEach(b => (b as HTMLElement).style.background = 'rgba(30, 81, 73, 0.1)');
|
||||
(box as HTMLElement).style.background = 'rgba(30, 81, 73, 0.4)';
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const renderAssetDetail = (asset: any) => {
|
||||
const title = container.querySelector('#loc-list-title')!;
|
||||
const tableContainer = container.querySelector('#loc-asset-table-container')!;
|
||||
|
||||
title.innerHTML = `
|
||||
<div class="detail-header-actions">
|
||||
<div class="header-identity">
|
||||
<span class="asset-code-title">${asset.asset_code || '미부여'}</span>
|
||||
<span class="service-type-badge">${asset.service_type || '운영'}</span>
|
||||
<span class="asset-type-label">${asset.asset_type || 'PC'}</span>
|
||||
</div>
|
||||
<button id="btn-view-from-loc" class="btn btn-primary btn-sm">조회</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const fields = [
|
||||
{ label: ASSET_SCHEMA.CURRENT_DEPT.ui, value: asset.current_dept },
|
||||
{ label: ASSET_SCHEMA.HW_STATUS.ui, value: asset.hw_status },
|
||||
{ label: ASSET_SCHEMA.MANAGER_MAIN.ui, value: asset.manager_primary },
|
||||
{ label: ASSET_SCHEMA.MANAGER_SUB.ui, value: asset.manager_secondary },
|
||||
{ label: ASSET_SCHEMA.ASSET_PURPOSE.ui, value: asset.asset_purpose, fullWidth: true },
|
||||
{ label: ASSET_SCHEMA.MODEL_NAME.ui, value: asset.model_name },
|
||||
{ label: ASSET_SCHEMA.OS.ui, value: asset.os },
|
||||
{ label: ASSET_SCHEMA.CPU.ui, value: asset.cpu },
|
||||
{ label: ASSET_SCHEMA.RAM.ui, value: asset.ram },
|
||||
{ label: ASSET_SCHEMA.GPU.ui, value: asset.gpu, fullWidth: true },
|
||||
{ label: ASSET_SCHEMA.IP_ADDR.ui, value: asset.ip_address },
|
||||
{ label: ASSET_SCHEMA.MAC_ADDR.ui, value: asset.mac_address },
|
||||
{ label: ASSET_SCHEMA.REMOTE_TOOL.ui, value: asset.remote_tool },
|
||||
{ label: ASSET_SCHEMA.MONITORING.ui, value: asset.monitoring },
|
||||
{ label: ASSET_SCHEMA.MEMO.ui, value: asset.memo, fullWidth: true }
|
||||
];
|
||||
|
||||
const sectionsHTML = `
|
||||
<div class="detail-section" style="margin-bottom: 0;">
|
||||
<div class="detail-grid-2col" style="gap: 0.75rem 1rem;">
|
||||
${fields.map(f => `
|
||||
<div class="detail-item ${f.fullWidth ? 'full-width' : ''}">
|
||||
<div class="detail-label-sm">${f.label}</div>
|
||||
<div class="detail-value-lg">${f.value || '-'}</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
tableContainer.innerHTML = `
|
||||
<div class="asset-detail-sidebar">
|
||||
${sectionsHTML}
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.querySelector('#btn-view-from-loc')?.addEventListener('click', () => {
|
||||
openHwModal(asset, 'view');
|
||||
});
|
||||
};
|
||||
|
||||
render();
|
||||
}
|
||||
@@ -18,6 +18,7 @@ export class MapEditor {
|
||||
private startY: number = 0;
|
||||
private currentBox: HTMLElement | null = null;
|
||||
private currentPath: string = '';
|
||||
private assetOptions: {id: string, name: string}[] = [];
|
||||
|
||||
constructor() {
|
||||
this.container = document.getElementById('container')!;
|
||||
@@ -33,11 +34,35 @@ export class MapEditor {
|
||||
public async init() {
|
||||
this.renderFileSidebar();
|
||||
await this.loadConfig();
|
||||
await this.loadAssets();
|
||||
this.bindEvents();
|
||||
this.selectFirstFile();
|
||||
createIcons({ icons: { X, Save, Trash2, ChevronLeft, ChevronRight } });
|
||||
}
|
||||
|
||||
private async loadAssets() {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/assets/master`);
|
||||
const masterData = await res.json();
|
||||
const allHw = [
|
||||
...(masterData.pc || []),
|
||||
...(masterData.server || []),
|
||||
...(masterData.storage || []),
|
||||
...(masterData.network || []),
|
||||
...(masterData.equipment || []),
|
||||
...(masterData.survey || []),
|
||||
...(masterData.officeSupplies || []),
|
||||
...(masterData.pcParts || [])
|
||||
];
|
||||
this.assetOptions = allHw.map(a => ({
|
||||
id: a.id,
|
||||
name: `[${a.asset_code}] ${a.asset_purpose || a.model_name || a.category}`
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Failed to load assets for mapping', err);
|
||||
}
|
||||
}
|
||||
|
||||
private renderFileSidebar() {
|
||||
let html = '';
|
||||
Object.entries(IMAGE_LOCATIONS).forEach(([bldg, details]) => {
|
||||
@@ -137,7 +162,8 @@ export class MapEditor {
|
||||
x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2),
|
||||
y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2),
|
||||
w: (width / rect.width * 100).toFixed(2),
|
||||
h: (height / rect.height * 100).toFixed(2)
|
||||
h: (height / rect.height * 100).toFixed(2),
|
||||
asset_id: null
|
||||
};
|
||||
this.boxes.push(boxData);
|
||||
this.render();
|
||||
@@ -152,14 +178,14 @@ export class MapEditor {
|
||||
this.render();
|
||||
};
|
||||
|
||||
(window as any).clearAll = () => {
|
||||
document.getElementById('btn-clear-all')?.addEventListener('click', () => {
|
||||
if(confirm('모든 박스를 삭제할까요?')) {
|
||||
this.boxes = [];
|
||||
this.render();
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
(window as any).saveToServer = () => this.saveToServer();
|
||||
document.getElementById('btn-save-server')?.addEventListener('click', () => this.saveToServer());
|
||||
}
|
||||
|
||||
private async saveToServer() {
|
||||
@@ -210,13 +236,63 @@ export class MapEditor {
|
||||
|
||||
this.wrapper.appendChild(div);
|
||||
|
||||
// Create asset options dropdown
|
||||
let optionsHtml = '<option value="">-- 자산 매핑 안 됨 --</option>';
|
||||
this.assetOptions.forEach(opt => {
|
||||
const selected = box.asset_id === opt.id ? 'selected' : '';
|
||||
optionsHtml += `<option value="${opt.id}" ${selected}>${opt.name}</option>`;
|
||||
});
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'box-item';
|
||||
item.innerHTML = `
|
||||
<span>#${i+1}: [${box.x}, ${box.y}]</span>
|
||||
<button class="btn-del" onclick="removeBox(${i})">×</button>
|
||||
<div class="box-header">
|
||||
<span class="box-index">#${i+1}</span>
|
||||
<button class="btn-del" onclick="removeBox(${i})">×</button>
|
||||
</div>
|
||||
<div class="box-inputs margin-bottom">
|
||||
<select data-index="${i}" data-prop="asset_id">
|
||||
${optionsHtml}
|
||||
</select>
|
||||
</div>
|
||||
<div class="box-inputs">
|
||||
<div class="input-group">
|
||||
<label>X</label>
|
||||
<input type="number" step="0.01" value="${box.x}" data-index="${i}" data-prop="x">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>Y</label>
|
||||
<input type="number" step="0.01" value="${box.y}" data-index="${i}" data-prop="y">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>W</label>
|
||||
<input type="number" step="0.01" value="${box.w}" data-index="${i}" data-prop="w">
|
||||
</div>
|
||||
<div class="input-group">
|
||||
<label>H</label>
|
||||
<input type="number" step="0.01" value="${box.h}" data-index="${i}" data-prop="h">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.boxListEl.appendChild(item);
|
||||
});
|
||||
|
||||
// Add events to new inputs and selects
|
||||
this.boxListEl.querySelectorAll('input, select').forEach(input => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const target = e.target as HTMLInputElement | HTMLSelectElement;
|
||||
const index = parseInt(target.dataset.index!);
|
||||
const prop = target.dataset.prop!;
|
||||
|
||||
if (this.boxes[index]) {
|
||||
if (prop === 'asset_id') {
|
||||
this.boxes[index][prop] = target.value || null;
|
||||
} else {
|
||||
this.boxes[index][prop] = parseFloat(target.value).toFixed(2);
|
||||
this.render(); // Re-render to update the map visual size
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,22 +9,28 @@ import { renderCloudList } from './List/CloudListView';
|
||||
import { renderDomainList } from './List/DomainListView';
|
||||
import { renderNetworkList } from './List/NetworkListView';
|
||||
import { renderPcPartList } from './List/PcPartListView';
|
||||
import { renderPartsMasterList } from './List/PartsMasterListView';
|
||||
import { renderSpaceInfoList } from './List/SpaceInfoListView';
|
||||
import { renderGiftList } from './List/GiftListView';
|
||||
import { renderFacilityList } from './List/FacilityListView';
|
||||
import { renderCostList } from './List/CostListView';
|
||||
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw } from 'lucide';
|
||||
import { renderUserList } from './List/UserListView';
|
||||
import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, BookOpen, Settings } from 'lucide';
|
||||
|
||||
/**
|
||||
* 자산 목록 테이블 렌더링 통합 허브
|
||||
* 자산 목록 테이블 렌더링 통합 허브 (Vercel Style Normalized)
|
||||
*/
|
||||
export function renderSWTable(mainContent: HTMLElement) {
|
||||
if (!mainContent) return;
|
||||
console.log(`📂 Rendering Table for: ${state.activeCategory} / ${state.activeSubTab}`);
|
||||
|
||||
mainContent.innerHTML = '';
|
||||
const container = document.createElement('div');
|
||||
container.className = 'view-container';
|
||||
mainContent.innerHTML = `
|
||||
<div class="view-content-wrapper">
|
||||
<div id="list-view-container" style="flex: 1; display: flex; flex-direction: column; overflow: hidden;"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const container = document.getElementById('list-view-container')!;
|
||||
|
||||
try {
|
||||
const tab = state.activeSubTab;
|
||||
@@ -36,12 +42,13 @@ export function renderSWTable(mainContent: HTMLElement) {
|
||||
else if (tab === '업무지원장비') renderEquipmentList(container);
|
||||
else if (tab === '네트워크') renderNetworkList(container);
|
||||
else if (tab === 'PC부품') renderPcPartList(container);
|
||||
else if (tab === '부품 마스터') renderPartsMasterList(container);
|
||||
else if (tab === '공간정보장비') renderSpaceInfoList(container);
|
||||
else {
|
||||
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 하드웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
|
||||
}
|
||||
} else if (state.activeCategory === 'sw') {
|
||||
if (tab === '외부' || tab === '내부') {
|
||||
if (tab === '외부SW' || tab === '내부SW') {
|
||||
renderSwList(container);
|
||||
} else {
|
||||
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 소프트웨어 리스트 뷰가 정의되지 않았습니다.</div>`;
|
||||
@@ -50,6 +57,7 @@ export function renderSWTable(mainContent: HTMLElement) {
|
||||
if (tab === '도메인') renderDomainList(container);
|
||||
else if (tab === '클라우드') renderCloudList(container);
|
||||
else if (tab === '비용관리') renderCostList(container);
|
||||
else if (tab === '사용자') renderUserList(container);
|
||||
else {
|
||||
container.innerHTML = `<div style="padding:2rem; color:var(--text-muted);">"${tab}" 탭에 대한 운영지원 리스트 뷰가 정의되지 않았습니다.</div>`;
|
||||
}
|
||||
@@ -65,11 +73,9 @@ export function renderSWTable(mainContent: HTMLElement) {
|
||||
}
|
||||
}
|
||||
|
||||
mainContent.appendChild(container);
|
||||
|
||||
// 전역 아이콘 초기화 (한 번 더 실행하여 누락 방지)
|
||||
// 전역 아이콘 초기화
|
||||
createIcons({
|
||||
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw }
|
||||
icons: { Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, RefreshCcw, BookOpen, Settings }
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('❌ Error rendering table view:', err);
|
||||
|
||||
@@ -12,8 +12,8 @@
|
||||
.folder-item {
|
||||
padding: 10px 15px;
|
||||
background: var(--bg-light);
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-size: var(--fs-base);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
@@ -21,13 +21,13 @@
|
||||
.file-item {
|
||||
padding: 8px 25px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
font-size: var(--fs-sm);
|
||||
border-bottom: 1px solid var(--bg-color);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover { background: var(--bg-light); }
|
||||
.file-item.active { background: var(--primary-color); color: var(--white); font-weight: bold; }
|
||||
.file-item.active { background: var(--primary-color); color: var(--white); font-weight: 700; }
|
||||
|
||||
/* Center: Editor Area */
|
||||
.editor-container {
|
||||
@@ -70,10 +70,10 @@
|
||||
box-shadow: -5px 0 15px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.sidebar h2 { margin-top: 0; color: var(--primary-color); font-size: 1.2rem; }
|
||||
.sidebar p { font-size: 0.85rem; color: var(--text-muted); line-height: 1.4; margin-bottom: 20px; }
|
||||
.sidebar h2 { margin-top: 0; color: var(--primary-color); font-size: var(--fs-lg); font-weight: 600; }
|
||||
.sidebar p { font-size: var(--fs-sm); color: var(--text-muted); line-height: 1.4; margin-bottom: 20px; }
|
||||
|
||||
.current-path { font-size: 11px; color: var(--text-muted); margin-bottom: 10px; word-break: break-all; font-family: monospace; }
|
||||
.current-path { font-size: var(--fs-xs); color: var(--text-muted); margin-bottom: 10px; word-break: break-all; font-family: monospace; }
|
||||
|
||||
.box-list {
|
||||
flex: 1;
|
||||
@@ -87,16 +87,57 @@
|
||||
|
||||
.box-item {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
padding: 6px;
|
||||
font-size: var(--fs-xs);
|
||||
padding: 10px 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.box-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.box-index {
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.box-inputs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.input-group label {
|
||||
color: var(--text-muted);
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
width: 100%;
|
||||
padding: 2px 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 2px;
|
||||
font-size: var(--fs-xs);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-group input:focus {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.box-item:hover { background: var(--white); }
|
||||
.btn-del { cursor: pointer; color: var(--danger); border: none; background: none; font-size: 16px; padding: 0 5px; }
|
||||
.btn-del { cursor: pointer; color: var(--danger); border: none; background: none; font-size: var(--fs-md); padding: 0 5px; }
|
||||
|
||||
.actions { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
@@ -133,8 +174,8 @@
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
@@ -151,9 +192,45 @@
|
||||
|
||||
#save-status {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--success);
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
font-weight: 700;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
/* Editor Body & Layout Overrides */
|
||||
.editor-body {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-version {
|
||||
font-size: var(--fs-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.actions .btn {
|
||||
height: 38px;
|
||||
}
|
||||
|
||||
/* Box Item Dropdown Inputs */
|
||||
.box-inputs.margin-bottom {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.box-inputs select {
|
||||
width: 100%;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: var(--fs-xs);
|
||||
background-color: var(--canvas);
|
||||
color: var(--text-main);
|
||||
outline: none;
|
||||
}
|
||||
.box-inputs select:focus {
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||