Merge remote-tracking branch 'origin/main' into ux_setting
This commit is contained in:
6
.env
Normal file
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
|
||||
379
PC_사양_개선_기획서.html
Normal file
379
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>
|
||||
429
PC_사양_적정성_분석_기획서.html
Normal file
429
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>
|
||||
60
PLAN_ASSET_HISTORY.md
Normal file
60
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) 확인.
|
||||
BIN
SampleData_PC.xlsx
Normal file
BIN
SampleData_PC.xlsx
Normal file
Binary file not shown.
BIN
SampleData_SVR.xlsx
Normal file
BIN
SampleData_SVR.xlsx
Normal file
Binary file not shown.
59
backup_db.js
Normal file
59
backup_db.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
import * as xlsx from 'xlsx';
|
||||
import fs from 'fs';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
|
||||
|
||||
async function backup() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('🚀 Starting Database Backup Process...');
|
||||
|
||||
const tables = [
|
||||
'asset_pc', 'asset_server', 'asset_storage', 'asset_remote',
|
||||
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
|
||||
];
|
||||
|
||||
const wb = xlsx.utils.book_new();
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
// 1. Create table backup
|
||||
await connection.query(`DROP TABLE IF EXISTS ${table}_backup`);
|
||||
await connection.query(`CREATE TABLE ${table}_backup AS SELECT * FROM ${table}`);
|
||||
console.log(`✅ Table backup created: ${table} -> ${table}_backup`);
|
||||
|
||||
// 2. Fetch data for Excel
|
||||
const [rows] = await connection.query(`SELECT * FROM ${table}`);
|
||||
if (rows.length > 0) {
|
||||
const ws = xlsx.utils.json_to_sheet(rows);
|
||||
// Sheet names max length is 31 chars
|
||||
const sheetName = table.substring(0, 31);
|
||||
xlsx.utils.book_append_sheet(wb, ws, sheetName);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(`⚠️ Skipped ${table}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Write Excel file
|
||||
const fileName = 'backupDB_20260608.xlsx';
|
||||
xlsx.writeFile(wb, fileName);
|
||||
console.log(`✅ Excel data exported successfully to ${fileName}`);
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
backup().catch(err => {
|
||||
console.error('❌ Backup Failed:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
28
check_logs.js
Normal file
28
check_logs.js
Normal file
@@ -0,0 +1,28 @@
|
||||
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 checkRecentLogs() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('--- Recent History Logs ---');
|
||||
const [rows] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC LIMIT 5');
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
|
||||
console.log('\n--- Recent Core Data (to check current_dept) ---');
|
||||
const [coreRows] = await connection.query('SELECT id, asset_code, current_dept, previous_dept FROM asset_core ORDER BY updated_at DESC LIMIT 5');
|
||||
console.log(JSON.stringify(coreRows, null, 2));
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
checkRecentLogs().catch(console.error);
|
||||
29
check_network.js
Normal file
29
check_network.js
Normal file
@@ -0,0 +1,29 @@
|
||||
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 checkRemote() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('--- Checking asset_remote table ---');
|
||||
|
||||
const [columns] = await connection.query('DESCRIBE asset_remote');
|
||||
const cols = columns.map(c => c.Field);
|
||||
console.log('Columns in asset_remote:', cols.join(', '));
|
||||
|
||||
const [count] = await connection.query('SELECT COUNT(*) as count FROM asset_remote WHERE remote_tool IS NOT NULL OR remote_id IS NOT NULL');
|
||||
console.log(`Rows with remote info (tool or id): ${count[0].count}`);
|
||||
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
checkRemote().catch(console.error);
|
||||
44
drop_legacy.js
Normal file
44
drop_legacy.js
Normal file
@@ -0,0 +1,44 @@
|
||||
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 dropLegacyTables() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('🧹 Starting cleanup of obsolete legacy backup tables...');
|
||||
|
||||
const tablesToDrop = [
|
||||
'asset_pc', 'asset_pc_backup',
|
||||
'asset_server', 'asset_server_backup',
|
||||
'asset_storage', 'asset_storage_backup',
|
||||
'asset_remote_backup', // IMPORTANT: DO NOT drop asset_remote!
|
||||
'asset_equipment', 'asset_equipment_backup',
|
||||
'asset_office_supplies', 'asset_office_supplies_backup',
|
||||
'asset_survey', 'asset_survey_backup',
|
||||
'asset_vip', 'asset_vip_backup',
|
||||
'asset_pc_parts'
|
||||
];
|
||||
|
||||
for (const table of tablesToDrop) {
|
||||
try {
|
||||
await connection.query(`DROP TABLE IF EXISTS ${table}`);
|
||||
console.log(`✅ Dropped table: ${table}`);
|
||||
} catch (err) {
|
||||
console.warn(`⚠️ Failed to drop table ${table}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 Cleanup complete. Database is now lean and mean.');
|
||||
await connection.end();
|
||||
}
|
||||
|
||||
dropLegacyTables().catch(console.error);
|
||||
30
index.html
30
index.html
@@ -19,36 +19,6 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Login Screen -->
|
||||
<div id="login-container" class="login-layout">
|
||||
<div class="login-card">
|
||||
<div class="login-header">
|
||||
<img src="/image 92.png" alt="Logo" class="login-logo" />
|
||||
<h2>ITAM 시스템</h2>
|
||||
<p>자산 관리 포털에 오신 것을 환영합니다</p>
|
||||
</div>
|
||||
<div id="login-selection" class="login-selection">
|
||||
<div class="role-card" data-role="admin">
|
||||
<div class="role-icon">
|
||||
<i data-lucide="settings"></i>
|
||||
</div>
|
||||
<h3>관리자</h3>
|
||||
<p>시스템 설정 및 자산 마스터 관리</p>
|
||||
</div>
|
||||
<div class="role-card" data-role="user">
|
||||
<div class="role-icon">
|
||||
<i data-lucide="monitor"></i>
|
||||
</div>
|
||||
<h3>실무자</h3>
|
||||
<p>자산 조회 및 현황 확인</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="login-footer">
|
||||
<p>© 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="app-layout" id="app-layout" style="display: none;">
|
||||
<!-- Single-Line Integrated Header -->
|
||||
<header class="main-header">
|
||||
|
||||
197
migrate_schema.js
Normal file
197
migrate_schema.js
Normal file
@@ -0,0 +1,197 @@
|
||||
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 migrateSchema() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('🚀 Phase 1: Creating Normalized Tables & Migrating Data...');
|
||||
|
||||
try {
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
// --- 1. Drop existing new tables if they exist ---
|
||||
await connection.query('DROP TABLE IF EXISTS asset_core, asset_hardware, asset_location, asset_remote');
|
||||
|
||||
// --- 2. Create New Schema ---
|
||||
await connection.query(`
|
||||
CREATE TABLE asset_core (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
asset_code VARCHAR(100) UNIQUE NOT NULL,
|
||||
category VARCHAR(100),
|
||||
asset_type VARCHAR(100),
|
||||
asset_purpose VARCHAR(255),
|
||||
service_type VARCHAR(50),
|
||||
purchase_corp VARCHAR(100),
|
||||
purchase_date VARCHAR(50),
|
||||
purchase_amount VARCHAR(100),
|
||||
purchase_vendor VARCHAR(255),
|
||||
approval_document VARCHAR(255),
|
||||
memo TEXT,
|
||||
manager_primary VARCHAR(100),
|
||||
manager_secondary VARCHAR(100),
|
||||
current_dept VARCHAR(255),
|
||||
previous_dept VARCHAR(255),
|
||||
user_current VARCHAR(100),
|
||||
previous_user VARCHAR(100),
|
||||
emp_no VARCHAR(20),
|
||||
user_position VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE asset_hardware (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_id VARCHAR(50) NOT NULL,
|
||||
hw_status VARCHAR(50),
|
||||
model_name VARCHAR(255),
|
||||
mainboard VARCHAR(255),
|
||||
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),
|
||||
volume VARCHAR(100),
|
||||
monitor_inch VARCHAR(50),
|
||||
serial_num VARCHAR(100),
|
||||
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE asset_location (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_id VARCHAR(50) NOT NULL,
|
||||
location VARCHAR(255),
|
||||
location_detail VARCHAR(255),
|
||||
location_photo VARCHAR(255),
|
||||
loc_x VARCHAR(20),
|
||||
loc_y VARCHAR(20),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE asset_remote (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_id VARCHAR(50) NOT NULL,
|
||||
ip_address VARCHAR(100),
|
||||
mac_address VARCHAR(100),
|
||||
remote_tool VARCHAR(100),
|
||||
remote_id VARCHAR(100),
|
||||
remote_pw VARCHAR(100),
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
console.log('✅ Normalized tables created.');
|
||||
|
||||
// --- 3. Migrate Data from Legacy Tables ---
|
||||
const legacyTables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_remote', 'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'];
|
||||
|
||||
let totalMigrated = 0;
|
||||
|
||||
for (const table of legacyTables) {
|
||||
try {
|
||||
const [rows] = await connection.query(`SELECT * FROM ${table}`);
|
||||
|
||||
for (const row of rows) {
|
||||
// 3.1 Insert into asset_core
|
||||
await connection.query(`
|
||||
INSERT IGNORE INTO asset_core (
|
||||
id, asset_code, category, asset_type, 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, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
row.id, row.asset_code, row.category, row.asset_type, row.asset_purpose, row.service_type,
|
||||
row.purchase_corp, row.purchase_date, row.purchase_amount, row.purchase_vendor, row.approval_document,
|
||||
row.memo, row.manager_primary, row.manager_secondary, row.current_dept, row.previous_dept,
|
||||
row.user_current, row.previous_user, row.emp_no, row.user_position, row.created_at
|
||||
]);
|
||||
|
||||
// 3.2 Insert into asset_hardware (if hardware fields exist)
|
||||
if (row.model_name || row.cpu || row.ram || row.hw_status) {
|
||||
await connection.query(`
|
||||
INSERT INTO asset_hardware (
|
||||
asset_id, hw_status, model_name, mainboard, os, cpu, ram, gpu, storage1, storage2, storage3, monitoring, price, volume, monitor_inch, serial_num
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
row.id, row.hw_status, row.model_name, row.mainboard, row.os, row.cpu, row.ram, row.gpu,
|
||||
row.ssd_1 || row.hdd_1, row.ssd_2 || row.hdd_2, row.hdd_3, row.monitoring, row.price,
|
||||
row.volume, row.monitor_inch, row.serial_num
|
||||
]);
|
||||
}
|
||||
|
||||
// 3.3 Insert into asset_location (if location fields exist)
|
||||
if (row.location || row.location_detail) {
|
||||
await connection.query(`
|
||||
INSERT INTO asset_location (
|
||||
asset_id, location, location_detail, location_photo, loc_x, loc_y
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
row.id, row.location, row.location_detail, row.location_photo, row.loc_x, row.loc_y
|
||||
]);
|
||||
}
|
||||
|
||||
// 3.4 Insert into asset_remote (if network fields exist)
|
||||
// Handle primary network interface
|
||||
if (row.ip_address || row.mac_address || row.remote_tool) {
|
||||
await connection.query(`
|
||||
INSERT INTO asset_remote (
|
||||
asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
row.id, row.ip_address, row.mac_address, row.remote_tool, row.remote_id, row.remote_pw
|
||||
]);
|
||||
}
|
||||
|
||||
// Handle secondary network interface (e.g., from server table) if it exists
|
||||
if (row.ip_address_2 || row.remote_tool_2) {
|
||||
await connection.query(`
|
||||
INSERT INTO asset_remote (
|
||||
asset_id, ip_address, remote_tool, remote_id, remote_pw
|
||||
) VALUES (?, ?, ?, ?, ?)
|
||||
`, [
|
||||
row.id, row.ip_address_2, row.remote_tool_2, row.remote_id_2, row.remote_pw_2
|
||||
]);
|
||||
}
|
||||
|
||||
totalMigrated++;
|
||||
}
|
||||
console.log(`- Migrated ${rows.length} records from ${table}`);
|
||||
} catch (err) {
|
||||
console.warn(`- Skipping legacy table ${table}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`✅ Phase 1 Data Migration Completed. Total Assets Migrated: ${totalMigrated}`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Migration Failed:', err);
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
migrateSchema();
|
||||
212
migrate_v2_final.js
Normal file
212
migrate_v2_final.js
Normal file
@@ -0,0 +1,212 @@
|
||||
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 migrateV2() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('🚀 Phase 2: Final Migration to Normalized V2 Schema...');
|
||||
|
||||
try {
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
|
||||
|
||||
// 1. Create/Enhance Core Tables
|
||||
console.log('1. Creating/Enhancing Tables...');
|
||||
|
||||
await connection.query('DROP TABLE IF EXISTS asset_core, asset_hardware, asset_location, asset_remote');
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE asset_core (
|
||||
id VARCHAR(50) PRIMARY KEY,
|
||||
asset_code VARCHAR(100) UNIQUE NOT NULL,
|
||||
category VARCHAR(100),
|
||||
asset_type VARCHAR(100),
|
||||
current_role VARCHAR(50) DEFAULT 'Normal' COMMENT 'Normal, Server, Personal, etc.',
|
||||
asset_purpose VARCHAR(255),
|
||||
service_type VARCHAR(50),
|
||||
purchase_corp VARCHAR(100),
|
||||
purchase_date VARCHAR(50),
|
||||
purchase_amount VARCHAR(100),
|
||||
purchase_vendor VARCHAR(255),
|
||||
approval_document VARCHAR(255),
|
||||
memo TEXT,
|
||||
manager_primary VARCHAR(100),
|
||||
manager_secondary VARCHAR(100),
|
||||
current_dept VARCHAR(255),
|
||||
previous_dept VARCHAR(255),
|
||||
user_current VARCHAR(100),
|
||||
previous_user VARCHAR(100),
|
||||
emp_no VARCHAR(20),
|
||||
user_position VARCHAR(50),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE asset_hardware (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_id VARCHAR(50) NOT NULL,
|
||||
hw_status VARCHAR(50),
|
||||
model_name VARCHAR(255),
|
||||
mainboard VARCHAR(255),
|
||||
os VARCHAR(100),
|
||||
cpu VARCHAR(255),
|
||||
ram VARCHAR(100),
|
||||
gpu VARCHAR(100),
|
||||
storage1 VARCHAR(255),
|
||||
storage2 VARCHAR(255),
|
||||
storage3 VARCHAR(255),
|
||||
storage4 VARCHAR(255),
|
||||
monitoring VARCHAR(100),
|
||||
price VARCHAR(100),
|
||||
volume VARCHAR(100),
|
||||
monitor_inch VARCHAR(50),
|
||||
serial_num VARCHAR(100),
|
||||
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE asset_location (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_id VARCHAR(50) NOT NULL,
|
||||
location VARCHAR(255),
|
||||
location_detail VARCHAR(255),
|
||||
location_photo VARCHAR(255),
|
||||
loc_x VARCHAR(20),
|
||||
loc_y VARCHAR(20),
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
deactivated_at DATETIME NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
await connection.query(`
|
||||
CREATE TABLE asset_remote (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_id VARCHAR(50) NOT NULL,
|
||||
ip_address VARCHAR(100),
|
||||
mac_address VARCHAR(100),
|
||||
remote_tool VARCHAR(100),
|
||||
remote_id VARCHAR(100),
|
||||
remote_pw VARCHAR(100),
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
deactivated_at DATETIME NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
console.log('✅ V2 Schema tables created.');
|
||||
|
||||
// 2. Migration Logic
|
||||
const legacyTables = [
|
||||
{ name: 'asset_pc', defaultRole: 'Personal' },
|
||||
{ name: 'asset_server', defaultRole: 'Server' },
|
||||
{ name: 'asset_storage', defaultRole: 'Normal' },
|
||||
{ name: 'asset_equipment', defaultRole: 'Normal' },
|
||||
{ name: 'asset_office_supplies', defaultRole: 'Normal' },
|
||||
{ name: 'asset_survey', defaultRole: 'Normal' },
|
||||
{ name: 'asset_vip', defaultRole: 'Normal' },
|
||||
{ name: 'asset_pc_parts', defaultRole: 'Normal' }
|
||||
];
|
||||
|
||||
let totalMigrated = 0;
|
||||
|
||||
for (const tableInfo of legacyTables) {
|
||||
const table = tableInfo.name;
|
||||
try {
|
||||
const [rows] = await connection.query(`SELECT * FROM ${table}`);
|
||||
console.log(`- Migrating ${rows.length} records from ${table}...`);
|
||||
|
||||
for (const row of rows) {
|
||||
// 2.1 Insert into asset_core
|
||||
const role = (table === 'asset_pc' && row.asset_type === '서버PC') ? 'Server' : tableInfo.defaultRole;
|
||||
|
||||
await connection.query(`
|
||||
INSERT IGNORE INTO asset_core (
|
||||
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, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
row.id, row.asset_code, row.category, row.asset_type, role, row.asset_purpose, row.service_type,
|
||||
row.purchase_corp, row.purchase_date, row.purchase_amount, row.purchase_vendor, row.approval_document,
|
||||
row.memo, row.manager_primary, row.manager_secondary, row.current_dept, row.previous_dept,
|
||||
row.user_current || row.current_user, row.previous_user, row.emp_no, row.user_position, row.created_at
|
||||
]);
|
||||
|
||||
// 2.2 Insert into asset_hardware
|
||||
await connection.query(`
|
||||
INSERT INTO asset_hardware (
|
||||
asset_id, hw_status, model_name, mainboard, os, cpu, ram, gpu, storage1, storage2, storage3, storage4, monitoring, price, volume, monitor_inch, serial_num
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, [
|
||||
row.id, row.hw_status, row.model_name, row.mainboard, row.os, row.cpu, row.ram, row.gpu,
|
||||
row.ssd_1 || row.storage1, row.ssd_2 || row.storage2, row.hdd_1 || row.storage3, row.hdd_2, row.monitoring, row.price,
|
||||
row.volume, row.monitor_inch, row.serial_num
|
||||
]);
|
||||
|
||||
// 2.3 Insert into asset_location
|
||||
if (row.location || row.location_detail) {
|
||||
await connection.query(`
|
||||
INSERT INTO asset_location (
|
||||
asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
`, [
|
||||
row.id, row.location, row.location_detail, row.location_photo, row.loc_x, row.loc_y
|
||||
]);
|
||||
}
|
||||
|
||||
// 2.4 Insert into asset_remote
|
||||
// Primary Network
|
||||
if (row.ip_address || row.mac_address || row.remote_tool) {
|
||||
await connection.query(`
|
||||
INSERT INTO asset_remote (
|
||||
asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw, is_active
|
||||
) VALUES (?, ?, ?, ?, ?, ?, 1)
|
||||
`, [
|
||||
row.id, row.ip_address, row.mac_address, row.remote_tool, row.remote_id, row.remote_pw
|
||||
]);
|
||||
}
|
||||
|
||||
// Secondary Network (for servers)
|
||||
if (row.ip_address_2 || row.remote_tool_2) {
|
||||
await connection.query(`
|
||||
INSERT INTO asset_remote (
|
||||
asset_id, ip_address, remote_tool, remote_id, remote_pw, is_active
|
||||
) VALUES (?, ?, ?, ?, ?, 1)
|
||||
`, [
|
||||
row.id, row.ip_address_2, row.remote_tool_2, row.remote_id_2, row.remote_pw_2
|
||||
]);
|
||||
}
|
||||
|
||||
totalMigrated++;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`- Skipping table ${table}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
|
||||
console.log(`✅ Phase 2 Data Migration Completed. Total Assets Migrated: ${totalMigrated}`);
|
||||
|
||||
} catch (err) {
|
||||
console.error('❌ Migration Failed:', err);
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
migrateV2();
|
||||
73
migrate_v4_network.js
Normal file
73
migrate_v4_network.js
Normal file
@@ -0,0 +1,73 @@
|
||||
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 migrate() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
console.log('1. Creating asset_remote_v4 table...');
|
||||
await conn.query(`
|
||||
CREATE TABLE IF NOT EXISTS asset_remote_v4 (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
asset_id VARCHAR(50) NOT NULL,
|
||||
net_type VARCHAR(20) NOT NULL, /* 'IP' or 'REMOTE' */
|
||||
net_name VARCHAR(100), /* e.g., '기본망', 'AnyDesk' */
|
||||
net_value1 VARCHAR(100), /* IP or ID */
|
||||
net_value2 VARCHAR(100), /* MAC or PW */
|
||||
is_active TINYINT(1) DEFAULT 1,
|
||||
deactivated_at DATETIME NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
|
||||
console.log('2. Migrating data from asset_remote...');
|
||||
const [oldRows] = await conn.query('SELECT * FROM asset_remote WHERE is_active = 1');
|
||||
|
||||
let ipCount = 0;
|
||||
let remoteCount = 0;
|
||||
|
||||
for (const row of oldRows) {
|
||||
// Migrating IP/MAC
|
||||
if (row.ip_address || row.mac_address) {
|
||||
await conn.query(
|
||||
'INSERT INTO asset_remote_v4 (asset_id, net_type, net_name, net_value1, net_value2, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[row.asset_id, 'IP', '기본망', row.ip_address, row.mac_address, row.created_at]
|
||||
);
|
||||
ipCount++;
|
||||
}
|
||||
// Migrating Remote
|
||||
if (row.remote_tool || row.remote_id || row.remote_pw) {
|
||||
await conn.query(
|
||||
'INSERT INTO asset_remote_v4 (asset_id, net_type, net_name, net_value1, net_value2, created_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
[row.asset_id, 'REMOTE', row.remote_tool, row.remote_id, row.remote_pw, row.created_at]
|
||||
);
|
||||
remoteCount++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Migrated ${ipCount} IP records and ${remoteCount} Remote records.`);
|
||||
|
||||
console.log('3. Renaming tables...');
|
||||
await conn.query('DROP TABLE IF EXISTS asset_remote_legacy');
|
||||
await conn.query('RENAME TABLE asset_remote TO asset_remote_legacy, asset_remote_v4 TO asset_remote;');
|
||||
|
||||
console.log('✅ Migration V4 (Remote) Complete.');
|
||||
} catch (e) {
|
||||
console.error('Migration failed:', e);
|
||||
} finally {
|
||||
conn.release();
|
||||
pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
28
migrate_v5_rename_remote.js
Normal file
28
migrate_v5_rename_remote.js
Normal file
@@ -0,0 +1,28 @@
|
||||
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 migrate() {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
console.log('1. Renaming asset_network to asset_remote...');
|
||||
await conn.query('RENAME TABLE asset_network TO asset_remote');
|
||||
console.log('✅ Table renamed successfully.');
|
||||
} catch (e) {
|
||||
console.error('Migration failed:', e);
|
||||
} finally {
|
||||
conn.release();
|
||||
pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
migrate();
|
||||
195
migrate_v6_parts_master.js
Normal file
195
migrate_v6_parts_master.js
Normal file
@@ -0,0 +1,195 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
|
||||
dotenv.config({ override: true });
|
||||
|
||||
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
|
||||
|
||||
// 기존의 감점 계산 로직을 그대로 이용해 등급과 감점점수를 도출하는 헬퍼 함수
|
||||
function parseCpu(cpu) {
|
||||
if (!cpu) return { tier: '기타', deduction: 30 };
|
||||
const cpuUpper = cpu.toUpperCase().trim();
|
||||
if (cpuUpper === '-' || cpuUpper === '') return { tier: '기타', deduction: 30 };
|
||||
|
||||
let tier = '기타';
|
||||
let deduction = 30;
|
||||
|
||||
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
|
||||
tier = 'i9 / Ryzen 9';
|
||||
deduction = 0;
|
||||
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
|
||||
tier = 'i7 / Ryzen 7';
|
||||
deduction = 5;
|
||||
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
|
||||
tier = 'i5 / Ryzen 5';
|
||||
deduction = 15;
|
||||
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
|
||||
tier = 'i3 / Ryzen 3';
|
||||
deduction = 25;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 최종 등급 감점 + 세대 감점 합산
|
||||
return { tier, deduction: deduction + genDeduction };
|
||||
}
|
||||
|
||||
function parseGpu(gpu) {
|
||||
if (!gpu) return { tier: 'C', deduction: 25 };
|
||||
const gpuUpper = gpu.toUpperCase().trim();
|
||||
if (gpuUpper === '-' || gpuUpper === '') return { tier: 'C', deduction: 25 };
|
||||
|
||||
if (
|
||||
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
|
||||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
|
||||
) {
|
||||
return { tier: 'S', deduction: 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')
|
||||
) {
|
||||
return { tier: 'A', deduction: 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')
|
||||
) {
|
||||
return { tier: 'B', deduction: 15 };
|
||||
} else {
|
||||
return { tier: 'C', deduction: 25 };
|
||||
}
|
||||
}
|
||||
|
||||
function parseRam(ram) {
|
||||
if (!ram) return { tier: '부족', deduction: 25 };
|
||||
const ramUpper = ram.toUpperCase().trim();
|
||||
if (ramUpper === '-' || ramUpper === '') return { tier: '부족', deduction: 25 };
|
||||
|
||||
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
|
||||
if (ramMatch && ramMatch[1]) {
|
||||
const ramVal = parseInt(ramMatch[1], 10);
|
||||
if (ramVal >= 32) return { tier: '최적', deduction: 0 };
|
||||
else if (ramVal >= 16) return { tier: '보통', deduction: 10 };
|
||||
else if (ramVal >= 8) return { tier: '주의', deduction: 20 };
|
||||
}
|
||||
return { tier: '부족', deduction: 25 };
|
||||
}
|
||||
|
||||
async function runMigration() {
|
||||
console.log('🔄 DB 커넥션 연결 중...');
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
try {
|
||||
console.log('⚙️ 1. hardware_components_master 테이블 생성...');
|
||||
await connection.query('DROP TABLE IF EXISTS hardware_components_master');
|
||||
await connection.query(`
|
||||
CREATE TABLE hardware_components_master (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category VARCHAR(50) NOT NULL COMMENT 'CPU, GPU, RAM 등',
|
||||
component_name VARCHAR(255) NOT NULL UNIQUE COMMENT '부품 표준 명칭',
|
||||
score_tier VARCHAR(50) COMMENT '성능 등급',
|
||||
deduction INT DEFAULT 0 COMMENT '감점 점수',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
`);
|
||||
console.log('✅ 테이블 생성 완료.');
|
||||
|
||||
console.log('🔍 2. 기존 asset_spec 테이블에서 부품명 조회...');
|
||||
const [specRows] = await connection.query('SELECT DISTINCT cpu, ram, gpu FROM asset_spec');
|
||||
|
||||
const uniqueCpus = new Set();
|
||||
const uniqueGpus = new Set();
|
||||
const uniqueRams = new Set();
|
||||
|
||||
specRows.forEach(row => {
|
||||
if (row.cpu && row.cpu.trim() !== '-' && row.cpu.trim() !== '') uniqueCpus.add(row.cpu.trim());
|
||||
if (row.gpu && row.gpu.trim() !== '-' && row.gpu.trim() !== '') uniqueGpus.add(row.gpu.trim());
|
||||
if (row.ram && row.ram.trim() !== '-' && row.ram.trim() !== '') uniqueRams.add(row.ram.trim());
|
||||
});
|
||||
|
||||
// 만약 데이터가 너무 비어있을 경우를 대비하여 기본 대표 부품 몇 개 추가
|
||||
if (uniqueCpus.size === 0) {
|
||||
['Intel Core i9-13900K', 'Intel Core i7-14700K', 'Intel Core i5-12400', 'AMD Ryzen 7 7800X3D', 'Intel Core i3-10100'].forEach(c => uniqueCpus.add(c));
|
||||
}
|
||||
if (uniqueGpus.size === 0) {
|
||||
['NVIDIA GeForce RTX 4090', 'NVIDIA GeForce RTX 4070', 'NVIDIA GeForce RTX 3060', 'Intel Iris Xe Graphics', 'NVIDIA GeForce GTX 1660 Super'].forEach(g => uniqueGpus.add(g));
|
||||
}
|
||||
if (uniqueRams.size === 0) {
|
||||
['8GB', '16GB', '32GB', '64GB'].forEach(r => uniqueRams.add(r));
|
||||
}
|
||||
|
||||
console.log(` - 추출된 CPU 개수: ${uniqueCpus.size}`);
|
||||
console.log(` - 추출된 GPU 개수: ${uniqueGpus.size}`);
|
||||
console.log(` - 추출된 RAM 개수: ${uniqueRams.size}`);
|
||||
|
||||
console.log('💾 3. 마스터 테이블에 부품 데이터 및 감점 정보 삽입...');
|
||||
|
||||
// CPU 삽입
|
||||
for (const cpu of uniqueCpus) {
|
||||
const { tier, deduction } = parseCpu(cpu);
|
||||
await connection.query(
|
||||
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
|
||||
['CPU', cpu, tier, deduction]
|
||||
);
|
||||
}
|
||||
|
||||
// GPU 삽입
|
||||
for (const gpu of uniqueGpus) {
|
||||
const { tier, deduction } = parseGpu(gpu);
|
||||
await connection.query(
|
||||
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
|
||||
['GPU', gpu, tier, deduction]
|
||||
);
|
||||
}
|
||||
|
||||
// RAM 삽입
|
||||
for (const ram of uniqueRams) {
|
||||
const { tier, deduction } = parseRam(ram);
|
||||
await connection.query(
|
||||
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
|
||||
['RAM', ram, tier, deduction]
|
||||
);
|
||||
}
|
||||
|
||||
console.log('✅ 마이그레이션이 성공적으로 완료되었습니다!');
|
||||
} catch (error) {
|
||||
console.error('❌ 마이그레이션 오류 발생:', error);
|
||||
} finally {
|
||||
await connection.end();
|
||||
}
|
||||
}
|
||||
|
||||
runMigration();
|
||||
36
probe_db.js
Normal file
36
probe_db.js
Normal file
@@ -0,0 +1,36 @@
|
||||
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 probeDB() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('--- Database Probe Start ---');
|
||||
|
||||
const [tables] = await connection.query('SHOW TABLES');
|
||||
const tableNames = tables.map(t => Object.values(t)[0]);
|
||||
|
||||
console.log('Existing Tables:', tableNames);
|
||||
|
||||
for (const table of tableNames) {
|
||||
const [columns] = await connection.query(`DESCRIBE ${table}`);
|
||||
console.log(`\n[Table: ${table}]`);
|
||||
columns.forEach(c => {
|
||||
console.log(` - ${c.Field} (${c.Type}) ${c.Comment ? '// ' + c.Comment : ''}`);
|
||||
});
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
console.log('\n--- Database Probe End ---');
|
||||
}
|
||||
|
||||
probeDB().catch(console.error);
|
||||
429
public/PC_사양_적정성_분석_기획서.html
Normal file
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>
|
||||
163
scratch/calculate_job_averages.js
Normal file
163
scratch/calculate_job_averages.js
Normal file
@@ -0,0 +1,163 @@
|
||||
import * as fs from 'fs';
|
||||
|
||||
// dummyData.ts를 읽어와서 dummyPCs 파싱
|
||||
const content = fs.readFileSync('c:/Project/HM ITAM/src/core/dummyData.ts', 'utf-8');
|
||||
|
||||
// export const dummyPCs: any[] = [ ... ]; 패턴 추출
|
||||
const match = content.match(/export const dummyPCs: any\[\] = (\[[\s\S]*?\]);/);
|
||||
if (!match) {
|
||||
console.error('Failed to parse dummyPCs from dummyData.ts');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const dummyPCs = JSON.parse(match[1]);
|
||||
|
||||
function calculatePcScoreDeductive(cpu, ram, gpu, purchaseDate) {
|
||||
let score = 100;
|
||||
|
||||
// 1. CPU 등급 감점
|
||||
const cpuUpper = (cpu || '').toUpperCase();
|
||||
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 세대 감점
|
||||
let genDeduction = 0;
|
||||
let 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);
|
||||
}
|
||||
|
||||
let 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 용량 감점
|
||||
const ramUpper = (ram || '').toUpperCase();
|
||||
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 성능 감점
|
||||
const gpuUpper = (gpu || '').toUpperCase();
|
||||
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 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. 연식(노후도) 감점
|
||||
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())) {
|
||||
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);
|
||||
}
|
||||
|
||||
const jobScores = {};
|
||||
let totalPcs = 0;
|
||||
|
||||
const filteredPCs = dummyPCs.filter(pc => pc.user_position !== '재고PC');
|
||||
|
||||
filteredPCs.forEach(pc => {
|
||||
const job = pc.user_position || '미분류';
|
||||
const score = calculatePcScoreDeductive(pc.cpu, pc.ram, pc.gpu, pc.purchase_date);
|
||||
|
||||
if (!jobScores[job]) {
|
||||
jobScores[job] = { total: 0, count: 0 };
|
||||
}
|
||||
jobScores[job].total += score;
|
||||
jobScores[job].count += 1;
|
||||
totalPcs++;
|
||||
});
|
||||
|
||||
console.log('--- Job Averages (Deductive 100-point) ---');
|
||||
const sortedJobs = Object.keys(jobScores).map(job => {
|
||||
const avg = jobScores[job].total / jobScores[job].count;
|
||||
return {
|
||||
job,
|
||||
avg: parseFloat(avg.toFixed(1)),
|
||||
count: jobScores[job].count
|
||||
};
|
||||
}).sort((a, b) => b.avg - a.avg);
|
||||
|
||||
sortedJobs.forEach((item, index) => {
|
||||
console.log(`${index + 1}. ${item.job}: Avg=${item.avg}점, Count=${item.count}대`);
|
||||
});
|
||||
|
||||
console.log('Total PCs (excluding Stock):', totalPcs);
|
||||
30
scratch/parse_excel.js
Normal file
30
scratch/parse_excel.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import pkg from 'xlsx';
|
||||
const { readFile, utils } = pkg;
|
||||
|
||||
try {
|
||||
const workbook = readFile('c:/Project/HM ITAM/SampleData_PC.xlsx');
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
const rawRows = utils.sheet_to_json(sheet, { header: 1 });
|
||||
|
||||
const corps = new Set();
|
||||
|
||||
// 첫 번째 행(헤더) 제외하고 C열(인덱스 2) 데이터 추출
|
||||
rawRows.slice(1).forEach(row => {
|
||||
if (row[2] !== undefined && row[2] !== null) {
|
||||
corps.add(String(row[2]).trim());
|
||||
}
|
||||
});
|
||||
|
||||
const jobs = new Map();
|
||||
rawRows.slice(1).forEach(row => {
|
||||
const job = String(row[3] || '').trim();
|
||||
jobs.set(job, (jobs.get(job) || 0) + 1);
|
||||
});
|
||||
|
||||
console.log('--- Unique Jobs in D column ---');
|
||||
Array.from(jobs.entries()).forEach(([key, val]) => {
|
||||
console.log(`${key}: ${val}대`);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
27
scratch/parse_svr_excel.js
Normal file
27
scratch/parse_svr_excel.js
Normal file
@@ -0,0 +1,27 @@
|
||||
import pkg from 'xlsx';
|
||||
const { readFile, utils } = pkg;
|
||||
|
||||
try {
|
||||
const workbook = readFile('c:/Project/HM ITAM/SampleData_SVR.xlsx');
|
||||
|
||||
for (const sheetName of workbook.SheetNames) {
|
||||
console.log(`\n================= Sheet: ${sheetName} =================`);
|
||||
const sheet = workbook.Sheets[sheetName];
|
||||
const rawRows = utils.sheet_to_json(sheet, { header: 1 });
|
||||
const validRows = rawRows.filter(row => {
|
||||
return row.some(val => val !== undefined && val !== null && String(val).trim() !== '');
|
||||
});
|
||||
|
||||
const header = validRows[0];
|
||||
const assetNameIdx = header.indexOf('자산명');
|
||||
const typeIdx = header.indexOf('유형');
|
||||
const detailIdx = header.indexOf('상세');
|
||||
const teamIdx = header.indexOf('팀명');
|
||||
|
||||
validRows.slice(1).forEach((row, idx) => {
|
||||
console.log(`[${idx + 1}] 팀명: ${row[teamIdx]} | 자산명: ${row[assetNameIdx]} | 유형: ${row[typeIdx]} | 상세: ${row[detailIdx]}`);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
447
scratch/update_dummy_pcs.js
Normal file
447
scratch/update_dummy_pcs.js
Normal file
@@ -0,0 +1,447 @@
|
||||
import pkg from 'xlsx';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const { readFile, utils } = pkg;
|
||||
|
||||
// 임시 ID 생성 및 도우미 함수
|
||||
const randomId = () => Math.random().toString(36).substring(2, 9);
|
||||
const CORPS = ['한맥', '삼안', '장헌', '장헌산업', 'PTC', '바론', '한라'];
|
||||
|
||||
function cleanValue(val) {
|
||||
if (val === undefined || val === null) return '-';
|
||||
const str = String(val).trim();
|
||||
return str === '' ? '-' : str;
|
||||
}
|
||||
|
||||
try {
|
||||
const workbook = readFile('c:/Project/HM ITAM/SampleData_PC.xlsx');
|
||||
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
||||
|
||||
// header: 1로 읽어 2차원 배열을 획득
|
||||
const rawRows = utils.sheet_to_json(sheet, { header: 1 });
|
||||
|
||||
// 첫 번째 행은 헤더이므로 제외
|
||||
const dataRows = rawRows.slice(1);
|
||||
|
||||
const parsedPCs = [];
|
||||
let pcIndex = 0;
|
||||
let designKihuckCount = 0;
|
||||
|
||||
for (const row of dataRows) {
|
||||
// 빈 행 건너뛰기 (성명, 부서, 팀명 모두 비어있으면 데이터가 없는 행으로 판단)
|
||||
if (!row[0] && !row[1] && !row[2] && !row[3] && !row[4]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const deptRaw = cleanValue(row[0]);
|
||||
const teamRaw = cleanValue(row[1]);
|
||||
const corpRaw = cleanValue(row[2]); // C열: 소속 (NEW)
|
||||
const jobRaw = cleanValue(row[3]); // D열: 직무 (밀림)
|
||||
const nameRaw = cleanValue(row[4]); // E열: 성명 (밀림)
|
||||
|
||||
// 특정 사용자 제외 필터
|
||||
if (nameRaw === '한치영' || nameRaw === '공용') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const posRaw = cleanValue(row[5]); // F열: 직급 (밀림)
|
||||
const mainboardRaw = cleanValue(row[6]); // G열: 메인보드 (밀림)
|
||||
const cpuRaw = cleanValue(row[7]); // H열: CPU (밀림)
|
||||
const cpuYearRaw = row[8]; // I열: CPU 출시연도 (밀림)
|
||||
const gpuRaw = cleanValue(row[9]); // J열: GPU (밀림)
|
||||
const gpuYearRaw = row[10]; // K열: GPU 출시연도 (밀림)
|
||||
const ramRaw = cleanValue(row[11]); // L열: RAM (밀림)
|
||||
const ssd1Raw = cleanValue(row[12]);// M열: SDD1 (밀림)
|
||||
const ssd2Raw = cleanValue(row[13]);// N열: SDD2 (밀림)
|
||||
const hdd1Raw = cleanValue(row[14]);// O열: HDD1 (밀림)
|
||||
const hdd2Raw = cleanValue(row[15]);// P열: HDD2 (밀림)
|
||||
const hdd3Raw = cleanValue(row[16]);// Q열: HDD3 (밀림)
|
||||
const hdd4Raw = cleanValue(row[17]);// R열: HDD4 (밀림)
|
||||
|
||||
// W열(22번째 인덱스) -> 구매일자
|
||||
const dateRaw = cleanValue(row[22]);
|
||||
// X열(23번째 인덱스) -> 비고
|
||||
const memoRaw = cleanValue(row[23]);
|
||||
|
||||
// 1. 법인 매핑 (엑셀 C열의 실제 소속 우선 사용, 없을 시 순환 지정)
|
||||
const purchase_corp = corpRaw !== '-' ? corpRaw : CORPS[pcIndex % CORPS.length];
|
||||
|
||||
// 2. 재고PC 판단 및 상태 설정
|
||||
const isStock = teamRaw === '재고PC';
|
||||
const hw_status = isStock ? '창고보관' : '운영중';
|
||||
|
||||
// 3. 성명 정제
|
||||
let user_current = nameRaw;
|
||||
if (isStock) {
|
||||
// 재고PC인 경우 직무 컬럼(row[3])에 성명이 들어가 있음
|
||||
user_current = jobRaw !== '-' ? jobRaw : '재고장비';
|
||||
}
|
||||
|
||||
// 4. 직무 정제
|
||||
let user_position = jobRaw;
|
||||
if (isStock) {
|
||||
user_position = '재고PC';
|
||||
} else if (user_position === '-' || user_position === 'undefined' || !user_position || ['안용주', '김민수', '심영표', '이수창A', '조병철', '윤진호', '김대영', '박정웅', '김유식'].includes(user_position)) {
|
||||
// 직무가 유효하지 않거나 이름인 경우 정제
|
||||
if (nameRaw === '장종찬' || posRaw === '사장') {
|
||||
user_position = '기획자';
|
||||
} else if (nameRaw === '노트북' || nameRaw === '공용') {
|
||||
user_position = '기획자';
|
||||
} else {
|
||||
// 팀명/부서 기준 매핑
|
||||
const combined = (deptRaw + ' ' + teamRaw).toUpperCase();
|
||||
if (combined.includes('개발') || combined.includes('SOLUTION') || combined.includes('WEB') || combined.includes('ERP')) {
|
||||
user_position = '개발자';
|
||||
} else if (combined.includes('BIM') || combined.includes('구조') || combined.includes('설계') || combined.includes('터널') || combined.includes('상하수도') || combined.includes('수자원') || combined.includes('건설') || combined.includes('CM')) {
|
||||
user_position = '엔지니어';
|
||||
} else if (combined.includes('디자인') || combined.includes('GRAPHICS')) {
|
||||
user_position = '디자이너';
|
||||
} else {
|
||||
user_position = '기획자';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 만약 직무가 'BIM모델러' 인 경우, 그대로 유지
|
||||
if (jobRaw === 'BIM모델러') {
|
||||
user_position = 'BIM모델러';
|
||||
}
|
||||
|
||||
// 개발자/디자이너 세부 직무 분리 로직 적용
|
||||
if (user_position === '개발자') {
|
||||
const nameUpper = nameRaw.trim();
|
||||
const teamUpper = teamRaw.toUpperCase();
|
||||
|
||||
if (nameUpper === '조찬영' || nameUpper === '김용연') {
|
||||
user_position = 'AI 개발자';
|
||||
} else if (
|
||||
teamUpper.includes('그래픽스') ||
|
||||
teamUpper.includes('MODELER') ||
|
||||
teamUpper.includes('HMEG') ||
|
||||
teamUpper.includes('EG-BIM') ||
|
||||
teamUpper.includes('GSIM') ||
|
||||
teamUpper.includes('STRANA')
|
||||
) {
|
||||
user_position = '3D 개발자';
|
||||
} else if (
|
||||
teamUpper.includes('WEB') ||
|
||||
teamUpper.includes('솔루션개발') ||
|
||||
teamUpper.includes('ERP') ||
|
||||
teamUpper.includes('전산')
|
||||
) {
|
||||
user_position = '웹 개발자';
|
||||
} else {
|
||||
user_position = '프로그램 개발자';
|
||||
}
|
||||
} else if (user_position === '디자이너') {
|
||||
const teamUpper = teamRaw.toUpperCase();
|
||||
if (teamUpper.includes('디자인셀')) {
|
||||
user_position = 'UXUI 디자이너';
|
||||
} else if (teamUpper.includes('디자인기획')) {
|
||||
// 디자인기획팀 소속 중 약 40%는 3D 디자이너, 60%는 편집 디자이너
|
||||
if (designKihuckCount % 10 < 4) {
|
||||
user_position = '3D 디자이너';
|
||||
} else {
|
||||
user_position = '편집 디자이너';
|
||||
}
|
||||
designKihuckCount++;
|
||||
} else {
|
||||
user_position = '편집 디자이너';
|
||||
}
|
||||
}
|
||||
|
||||
// 5. 구매일자 포맷 가공 (YYYY-MM)
|
||||
let purchase_date = '2022-01'; // 기본값
|
||||
if (dateRaw !== '-') {
|
||||
if (dateRaw.length === 6 && !isNaN(dateRaw)) {
|
||||
purchase_date = `${dateRaw.substring(0, 4)}-${dateRaw.substring(4, 6)}`;
|
||||
} else if (dateRaw.length === 4 && !isNaN(dateRaw)) {
|
||||
purchase_date = `${dateRaw}-01`;
|
||||
} else {
|
||||
purchase_date = dateRaw;
|
||||
}
|
||||
} else if (cpuYearRaw && !isNaN(cpuYearRaw)) {
|
||||
purchase_date = `${cpuYearRaw}-01`;
|
||||
}
|
||||
|
||||
// 6. 도입 금액(purchase_amount) 책정
|
||||
let purchase_amount = '1500000';
|
||||
const cpuUpper = cpuRaw.toUpperCase();
|
||||
const gpuUpper = gpuRaw.toUpperCase();
|
||||
|
||||
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9') || gpuUpper.includes('4080') || gpuUpper.includes('4090')) {
|
||||
purchase_amount = '3500000';
|
||||
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7') || gpuUpper.includes('3070') || gpuUpper.includes('4070') || gpuUpper.includes('A2000')) {
|
||||
purchase_amount = '2200000';
|
||||
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5') || gpuUpper.includes('3060') || gpuUpper.includes('2060')) {
|
||||
purchase_amount = '1500000';
|
||||
} else if (cpuYearRaw && parseInt(cpuYearRaw) < 2020) {
|
||||
purchase_amount = '800000';
|
||||
} else {
|
||||
purchase_amount = '950000';
|
||||
}
|
||||
|
||||
// 7. MAC 주소 생성 (16진수 포맷)
|
||||
const mac_address = `00:1A:2B:3C:4D:${pcIndex.toString(16).toUpperCase().padStart(2, '0')}`;
|
||||
|
||||
parsedPCs.push({
|
||||
id: randomId(),
|
||||
asset_type: '개인PC',
|
||||
purchase_corp,
|
||||
asset_code: 'PC-24' + String(pcIndex).padStart(3, '0'),
|
||||
purchase_date,
|
||||
user_current,
|
||||
user_position,
|
||||
current_dept: teamRaw !== '-' ? teamRaw : deptRaw,
|
||||
previous_dept: pcIndex % 8 === 0 ? '기획팀' : '-',
|
||||
location: '서울본사 7층',
|
||||
manager_primary: '김IT',
|
||||
manager_secondary: '이IT',
|
||||
model_name: mainboardRaw !== '-' ? mainboardRaw : '사내 표준 데스크톱',
|
||||
os: 'Windows 11 Pro',
|
||||
cpu: cpuRaw,
|
||||
gpu: gpuRaw,
|
||||
ram: ramRaw,
|
||||
ssd_1: ssd1Raw,
|
||||
ssd_2: ssd2Raw,
|
||||
ssd_3: '-',
|
||||
hdd_1: hdd1Raw,
|
||||
hdd_2: hdd2Raw,
|
||||
hdd_3: hdd3Raw,
|
||||
hdd_4: hdd4Raw,
|
||||
mainboard: mainboardRaw,
|
||||
ip_address: '192.168.0.' + (10 + (pcIndex % 240)),
|
||||
purchase_amount,
|
||||
purchase_vendor: 'LG전자/삼성전자/HP',
|
||||
approval_document: '2024_상반기_PC구매_' + pcIndex,
|
||||
memo: memoRaw !== '-' ? memoRaw : (isStock ? '재고 보유 분' : '임직원 지급용'),
|
||||
asset_name: `개인PC ${pcIndex + 1}`,
|
||||
mac_address,
|
||||
hw_status
|
||||
});
|
||||
|
||||
pcIndex++;
|
||||
}
|
||||
|
||||
console.log(`Successfully parsed ${parsedPCs.length} PCs from excel file.`);
|
||||
|
||||
// dummyData.ts 의 나머지 데이터(dummyServers 등)를 포함하여 전체 파일을 새로 씁니다.
|
||||
const newDummyDataFileContent = `import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog } from './excelHandler';
|
||||
|
||||
// 유틸리티: 랜덤 문자열
|
||||
const randomId = () => Math.random().toString(36).substring(2, 9);
|
||||
|
||||
// 유틸리티: 랜덤 년월 (YYYY-MM) (최근 10년)
|
||||
const randomPurchaseYM = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const year = currentYear - Math.floor(Math.random() * 10);
|
||||
const month = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
|
||||
return \`\${year}-\${month}\`;
|
||||
};
|
||||
|
||||
// 유틸리티: 랜덤 YYYY-MM-DD
|
||||
const randomDateStr = (maxYearsAgo = 10) => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const year = currentYear - Math.floor(Math.random() * maxYearsAgo);
|
||||
const month = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
|
||||
const day = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0');
|
||||
return \`\${year}-\${month}-\${day}\`;
|
||||
};
|
||||
|
||||
const CORPS = ['한맥', '삼안', '장헌', '장헌산업', 'PTC', '바론', '한라'];
|
||||
const getRandomCorp = () => CORPS[Math.floor(Math.random() * CORPS.length)];
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 1. SampleData_PC.xlsx 에서 파싱된 PC 데이터 주입
|
||||
// ────────────────────────────────────────────────────────
|
||||
export const dummyPCs: any[] = ${JSON.stringify(parsedPCs, null, 2)};
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 2. 기타 자산 더미 데이터 (서버, 스토리지, 소프트웨어 등)
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
export const dummyServers: any[] = Array.from({ length: 15 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
asset_type: '서버',
|
||||
type2: i % 2 === 0 ? '물리' : '가상',
|
||||
purchase_corp: getRandomCorp(),
|
||||
asset_code: \`SRV-24\${String(i).padStart(3, '0')}\`,
|
||||
purchase_date: randomPurchaseYM(),
|
||||
asset_purpose: i % 2 === 0 ? '운영 웹 서버' : '사내망 DB 서버',
|
||||
current_dept: '인프라팀',
|
||||
previous_dept: '-',
|
||||
location: 'IDC 센터 1-A',
|
||||
manager_primary: '박서버',
|
||||
manager_secondary: '최백업',
|
||||
ip_address: \`10.0.0.\${10 + i}\`,
|
||||
ip_address_2: \`192.168.100.\${10 + i}\`,
|
||||
remote_tool: 'RDP / SSH',
|
||||
remote_id: \`admin_\${i}\`,
|
||||
remote_pw: '********',
|
||||
model_name: 'Dell PowerEdge R750',
|
||||
os: 'Ubuntu 22.04 LTS',
|
||||
cpu: 'Intel Xeon Gold 6330',
|
||||
ram: '128GB',
|
||||
gpu: i % 3 === 0 ? 'NVIDIA A100' : '-',
|
||||
ssd_1: '1TB NVMe',
|
||||
ssd_2: '1TB NVMe',
|
||||
hdd_1: '4TB HDD',
|
||||
monitoring: 'Zabbix Agent',
|
||||
purchase_amount: '8500000',
|
||||
purchase_vendor: '델테크놀로지스',
|
||||
approval_document: \`2024_IDC_확장품의_\sign\${i}\`,
|
||||
memo: '서버 랙 3번 위치',
|
||||
asset_name: \`운영 서버 \${i+1}\`,
|
||||
mac_address: \`00:1A:2B:3C:4E:\${String(i).padStart(2, '0')}\`,
|
||||
hw_status: '운영중'
|
||||
}));
|
||||
|
||||
export const dummyStorages: any[] = Array.from({ length: 8 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
asset_type: '스토리지',
|
||||
purchase_corp: getRandomCorp(),
|
||||
asset_code: \`STR-24\${String(i).padStart(3, '0')}\`,
|
||||
asset_name: \`공용 스토리지 \${i+1}\`,
|
||||
location: 'IDC 센터 1-A',
|
||||
model_name: 'Synology RS4021xs+',
|
||||
volume: '100TB',
|
||||
manager_primary: '박서버',
|
||||
manager_secondary: '최백업',
|
||||
ip_address: \`10.0.0.\${50 + i}\`,
|
||||
mac_address: \`00:1A:2B:3C:4F:\${String(i).padStart(2, '0')}\`,
|
||||
purchase_date: randomPurchaseYM(),
|
||||
purchase_amount: '12000000',
|
||||
purchase_vendor: '시놀로지코리아',
|
||||
approval_document: \`2024_스토리지구매_\${i}\`,
|
||||
memo: '부서별 백업본 저장용',
|
||||
os: 'Synology DSM',
|
||||
asset_purpose: '데이터 백업',
|
||||
hw_status: '운영중'
|
||||
}));
|
||||
|
||||
export const dummyEquips: any[] = Array.from({ length: 12 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
asset_type: '전산비품',
|
||||
purchase_corp: getRandomCorp(),
|
||||
asset_code: \`EQ-24\${String(i).padStart(3, '0')}\`,
|
||||
asset_name: \`네트워크 스위치 \${i+1}\`,
|
||||
location: '전산실 랙 1',
|
||||
manager_primary: '네트워크담당자',
|
||||
ip_address: \`192.168.10.\${200 + i}\`,
|
||||
mac_address: \`00:1A:2B:3C:51:\${String(i).padStart(2, '0')}\`,
|
||||
os: 'Cisco IOS',
|
||||
purchase_date: randomPurchaseYM(),
|
||||
purchase_amount: '150000',
|
||||
purchase_vendor: '다나와',
|
||||
approval_document: \`2024_비품구매_\${i}\`,
|
||||
memo: '사내망 확장용',
|
||||
asset_purpose: '네트워크 분배'
|
||||
}));
|
||||
|
||||
export const dummyMobiles: any[] = Array.from({ length: 15 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
asset_type: '모바일기기',
|
||||
purchase_corp: getRandomCorp(),
|
||||
asset_code: \`MOB-24\${String(i).padStart(3, '0')}\`,
|
||||
asset_name: \`테스트용 단말기 \${i+1}\`,
|
||||
location: '개발2팀',
|
||||
manager_primary: '테스터',
|
||||
os: i % 2 === 0 ? 'Android 14' : 'iOS 17',
|
||||
purchase_date: randomPurchaseYM(),
|
||||
purchase_amount: '900000',
|
||||
purchase_vendor: '삼성전자/애플',
|
||||
approval_document: \`2024_모바일구매_\${i}\`,
|
||||
memo: '앱 호환성 테스트 전용',
|
||||
asset_purpose: 'QA 테스트',
|
||||
ip_address: \`192.168.1.\${10 + i}\`,
|
||||
mac_address: \`00:1A:2B:3C:50:\${String(i).padStart(2, '0')}\`
|
||||
}));
|
||||
|
||||
export const dummySubSw: any[] = Array.from({ length: 10 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
sw_type: '구독SW',
|
||||
sw_field: '업무용/협업',
|
||||
purchase_corp: getRandomCorp(),
|
||||
current_dept: '전사',
|
||||
product_name: \`Microsoft 365 E\${3 + (i%2)}\`,
|
||||
purchase_date: randomDateStr(3),
|
||||
start_date: randomDateStr(1),
|
||||
expired_date: randomDateStr(0),
|
||||
purchase_amount: '150000',
|
||||
asset_count: 50 + i * 5,
|
||||
email_account: \`admin\${i}@hmcorp.com\`,
|
||||
purchase_vendor: '소프트웨어인라이프',
|
||||
memo: '연간 계약 갱신 필요'
|
||||
}));
|
||||
|
||||
export const dummyPermSw: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
sw_type: '영구SW',
|
||||
sw_field: '디자인/설계',
|
||||
purchase_corp: getRandomCorp(),
|
||||
current_dept: '디자인팀',
|
||||
product_name: \`AutoCAD 202\${i%4}\`,
|
||||
purchase_date: randomDateStr(5),
|
||||
start_date: randomDateStr(5),
|
||||
expired_date: '2099-12-31',
|
||||
purchase_amount: '3000000',
|
||||
asset_count: 2,
|
||||
email_account: \`design\${i}@hmcorp.com\`,
|
||||
purchase_vendor: '오토데스크 파트너',
|
||||
memo: 'USB 동글키 보관중'
|
||||
}));
|
||||
|
||||
export const dummyCloud: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
sw_type: '클라우드',
|
||||
asset_mfr: i % 2 === 0 ? 'AWS' : 'GCP',
|
||||
purchase_corp: getRandomCorp(),
|
||||
current_dept: '개발팀',
|
||||
product_name: \`컴퓨팅 인스턴스 Type \${i}\`,
|
||||
email_account: \`awsadmin\${i}@hmcorp.com\`,
|
||||
purchase_method: '법인카드(신한 1234)',
|
||||
purchase_amount: \`\${500000 + i * 100000}\`,
|
||||
asset_count: 1,
|
||||
purchase_vendor: 'AWS/GCP',
|
||||
memo: '환율 변동에 따라 매월 상이함'
|
||||
}));
|
||||
|
||||
export const dummyDomain: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
asset_type: '도메인',
|
||||
purchase_corp: getRandomCorp(),
|
||||
product_name: \`사내 운영 서비스 \${i+1}\`,
|
||||
domain_address: \`service\${i+1}.hmcorp.com\`,
|
||||
start_date: randomDateStr(4),
|
||||
expired_date: randomDateStr(0),
|
||||
purchase_amount: '22000',
|
||||
manager_primary: '인프라팀장',
|
||||
manager_secondary: '인프라담당자',
|
||||
memo: '가비아 자동갱신 설정 완료'
|
||||
}));
|
||||
|
||||
export const dummySwUsers: any[] = Array.from({ length: 15 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
sw_id: dummySubSw[0]?.id || randomId(),
|
||||
purchase_corp: getRandomCorp(),
|
||||
current_dept: '경영지원팀',
|
||||
user_current: \`홍길동\${i}\`,
|
||||
memo: \`SW신청서_2400\${i}\`
|
||||
}));
|
||||
|
||||
export const dummyLogs: any[] = Array.from({ length: 10 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
assetId: dummyPCs[0]?.id || randomId(),
|
||||
date: randomDateStr(1),
|
||||
details: i % 2 === 0 ? '메모리 추가 증설 (16GB -> 32GB)' : '디스플레이 파손 수리',
|
||||
user: 'IT지원팀',
|
||||
cost: i % 2 === 0 ? 80000 : 150000,
|
||||
}));
|
||||
`;
|
||||
|
||||
fs.writeFileSync('c:/Project/HM ITAM/src/core/dummyData.ts', newDummyDataFileContent, 'utf-8');
|
||||
console.log('✅ dummyData.ts file updated successfully.');
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to update dummy data:', e);
|
||||
}
|
||||
442
scratch/update_dummy_servers.js
Normal file
442
scratch/update_dummy_servers.js
Normal file
@@ -0,0 +1,442 @@
|
||||
import pkg from 'xlsx';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
const { readFile, utils } = pkg;
|
||||
|
||||
const randomId = () => Math.random().toString(36).substring(2, 9);
|
||||
const CORPS = ['한맥', '삼안', '장헌', '장헌산업', 'PTC', '바론', '한라'];
|
||||
|
||||
function cleanValue(val) {
|
||||
if (val === undefined || val === null) return '-';
|
||||
const str = String(val).trim();
|
||||
return str === '' ? '-' : str;
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. 기존 dummyPCs 로딩
|
||||
const dummyDataPath = 'c:/Project/HM ITAM/src/core/dummyData.ts';
|
||||
const content = fs.readFileSync(dummyDataPath, 'utf-8');
|
||||
const matchPCs = content.match(/export const dummyPCs: any\[\] = (\[[\s\S]*?\]);/);
|
||||
if (!matchPCs) {
|
||||
console.error('Failed to parse dummyPCs from dummyData.ts');
|
||||
process.exit(1);
|
||||
}
|
||||
const dummyPCs = JSON.parse(matchPCs[1]);
|
||||
console.log(`Loaded ${dummyPCs.length} existing PCs from dummyData.ts`);
|
||||
|
||||
// 2. SampleData_SVR.xlsx 파싱
|
||||
const workbook = readFile('c:/Project/HM ITAM/SampleData_SVR.xlsx');
|
||||
|
||||
const parsedServers = [];
|
||||
const parsedStorages = [];
|
||||
const parsedEquips = [];
|
||||
|
||||
let serverIndex = 0;
|
||||
let storageIndex = 0;
|
||||
let equipIndex = 0;
|
||||
|
||||
// ----------------- 시트 1: 합본데이터(공용PC) -----------------
|
||||
const sheetPC = workbook.Sheets['합본데이터(공용PC)'];
|
||||
const rawPC = utils.sheet_to_json(sheetPC, { header: 1 });
|
||||
const rowsPC = rawPC.slice(1).filter(row => row.some(val => val !== undefined && val !== null && String(val).trim() !== ''));
|
||||
|
||||
for (const row of rowsPC) {
|
||||
const teamRaw = cleanValue(row[0]);
|
||||
const svrNoRaw = cleanValue(row[1]);
|
||||
const assetNameRaw = cleanValue(row[2]);
|
||||
const typeRaw = cleanValue(row[3]);
|
||||
const detailRaw = cleanValue(row[4]);
|
||||
const locRaw = cleanValue(row[5]);
|
||||
const mgr1Raw = cleanValue(row[6]);
|
||||
const mgr2Raw = cleanValue(row[7]);
|
||||
const osRaw = cleanValue(row[8]);
|
||||
const osVerRaw = cleanValue(row[9]);
|
||||
const osBuildRaw = cleanValue(row[10]);
|
||||
const modelRaw = cleanValue(row[11]);
|
||||
const mainboardRaw = cleanValue(row[12]);
|
||||
const cpuRaw = cleanValue(row[13]);
|
||||
const ramRaw = cleanValue(row[14]);
|
||||
const gpuRaw = cleanValue(row[15]);
|
||||
const ssd1Raw = cleanValue(row[16]);
|
||||
const ssd2Raw = cleanValue(row[17]);
|
||||
const hdd1Raw = cleanValue(row[18]);
|
||||
const hdd2Raw = cleanValue(row[19]);
|
||||
const hdd3Raw = cleanValue(row[20]);
|
||||
const hdd4Raw = cleanValue(row[21]);
|
||||
|
||||
const ipAddress = '172.16.10.' + (50 + (serverIndex % 150));
|
||||
const randomCorp = CORPS[serverIndex % CORPS.length];
|
||||
|
||||
// 서비스 분류 판단
|
||||
let service_type = '내부서비스';
|
||||
const detailUpper = detailRaw.toUpperCase();
|
||||
const assetUpper = assetNameRaw.toUpperCase();
|
||||
const teamUpper = teamRaw.toUpperCase();
|
||||
|
||||
if (teamUpper.includes('회의실') || assetUpper.includes('회의실') || assetUpper.includes('사이니지')) {
|
||||
service_type = '회의용/공용';
|
||||
} else if (
|
||||
detailUpper.includes('SAAS') || detailUpper.includes('웹서비스') ||
|
||||
detailUpper.includes('운영') || detailUpper.includes('WAS') ||
|
||||
detailUpper.includes('MYSTATION') || detailUpper.includes('CLOUD') ||
|
||||
detailUpper.includes('홈페이지') || detailUpper.includes('WEB') ||
|
||||
detailUpper.includes('외주') || assetUpper.includes('CLOUD') ||
|
||||
assetUpper.includes('웹서비스') || assetUpper.includes('운영')
|
||||
) {
|
||||
service_type = '외부서비스';
|
||||
}
|
||||
|
||||
// 방치 의심 판단
|
||||
const is_inactive = (
|
||||
detailUpper.includes('원격 및 로컬접근 불가') ||
|
||||
detailUpper.includes('철수예정') ||
|
||||
detailUpper.includes('미사용') ||
|
||||
detailUpper.includes('구형 OS')
|
||||
);
|
||||
|
||||
// 실시간 리소스 및 네트워크 가상 데이터 생성
|
||||
let cpu_usage = 0;
|
||||
let ram_usage = 0;
|
||||
let network_traffic = '0 GB';
|
||||
|
||||
if (is_inactive) {
|
||||
cpu_usage = 0;
|
||||
ram_usage = 0;
|
||||
network_traffic = '0 GB (N/A)';
|
||||
} else if (service_type === '회의용/공용') {
|
||||
cpu_usage = Math.floor(Math.random() * 10) + 2; // 2%~12%
|
||||
ram_usage = Math.floor(Math.random() * 15) + 5; // 5%~20%
|
||||
network_traffic = (Math.random() * 1.5 + 0.1).toFixed(1) + ' GB';
|
||||
} else if (service_type === '외부서비스') {
|
||||
// 일부 저사양 운영/SaaS 서버는 병목 현상을 시뮬레이션하기 위해 과부하 부여
|
||||
const isUnderSpec = !gpuRaw.toUpperCase().includes('RTX 30') && !gpuRaw.toUpperCase().includes('RTX 40') && (cpuRaw.toUpperCase().includes('I5') || ramRaw.toUpperCase().includes('16GB') || cpuRaw === '-');
|
||||
if (isUnderSpec) {
|
||||
cpu_usage = Math.floor(Math.random() * 15) + 81; // 81%~95% (과부하)
|
||||
ram_usage = Math.floor(Math.random() * 10) + 86; // 86%~95%
|
||||
} else {
|
||||
cpu_usage = Math.floor(Math.random() * 30) + 40; // 40%~70%
|
||||
ram_usage = Math.floor(Math.random() * 20) + 60; // 60%~80%
|
||||
}
|
||||
network_traffic = (Math.random() * 1500 + 300).toFixed(0) + ' GB';
|
||||
} else { // 내부서비스
|
||||
// Abaqus 해석용이나 Pix4D 등 고부하 내부 인프라도 부하율 높게 부여
|
||||
const isHighLoad = detailUpper.includes('ABAQUS') || detailUpper.includes('PIX4D') || detailUpper.includes('영상 렌더링') || detailUpper.includes('TERRA');
|
||||
if (isHighLoad) {
|
||||
cpu_usage = Math.floor(Math.random() * 20) + 70; // 70%~90%
|
||||
ram_usage = Math.floor(Math.random() * 20) + 75; // 75%~95%
|
||||
} else {
|
||||
cpu_usage = Math.floor(Math.random() * 35) + 15; // 15%~50%
|
||||
ram_usage = Math.floor(Math.random() * 30) + 20; // 20%~50%
|
||||
}
|
||||
network_traffic = (Math.random() * 300 + 10).toFixed(0) + ' GB';
|
||||
}
|
||||
|
||||
const assetItem = {
|
||||
id: randomId(),
|
||||
asset_type: typeRaw !== '-' ? typeRaw : '공용PC',
|
||||
purchase_corp: randomCorp,
|
||||
asset_code: 'SVR-24' + String(serverIndex).padStart(3, '0'),
|
||||
purchase_date: '2023-03',
|
||||
asset_purpose: detailRaw,
|
||||
current_dept: teamRaw,
|
||||
previous_dept: '-',
|
||||
location: locRaw,
|
||||
manager_primary: mgr1Raw,
|
||||
manager_secondary: mgr2Raw,
|
||||
ip_address: ipAddress,
|
||||
remote_tool: 'RDP / VNC',
|
||||
model_name: modelRaw !== '-' ? modelRaw : (mainboardRaw !== '-' ? mainboardRaw : '사내 표준 공용PC'),
|
||||
os: osRaw !== '-' ? `${osRaw} (${osVerRaw})` : 'Windows 10',
|
||||
cpu: cpuRaw,
|
||||
ram: ramRaw,
|
||||
gpu: gpuRaw,
|
||||
ssd_1: ssd1Raw,
|
||||
ssd_2: ssd2Raw,
|
||||
hdd_1: hdd1Raw,
|
||||
hdd_2: hdd2Raw,
|
||||
hdd_3: hdd3Raw,
|
||||
hdd_4: hdd4Raw,
|
||||
monitoring: service_type === '외부서비스' ? '대상' : '비대상',
|
||||
purchase_amount: gpuRaw.toUpperCase().includes('RTX 4080') || gpuRaw.toUpperCase().includes('RTX 3090') ? '3500000' : '1500000',
|
||||
purchase_vendor: '다나와',
|
||||
approval_document: '2023_공용PC_도입_' + serverIndex,
|
||||
memo: is_inactive ? '방치 의심 장비 (회수 필요)' : '정상 운영 장비',
|
||||
asset_name: assetNameRaw,
|
||||
mac_address: `00:1A:2B:3C:5E:${serverIndex.toString(16).toUpperCase().padStart(2, '0')}`,
|
||||
hw_status: is_inactive ? '수리/대기' : '운영중',
|
||||
service_type: service_type,
|
||||
is_inactive: is_inactive,
|
||||
cpu_usage: cpu_usage,
|
||||
ram_usage: ram_usage,
|
||||
network_traffic: network_traffic
|
||||
};
|
||||
|
||||
// 스토리지로 보낼 자산들 (유형이 NAS/DAS이거나 자산명에 NAS가 들어가면)
|
||||
if (typeRaw.toUpperCase().includes('NAS') || typeRaw.toUpperCase().includes('DAS') || assetUpper.includes('NAS') || assetUpper.includes('DAS')) {
|
||||
assetItem.asset_code = 'STO-24' + String(storageIndex).padStart(3, '0');
|
||||
assetItem.volume = hdd1Raw !== '-' ? hdd1Raw : '10TB';
|
||||
parsedStorages.push(assetItem);
|
||||
storageIndex++;
|
||||
} else {
|
||||
parsedServers.push(assetItem);
|
||||
serverIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------- 시트 2: 합본데이터(NAS) -----------------
|
||||
const sheetNAS = workbook.Sheets['합본데이터(NAS)'];
|
||||
const rawNAS = utils.sheet_to_json(sheetNAS, { header: 1 });
|
||||
const rowsNAS = rawNAS.slice(1).filter(row => row.some(val => val !== undefined && val !== null && String(val).trim() !== ''));
|
||||
|
||||
for (const row of rowsNAS) {
|
||||
const teamRaw = cleanValue(row[0]);
|
||||
const svrNoRaw = cleanValue(row[1]);
|
||||
const assetNameRaw = cleanValue(row[2]);
|
||||
const typeRaw = cleanValue(row[3]);
|
||||
const detailRaw = cleanValue(row[4]);
|
||||
const locRaw = cleanValue(row[5]);
|
||||
const mgr1Raw = cleanValue(row[6]);
|
||||
const mgr2Raw = cleanValue(row[7]);
|
||||
const toolRaw = cleanValue(row[8]);
|
||||
const ipRaw = cleanValue(row[9]);
|
||||
const ip2Raw = cleanValue(row[10]);
|
||||
const idRaw = cleanValue(row[11]);
|
||||
const pwRaw = cleanValue(row[12]);
|
||||
const osRaw = cleanValue(row[15]);
|
||||
const osVerRaw = cleanValue(row[16]);
|
||||
const osBuildRaw = cleanValue(row[17]);
|
||||
const modelRaw = cleanValue(row[18]);
|
||||
const cpuRaw = cleanValue(row[19]);
|
||||
const ramRaw = cleanValue(row[20]);
|
||||
const gpuRaw = cleanValue(row[21]);
|
||||
const ssd1Raw = cleanValue(row[22]);
|
||||
const ssd2Raw = cleanValue(row[23]);
|
||||
const hdd1Raw = cleanValue(row[24]);
|
||||
const hdd2Raw = cleanValue(row[25]);
|
||||
const hdd3Raw = cleanValue(row[26]);
|
||||
const hdd4Raw = cleanValue(row[27]);
|
||||
|
||||
const randomCorp = CORPS[storageIndex % CORPS.length];
|
||||
|
||||
// NAS는 기본적으로 내부 백업/공유용 인프라
|
||||
const service_type = '내부서비스';
|
||||
const is_inactive = false;
|
||||
|
||||
// NAS 실시간 리소스 가상 데이터
|
||||
const cpu_usage = Math.floor(Math.random() * 25) + 15; // 15%~40%
|
||||
const ram_usage = Math.floor(Math.random() * 35) + 30; // 30%~65%
|
||||
const network_traffic = (Math.random() * 600 + 50).toFixed(0) + ' GB';
|
||||
|
||||
const assetItem = {
|
||||
id: randomId(),
|
||||
asset_type: typeRaw !== '-' ? typeRaw : '공용 NAS',
|
||||
purchase_corp: randomCorp,
|
||||
asset_code: 'STO-24' + String(storageIndex).padStart(3, '0'),
|
||||
purchase_date: '2022-08',
|
||||
asset_purpose: detailRaw,
|
||||
current_dept: teamRaw !== '-' ? teamRaw : '디자인팀',
|
||||
previous_dept: '-',
|
||||
location: locRaw,
|
||||
manager_primary: mgr1Raw,
|
||||
manager_secondary: mgr2Raw,
|
||||
ip_address: ipRaw !== '-' ? ipRaw : '172.16.42.' + (100 + storageIndex),
|
||||
remote_tool: toolRaw !== '-' ? toolRaw : 'Web GUI',
|
||||
model_name: modelRaw !== '-' ? modelRaw : 'Synology 공용 NAS',
|
||||
os: osRaw !== '-' ? `${osRaw} ${osVerRaw}` : 'DSM 7.x',
|
||||
cpu: cpuRaw,
|
||||
ram: ramRaw,
|
||||
gpu: gpuRaw,
|
||||
ssd_1: ssd1Raw,
|
||||
ssd_2: ssd2Raw,
|
||||
hdd_1: hdd1Raw,
|
||||
hdd_2: hdd2Raw,
|
||||
hdd_3: hdd3Raw,
|
||||
hdd_4: hdd4Raw,
|
||||
monitoring: '비대상',
|
||||
purchase_amount: '4500000',
|
||||
purchase_vendor: '시놀로지 총판',
|
||||
approval_document: '2022_스토리지_도입_' + storageIndex,
|
||||
memo: '스토리지 서버 공유 자산',
|
||||
asset_name: assetNameRaw,
|
||||
mac_address: `00:1A:2B:3C:5F:${storageIndex.toString(16).toUpperCase().padStart(2, '0')}`,
|
||||
hw_status: '운영중',
|
||||
service_type: service_type,
|
||||
is_inactive: is_inactive,
|
||||
volume: hdd1Raw !== '-' ? hdd1Raw : '24TB',
|
||||
cpu_usage: cpu_usage,
|
||||
ram_usage: ram_usage,
|
||||
network_traffic: network_traffic
|
||||
};
|
||||
|
||||
parsedStorages.push(assetItem);
|
||||
storageIndex++;
|
||||
}
|
||||
|
||||
console.log(`Parsed Servers: ${parsedServers.length} units`);
|
||||
console.log(`Parsed Storages: ${parsedStorages.length} units`);
|
||||
|
||||
// 3. 파일 다시 쓰기
|
||||
const newDummyDataFileContent = `import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog } from './excelHandler';
|
||||
|
||||
// 유틸리티: 랜덤 문자열
|
||||
const randomId = () => Math.random().toString(36).substring(2, 9);
|
||||
|
||||
// 유틸리티: 랜덤 년월 (YYYY-MM) (최근 10년)
|
||||
const randomPurchaseYM = () => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const year = currentYear - Math.floor(Math.random() * 10);
|
||||
const month = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
|
||||
return \`\${year}-\${month}\`;
|
||||
};
|
||||
|
||||
// 유틸리티: 랜덤 YYYY-MM-DD
|
||||
const randomDateStr = (maxYearsAgo = 10) => {
|
||||
const currentYear = new Date().getFullYear();
|
||||
const year = currentYear - Math.floor(Math.random() * maxYearsAgo);
|
||||
const month = String(Math.floor(Math.random() * 12) + 1).padStart(2, '0');
|
||||
const day = String(Math.floor(Math.random() * 28) + 1).padStart(2, '0');
|
||||
return \`\${year}-\${month}-\${day}\`;
|
||||
};
|
||||
|
||||
const CORPS = ['한맥', '삼안', '장헌', '장헌산업', 'PTC', '바론', '한라'];
|
||||
const getRandomCorp = () => CORPS[Math.floor(Math.random() * CORPS.length)];
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 1. SampleData_PC.xlsx 에서 파싱된 PC 데이터 주입
|
||||
// ────────────────────────────────────────────────────────
|
||||
export const dummyPCs: any[] = ${JSON.stringify(dummyPCs, null, 2)};
|
||||
|
||||
// ────────────────────────────────────────────────────────
|
||||
// 2. 기타 자산 더미 데이터 (서버, 스토리지, 소프트웨어 등 - 엑셀 파싱 연동)
|
||||
// ────────────────────────────────────────────────────────
|
||||
|
||||
export const dummyServers: any[] = ${JSON.stringify(parsedServers, null, 2)};
|
||||
|
||||
export const dummyStorages: any[] = ${JSON.stringify(parsedStorages, null, 2)};
|
||||
|
||||
export const dummyEquips: any[] = Array.from({ length: 12 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
asset_type: '전산비품',
|
||||
purchase_corp: getRandomCorp(),
|
||||
asset_code: \`EQ-24\${String(i).padStart(3, '0')}\`,
|
||||
asset_name: \`네트워크 스위치 \${i+1}\`,
|
||||
location: '전산실 랙 1',
|
||||
manager_primary: '네트워크담당자',
|
||||
ip_address: \`192.168.10.\${200 + i}\`,
|
||||
mac_address: \`00:1A:2B:3C:51:\${String(i).padStart(2, '0')}\`,
|
||||
os: 'Cisco IOS',
|
||||
purchase_date: randomPurchaseYM(),
|
||||
purchase_amount: '150000',
|
||||
purchase_vendor: '다나와',
|
||||
approval_document: \`2024_비품구매_\${i}\`,
|
||||
memo: '사내망 확장용',
|
||||
asset_purpose: '네트워크 분배'
|
||||
}));
|
||||
|
||||
export const dummyMobiles: any[] = Array.from({ length: 15 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
asset_type: '모바일기기',
|
||||
purchase_corp: getRandomCorp(),
|
||||
asset_code: \`MOB-24\${String(i).padStart(3, '0')}\`,
|
||||
asset_name: \`테스트용 단말기 \${i+1}\`,
|
||||
location: '개발2팀',
|
||||
manager_primary: '테스터',
|
||||
os: i % 2 === 0 ? 'Android 14' : 'iOS 17',
|
||||
purchase_date: randomPurchaseYM(),
|
||||
purchase_amount: '900000',
|
||||
purchase_vendor: '삼성전자/애플',
|
||||
approval_document: \`2024_모바일구매_\${i}\`,
|
||||
memo: '앱 호환성 테스트 전용',
|
||||
asset_purpose: 'QA 테스트',
|
||||
ip_address: \`192.168.1.\${10 + i}\`,
|
||||
mac_address: \`00:1A:2B:3C:50:\${String(i).padStart(2, '0')}\`
|
||||
}));
|
||||
|
||||
export const dummySubSw: any[] = Array.from({ length: 10 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
sw_type: '구독SW',
|
||||
sw_field: '업무용/협업',
|
||||
purchase_corp: getRandomCorp(),
|
||||
current_dept: '전사',
|
||||
product_name: \`Microsoft 365 E\${3 + (i%2)}\`,
|
||||
purchase_date: randomDateStr(3),
|
||||
start_date: randomDateStr(1),
|
||||
expired_date: randomDateStr(0),
|
||||
purchase_amount: '150000',
|
||||
asset_count: 50 + i * 5,
|
||||
email_account: \`admin\${i}@hmcorp.com\`,
|
||||
purchase_vendor: '소프트웨어인라이프',
|
||||
memo: '연간 계약 갱신 필요'
|
||||
}));
|
||||
|
||||
export const dummyPermSw: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
sw_type: '영구SW',
|
||||
sw_field: '디자인/설계',
|
||||
purchase_corp: getRandomCorp(),
|
||||
current_dept: '디자인팀',
|
||||
product_name: \`AutoCAD 202\${i%4}\`,
|
||||
purchase_date: randomDateStr(5),
|
||||
start_date: randomDateStr(5),
|
||||
expired_date: '2099-12-31',
|
||||
purchase_amount: '3000000',
|
||||
asset_count: 2,
|
||||
email_account: \`design\${i}@hmcorp.com\`,
|
||||
purchase_vendor: '오토데스크 파트너',
|
||||
memo: 'USB 동글키 보관중'
|
||||
}));
|
||||
|
||||
export const dummyCloud: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
sw_type: '클라우드',
|
||||
asset_mfr: i % 2 === 0 ? 'AWS' : 'GCP',
|
||||
purchase_corp: getRandomCorp(),
|
||||
current_dept: '개발팀',
|
||||
product_name: \`컴퓨팅 인스턴스 Type \${i}\`,
|
||||
email_account: \`awsadmin\${i}@hmcorp.com\`,
|
||||
purchase_method: '법인카드(신한 1234)',
|
||||
purchase_amount: \`\${500000 + i * 100000}\`,
|
||||
asset_count: 1,
|
||||
purchase_vendor: 'AWS/GCP',
|
||||
memo: '환율 변동에 따라 매월 상이함'
|
||||
}));
|
||||
|
||||
export const dummyDomain: any[] = Array.from({ length: 5 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
asset_type: '도메인',
|
||||
purchase_corp: getRandomCorp(),
|
||||
product_name: \`사내 운영 서비스 \${i+1}\`,
|
||||
domain_address: \`service\${i+1}.hmcorp.com\`,
|
||||
start_date: randomDateStr(4),
|
||||
expired_date: randomDateStr(0),
|
||||
purchase_amount: '22000',
|
||||
manager_primary: '인프라팀장',
|
||||
manager_secondary: '인프라담당자',
|
||||
memo: '가비아 자동갱신 설정 완료'
|
||||
}));
|
||||
|
||||
export const dummySwUsers: any[] = Array.from({ length: 15 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
sw_id: dummySubSw[0]?.id || randomId(),
|
||||
purchase_corp: getRandomCorp(),
|
||||
current_dept: '경영지원팀',
|
||||
user_current: \`홍길동\${i}\`,
|
||||
memo: \`SW신청서_2400\${i}\`
|
||||
}));
|
||||
|
||||
export const dummyLogs: any[] = Array.from({ length: 10 }).map((_, i) => ({
|
||||
id: randomId(),
|
||||
assetId: dummyPCs[0]?.id || randomId(),
|
||||
date: randomDateStr(1),
|
||||
details: i % 2 === 0 ? '메모리 추가 증설 (16GB -> 32GB)' : '디스플레이 파손 수리',
|
||||
user: 'IT지원팀',
|
||||
cost: i % 2 === 0 ? 80000 : 150000,
|
||||
}));
|
||||
`;
|
||||
|
||||
fs.writeFileSync(dummyDataPath, newDummyDataFileContent, 'utf-8');
|
||||
console.log('✅ dummyData.ts file updated successfully with SVR dataset.');
|
||||
} catch (e) {
|
||||
console.error('❌ Failed to update dummy data:', e);
|
||||
}
|
||||
358
server.js
358
server.js
@@ -4,7 +4,7 @@ import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
|
||||
dotenv.config();
|
||||
dotenv.config({ override: true });
|
||||
|
||||
const app = express();
|
||||
app.use(cors());
|
||||
@@ -36,25 +36,24 @@ const handleError = (res, err, label) => {
|
||||
|
||||
// --- Global Constants ---
|
||||
const CATEGORY_TABLE_MAP = {
|
||||
pc: 'asset_pc',
|
||||
server: 'asset_server',
|
||||
storage: 'asset_storage',
|
||||
network: 'asset_network',
|
||||
equipment: 'asset_equipment',
|
||||
officeSupplies: 'asset_office_supplies',
|
||||
survey: 'asset_survey',
|
||||
vip: 'asset_vip',
|
||||
swInternal: 'sw_internal',
|
||||
swExternal: 'sw_external',
|
||||
cloud: 'asset_cloud',
|
||||
users: 'user_master',
|
||||
swUsers: 'sw_assignment',
|
||||
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'
|
||||
};
|
||||
|
||||
const ASSET_TABLES = [
|
||||
'asset_pc', 'asset_server', 'asset_storage', 'asset_network',
|
||||
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
|
||||
'asset_core'
|
||||
];
|
||||
|
||||
// --- API Endpoints ---
|
||||
@@ -62,6 +61,7 @@ const ASSET_TABLES = [
|
||||
// 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' });
|
||||
|
||||
@@ -70,14 +70,14 @@ app.post('/api/:table/batch', async (req, res) => {
|
||||
connection = await pool.getConnection();
|
||||
await connection.beginTransaction();
|
||||
|
||||
const [columns] = await connection.query(`DESCRIBE ${table}`);
|
||||
const [columns] = await connection.query(`DESCRIBE ${dbTable}`);
|
||||
const validFields = columns.map(c => c.Field);
|
||||
|
||||
await connection.query(`DELETE FROM ${table}`);
|
||||
await connection.query(`DELETE FROM ${dbTable}`);
|
||||
|
||||
if (data.length > 0) {
|
||||
const placeholders = validFields.map(() => '?').join(', ');
|
||||
const sql = `INSERT INTO ${table} (${validFields.join(', ')}) VALUES (${placeholders})`;
|
||||
const sql = `INSERT INTO ${dbTable} (${validFields.join(', ')}) VALUES (${placeholders})`;
|
||||
|
||||
for (const item of data) {
|
||||
const values = validFields.map(field => {
|
||||
@@ -100,22 +100,27 @@ app.post('/api/:table/batch', async (req, res) => {
|
||||
|
||||
// 2. Get All Assets (Integrated Master Data from Normalized V3 Schema)
|
||||
app.get('/api/assets/master', async (req, res) => {
|
||||
let connection;
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
connection = await pool.getConnection();
|
||||
|
||||
const masterData = {
|
||||
pc: [], server: [], storage: [], network: [],
|
||||
equipment: [], officeSupplies: [], survey: [], vip: [], pcParts: [],
|
||||
swInternal: [], swExternal: [], swUsers: [], users: [], logs: []
|
||||
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,
|
||||
n.ip_address, n.mac_address, n.remote_tool, n.remote_id, n.remote_pw,
|
||||
(
|
||||
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
|
||||
@@ -127,11 +132,6 @@ app.get('/api/assets/master', async (req, res) => {
|
||||
WHERE asset_id = c.id AND is_active = 1
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
)
|
||||
LEFT JOIN asset_network n ON n.id = (
|
||||
SELECT id FROM asset_network
|
||||
WHERE asset_id = c.id AND is_active = 1
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
)
|
||||
`);
|
||||
|
||||
const catMap = {
|
||||
@@ -150,17 +150,20 @@ app.get('/api/assets/master', async (req, res) => {
|
||||
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');
|
||||
|
||||
masterData.swInternal = swInternal;
|
||||
masterData.swExternal = swExternal;
|
||||
masterData.swUsers = swUsers;
|
||||
masterData.users = users;
|
||||
masterData.logs = logs;
|
||||
masterData.partsMaster = partsMaster;
|
||||
|
||||
connection.release();
|
||||
res.json(masterData);
|
||||
} catch (err) {
|
||||
handleError(res, err, 'MASTER DATA');
|
||||
} finally {
|
||||
if (connection) connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -172,13 +175,102 @@ app.post('/api/asset/:category/save', async (req, res) => {
|
||||
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}`
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
const coreSql = `INSERT INTO asset_core (${coreKeys.join(', ')}) VALUES (${coreKeys.map(() => '?').join(', ')}) ON DUPLICATE KEY UPDATE ${coreKeys.map(k => `${k} = VALUES(${k})`).join(', ')}`;
|
||||
await connection.query(coreSql, Object.values(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'];
|
||||
@@ -223,14 +315,36 @@ app.post('/api/asset/:category/save', async (req, res) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 3.5 asset_network
|
||||
if (asset.ip_address || asset.mac_address || asset.remote_tool) {
|
||||
const [netActive] = await connection.query('SELECT * FROM asset_network WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
const isChanged = netActive.length === 0 || netActive[0].ip_address !== asset.ip_address || netActive[0].mac_address !== asset.mac_address || netActive[0].remote_tool !== asset.remote_tool || netActive[0].remote_id !== asset.remote_id || netActive[0].remote_pw !== asset.remote_pw;
|
||||
if (isChanged) {
|
||||
await connection.query('UPDATE asset_network SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1', [asset.id]);
|
||||
await connection.query(`INSERT INTO asset_network (asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw, is_active) VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||
[asset.id, asset.ip_address, asset.mac_address, asset.remote_tool, asset.remote_id, asset.remote_pw]);
|
||||
// 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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,6 +359,71 @@ app.post('/api/asset/:category/save', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// 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;
|
||||
@@ -272,7 +451,7 @@ app.delete('/api/asset/:category/:id', async (req, res) => {
|
||||
|
||||
try {
|
||||
const connection = await pool.getConnection();
|
||||
// For asset_core, ON DELETE CASCADE will handle spec, location, network, volume
|
||||
// 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}`);
|
||||
@@ -317,6 +496,107 @@ app.get('/api/maps', (req, res) => {
|
||||
} 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 HARDWARE COMPONENTS');
|
||||
}
|
||||
});
|
||||
|
||||
// 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.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', (req, res) => {
|
||||
try {
|
||||
const { path, boxes } = req.body;
|
||||
|
||||
@@ -55,6 +55,9 @@ export abstract class BaseModal {
|
||||
this.currentAsset = asset;
|
||||
this.isEditMode = (mode === 'add' || mode === 'edit');
|
||||
|
||||
// 폼 초기화 추가
|
||||
if (this.formEl) this.formEl.reset();
|
||||
|
||||
this.setEditLockMode(mode);
|
||||
this.fillFormData(asset);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
||||
import { calculatePcScoreDeductive, getPcGrade } from '../../core/utils';
|
||||
import {
|
||||
generateOptionsHTML,
|
||||
setFieldValue,
|
||||
@@ -13,6 +14,7 @@ import { BaseModal } from './BaseModal';
|
||||
|
||||
class HwAssetModal extends BaseModal {
|
||||
private dynamicMapConfig: Record<string, any[]> = {};
|
||||
private masterComponents: any[] = [];
|
||||
|
||||
constructor() {
|
||||
super('hw', '자산 상세 정보');
|
||||
@@ -20,6 +22,39 @@ class HwAssetModal extends BaseModal {
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
return `
|
||||
<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;
|
||||
}
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
<div id="hw-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
@@ -63,6 +98,17 @@ class HwAssetModal extends BaseModal {
|
||||
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
|
||||
<select id="hw-hw_status" name="hw_status">${generateOptionsHTML(HW_STATUS_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.SERVICE_TYPE.ui}</label>
|
||||
<select id="hw-service_type" name="service_type" style="${inputStyle}">
|
||||
<option value="외부">외부</option>
|
||||
<option value="내부">내부</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group full-width" style="grid-column: span 2;">
|
||||
<label>${ASSET_SCHEMA.ASSET_PURPOSE.ui}</label>
|
||||
<input type="text" id="hw-asset_purpose" name="asset_purpose" placeholder="자산의 용도를 입력하세요" style="${inputStyle} width: 100%;" />
|
||||
</div>
|
||||
<div class="form-group infra-only monitoring-field">
|
||||
<label>${ASSET_SCHEMA.MONITORING.ui}</label>
|
||||
<select id="hw-monitoring" name="monitoring">
|
||||
@@ -72,15 +118,19 @@ class HwAssetModal extends BaseModal {
|
||||
</div>
|
||||
|
||||
<!-- [SECTION 2] 조직 및 사용자 정보 -->
|
||||
<div class="form-section-title org-user-section">사용자 및 조직 정보</div>
|
||||
<div class="form-group org-user-field">
|
||||
<div class="form-section-title" style="margin-top: 24px; margin-bottom: 12px;">사용자 및 조직 정보</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
||||
<select id="hw-current_dept" name="current_dept">${generateOptionsHTML(ORG_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group org-user-field">
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MANAGER_MAIN.ui}</label>
|
||||
<input type="text" id="hw-manager_primary" name="manager_primary" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MANAGER_SUB.ui}</label>
|
||||
<input type="text" id="hw-manager_secondary" name="manager_secondary" style="${inputStyle}" />
|
||||
</div>
|
||||
<div class="form-group personal-only">
|
||||
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
|
||||
<input type="text" id="hw-user_current" name="user_current" />
|
||||
@@ -89,10 +139,6 @@ class HwAssetModal extends BaseModal {
|
||||
<label>${ASSET_SCHEMA.USER_POSITION.ui}</label>
|
||||
<input type="text" id="hw-user_position" name="user_position" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.MANAGER_SUB.ui}</label>
|
||||
<input type="text" id="hw-manager_secondary" name="manager_secondary" />
|
||||
</div>
|
||||
<div class="form-group personal-only">
|
||||
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
|
||||
<input type="text" id="hw-previous_user" name="previous_user" />
|
||||
@@ -116,22 +162,31 @@ class HwAssetModal extends BaseModal {
|
||||
<label>${ASSET_SCHEMA.OS.ui}</label>
|
||||
<input type="text" id="hw-os" name="os" />
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<div class="form-group spec-only" style="position: relative;">
|
||||
<label>${ASSET_SCHEMA.CPU.ui}</label>
|
||||
<input type="text" id="hw-cpu" name="cpu" />
|
||||
<input type="text" id="hw-cpu" name="cpu" autocomplete="off" placeholder="CPU 부품 검색..." style="${inputStyle}" />
|
||||
<div id="hw-cpu-autocomplete" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<div class="form-group spec-only" style="position: relative;">
|
||||
<label>${ASSET_SCHEMA.RAM.ui}</label>
|
||||
<input type="text" id="hw-ram" name="ram" />
|
||||
<input type="text" id="hw-ram" name="ram" autocomplete="off" placeholder="RAM 부품 검색..." style="${inputStyle}" />
|
||||
<div id="hw-ram-autocomplete" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<div class="form-group spec-only" style="position: relative;">
|
||||
<label>${ASSET_SCHEMA.GPU.ui}</label>
|
||||
<input type="text" id="hw-gpu" name="gpu" />
|
||||
<input type="text" id="hw-gpu" name="gpu" autocomplete="off" placeholder="GPU 부품 검색..." style="${inputStyle}" />
|
||||
<div id="hw-gpu-autocomplete" class="autocomplete-list hidden"></div>
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<label>${ASSET_SCHEMA.MAINBOARD.ui}</label>
|
||||
<input type="text" id="hw-mainboard" name="mainboard" />
|
||||
</div>
|
||||
<div class="form-group spec-only">
|
||||
<label>성능 등급</label>
|
||||
<div id="hw-pc-grade-container" style="display: flex; align-items: center; height: 38px;">
|
||||
<span class="badge b-yellow" id="hw-pc-grade-badge">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group monitor-only">
|
||||
<label>${ASSET_SCHEMA.MONITOR_INCH.ui}</label>
|
||||
<input type="text" id="hw-monitor_inch" name="monitor_inch" />
|
||||
@@ -242,6 +297,18 @@ class HwAssetModal extends BaseModal {
|
||||
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
|
||||
applyDateMask(document.getElementById('hw-purchase_date') as HTMLInputElement);
|
||||
|
||||
this.fetchMasterComponents().then(() => {
|
||||
this.bindAutocomplete('hw-cpu', 'hw-cpu-autocomplete', 'CPU');
|
||||
this.bindAutocomplete('hw-ram', 'hw-ram-autocomplete', 'RAM');
|
||||
this.bindAutocomplete('hw-gpu', 'hw-gpu-autocomplete', 'GPU');
|
||||
});
|
||||
|
||||
const specInputs = ['hw-cpu', 'hw-ram', 'hw-gpu', 'hw-purchase_date'];
|
||||
specInputs.forEach(id => {
|
||||
document.getElementById(id)?.addEventListener('input', () => this.updatePcGradeBadge());
|
||||
document.getElementById(id)?.addEventListener('change', () => this.updatePcGradeBadge());
|
||||
});
|
||||
|
||||
categorySelect.addEventListener('change', () => {
|
||||
const types = CATEGORY_TYPE_MAP[categorySelect.value] || [];
|
||||
typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, '', true) : '<option value="">구분을 먼저 선택하세요</option>';
|
||||
@@ -253,10 +320,21 @@ class HwAssetModal extends BaseModal {
|
||||
});
|
||||
|
||||
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
|
||||
const type = typeSelect.value;
|
||||
const cat = categorySelect.value;
|
||||
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
|
||||
const prefix = TYPE_PREFIX_MAP[cat] || 'ETC';
|
||||
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
||||
if (!type) { alert('유형을 먼저 선택해주세요.'); return; }
|
||||
|
||||
const purchaseDateEl = document.getElementById('hw-purchase_date') as HTMLInputElement;
|
||||
const purchaseDate = purchaseDateEl?.value || '';
|
||||
|
||||
if (!purchaseDate) {
|
||||
alert('구매일자를 먼저 입력해야 자산번호 생성이 가능합니다.');
|
||||
purchaseDateEl?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
// 유형 기반 매핑 우선, 없으면 구분 기반, 그래도 없으면 ETC
|
||||
const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC';
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
|
||||
const data = await res.json();
|
||||
@@ -297,6 +375,7 @@ class HwAssetModal extends BaseModal {
|
||||
});
|
||||
|
||||
revertBtn.addEventListener('click', () => {
|
||||
this.isEditMode = false;
|
||||
this.setEditLockMode('view');
|
||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||
this.updateMapButtonVisibility();
|
||||
@@ -346,6 +425,35 @@ class HwAssetModal extends BaseModal {
|
||||
return;
|
||||
}
|
||||
|
||||
// CPU, RAM, GPU 마스터 테이블 기반 유효성 검사 (완전 강제 방식)
|
||||
const category = categorySelect.value;
|
||||
const type = typeSelect.value;
|
||||
const specCategories = ['PC', '서버', '노트북', '스토리지', '워크스테이션'];
|
||||
const hasSpec = specCategories.includes(category) || type.includes('서버PC');
|
||||
|
||||
if (hasSpec) {
|
||||
const cpuVal = (document.getElementById('hw-cpu') as HTMLInputElement)?.value || '';
|
||||
const ramVal = (document.getElementById('hw-ram') as HTMLInputElement)?.value || '';
|
||||
const gpuVal = (document.getElementById('hw-gpu') as HTMLInputElement)?.value || '';
|
||||
|
||||
const cpuMaster = this.masterComponents.filter(c => c.category === 'CPU').map(c => c.component_name);
|
||||
const ramMaster = this.masterComponents.filter(c => c.category === 'RAM').map(c => c.component_name);
|
||||
const gpuMaster = this.masterComponents.filter(c => c.category === 'GPU').map(c => c.component_name);
|
||||
|
||||
if (cpuVal && !cpuMaster.includes(cpuVal)) {
|
||||
alert(`[입력 오류] '${cpuVal}'은(는) 마스터 테이블에 존재하지 않는 CPU 부품명입니다. 자동완성 추천 목록에서 올바른 부품명을 골라 선택해 주세요.`);
|
||||
return;
|
||||
}
|
||||
if (ramVal && !ramMaster.includes(ramVal)) {
|
||||
alert(`[입력 오류] '${ramVal}'은(는) 마스터 테이블에 존재하지 않는 RAM 부품명입니다. 자동완성 추천 목록에서 올바른 부품명을 골라 선택해 주세요.`);
|
||||
return;
|
||||
}
|
||||
if (gpuVal && !gpuMaster.includes(gpuVal)) {
|
||||
alert(`[입력 오류] '${gpuVal}'은(는) 마스터 테이블에 존재하지 않는 GPU 부품명입니다. 자동완성 추천 목록에서 올바른 부품명을 골라 선택해 주세요.`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 동적 볼륨 데이터 수집
|
||||
const vols: any[] = [];
|
||||
document.querySelectorAll('#hw-volume-container .volume-row').forEach((row, idx) => {
|
||||
@@ -389,25 +497,24 @@ class HwAssetModal extends BaseModal {
|
||||
private addVolumeRow(vol: any = { type: 'SSD', capacity: '', unit: 'GB' }) {
|
||||
const container = document.getElementById('hw-volume-container');
|
||||
if (!container) return;
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'volume-row';
|
||||
|
||||
row.style.display = 'flex'; row.style.gap = '8px'; row.style.alignItems = 'center';
|
||||
const inputStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0; padding: 0 8px;';
|
||||
row.innerHTML = `
|
||||
<select class="vol-type" ${!this.isEditMode ? 'disabled' : ''}>
|
||||
<option value="SSD" ${vol.type === 'SSD' ? 'selected' : ''}>SSD</option>
|
||||
<option value="HDD" ${vol.type === 'HDD' ? 'selected' : ''}>HDD</option>
|
||||
<option value="NVMe" ${vol.type === 'NVMe' ? 'selected' : ''}>NVMe</option>
|
||||
</select>
|
||||
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" ${!this.isEditMode ? 'readonly' : ''} />
|
||||
<select class="vol-unit" ${!this.isEditMode ? 'disabled' : ''}>
|
||||
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
|
||||
<select class="vol-unit" style="${inputStyle} width: 60px;" ${!this.isEditMode ? 'disabled' : ''}>
|
||||
<option value="GB" ${vol.unit === 'GB' ? 'selected' : ''}>GB</option>
|
||||
<option value="TB" ${vol.unit === 'TB' ? 'selected' : ''}>TB</option>
|
||||
</select>
|
||||
<button type="button" class="btn btn-outline btn-remove-vol edit-only-btn" style="display: ${this.isEditMode ? 'inline-flex' : 'none'};">×</button>
|
||||
<button type="button" class="btn btn-outline btn-remove-row edit-only-btn" style="height: 38px !important; padding: 0 12px; color: #E11D48; border-color: #E11D48; display: ${this.isEditMode ? 'inline-flex' : 'none'};">×</button>
|
||||
`;
|
||||
|
||||
row.querySelector('.btn-remove-vol')?.addEventListener('click', () => row.remove());
|
||||
row.querySelector('.btn-remove-row')?.addEventListener('click', () => row.remove());
|
||||
container.appendChild(row);
|
||||
}
|
||||
|
||||
@@ -512,6 +619,8 @@ class HwAssetModal extends BaseModal {
|
||||
if (typeSelect) typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, asset.asset_type, true) : '<option value="">구분을 먼저 선택하세요</option>';
|
||||
setFieldValue('hw-asset_type', asset.asset_type || '');
|
||||
setFieldValue('hw-hw_status', asset.hw_status || '운영');
|
||||
setFieldValue('hw-service_type', asset.service_type || '외부');
|
||||
setFieldValue('hw-asset_purpose', asset.asset_purpose || '');
|
||||
setFieldValue('hw-current_dept', asset.current_dept || '');
|
||||
setFieldValue('hw-manager_primary', asset.manager_primary || '');
|
||||
setFieldValue('hw-manager_secondary', asset.manager_secondary || '');
|
||||
@@ -526,16 +635,12 @@ class HwAssetModal extends BaseModal {
|
||||
setFieldValue('hw-gpu', asset.gpu || '');
|
||||
setFieldValue('hw-mainboard', asset.mainboard || '');
|
||||
|
||||
// 동적 볼륨 렌더링 초기화 및 생성
|
||||
// 동적 볼륨 렌더링
|
||||
const volumeContainer = document.getElementById('hw-volume-container');
|
||||
if (volumeContainer) {
|
||||
volumeContainer.innerHTML = '';
|
||||
let vols = [];
|
||||
try {
|
||||
vols = asset.volumes ? (typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes) : [];
|
||||
} catch(e) {}
|
||||
vols.forEach((v: any) => this.addVolumeRow(v));
|
||||
}
|
||||
if (volumeContainer) volumeContainer.innerHTML = '';
|
||||
let vols = [];
|
||||
try { vols = asset.volumes ? (typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes) : []; } catch(e) {}
|
||||
vols.forEach((v: any) => this.addVolumeRow(v));
|
||||
|
||||
// 통합 원격 접속 정보 렌더링 초기화 및 생성
|
||||
const remoteInfoContainer = document.getElementById('hw-remote-info-container');
|
||||
@@ -573,6 +678,7 @@ class HwAssetModal extends BaseModal {
|
||||
setFieldValue('hw-purchase_vendor', asset.purchase_vendor || '');
|
||||
setFieldValue('hw-purchase_amount', asset.purchase_amount || '');
|
||||
setFieldValue('hw-approval_document', asset.approval_document || '');
|
||||
|
||||
const docName = document.getElementById('hw-file-name-display');
|
||||
if (docName) docName.textContent = asset.approval_document ? asset.approval_document.split('/').pop() : '파일 선택...';
|
||||
const fileLinkContainer = document.getElementById('hw-file-link-container');
|
||||
@@ -581,6 +687,7 @@ class HwAssetModal extends BaseModal {
|
||||
} else if (fileLinkContainer) {
|
||||
fileLinkContainer.innerHTML = '';
|
||||
}
|
||||
|
||||
setFieldValue('hw-memo', asset.memo || '');
|
||||
setFieldValue('hw-location_detail', asset.location_detail || '');
|
||||
setFieldValue('hw-loc_x', asset.loc_x || '');
|
||||
@@ -589,6 +696,7 @@ class HwAssetModal extends BaseModal {
|
||||
parseAndSetLocation(asset.location || '', asset.location_detail || '', 'hw-bldg-select', 'hw-location_detail');
|
||||
this.renderHistory(asset.id);
|
||||
this.applyRoleVisibility();
|
||||
this.updatePcGradeBadge();
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
@@ -676,29 +784,110 @@ class HwAssetModal extends BaseModal {
|
||||
overlay.className = 'image-picker-overlay';
|
||||
const renderContent = () => {
|
||||
const imgPath = imagePaths[currentIdx];
|
||||
const digitalMap = this.generateDynamicSVG(imgPath);
|
||||
const isMulti = imagePaths.length > 1;
|
||||
const isHtmlMap = imgPath.toLowerCase().endsWith('.html');
|
||||
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imgPath);
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button></div>
|
||||
<div class="image-picker-content"><div class="layout-map-container" id="picker-container"><img src="${imgPath}" class="layout-map-img" /><div id="picker-marker" class="layout-marker hidden"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
|
||||
<div class="image-picker-header">
|
||||
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
|
||||
<button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button>
|
||||
</div>
|
||||
<div class="image-picker-content">
|
||||
${isMulti ? `
|
||||
<div class="picker-nav prev ${currentIdx === 0 ? 'disabled' : ''}" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">◀</div>
|
||||
<div class="picker-nav next ${currentIdx === imagePaths.length - 1 ? 'disabled' : ''}" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">▶</div>
|
||||
` : ''}
|
||||
<div class="layout-map-container" id="picker-container">
|
||||
${isHtmlMap
|
||||
? `<iframe src="${imgPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
|
||||
: `<img src="${imgPath}" class="layout-map-img" /><div id="picker-marker" class="layout-marker hidden"></div><div class="digital-overlay-layer">${digitalMap}</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-picker-footer"><button id="btn-picker-cancel" class="btn btn-outline" style="color:white; border-color:white;">취소</button><button id="btn-picker-save" class="btn btn-primary">위치 확정</button></div>`;
|
||||
|
||||
let selectedX = ''; let selectedY = '';
|
||||
const container = overlay.querySelector('#picker-container') as HTMLElement;
|
||||
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
|
||||
container.addEventListener('click', (e) => {
|
||||
const rect = container.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width) * 100;
|
||||
const y = ((e.clientY - rect.top) / rect.height) * 100;
|
||||
selectedX = x.toFixed(2); selectedY = y.toFixed(2);
|
||||
marker.style.left = `${selectedX}%`; marker.style.top = `${selectedY}%`; marker.classList.remove('hidden');
|
||||
});
|
||||
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
||||
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
||||
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
||||
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
||||
this.updateMapButtonVisibility(); overlay.remove();
|
||||
});
|
||||
|
||||
if (isMulti) {
|
||||
overlay.querySelector('.picker-nav.prev')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx > 0) { currentIdx--; renderContent(); } });
|
||||
overlay.querySelector('.picker-nav.next')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx < imagePaths.length - 1) { currentIdx++; renderContent(); } });
|
||||
}
|
||||
|
||||
if (isHtmlMap) {
|
||||
// HTML 지도 메시지 리스너
|
||||
const handleMessage = (e: MessageEvent) => {
|
||||
if (e.data.type === 'PICK_LOCATION') {
|
||||
selectedX = e.data.x;
|
||||
selectedY = e.data.y;
|
||||
}
|
||||
};
|
||||
window.addEventListener('message', handleMessage);
|
||||
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
|
||||
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); });
|
||||
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
||||
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
||||
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
||||
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
||||
this.updateMapButtonVisibility();
|
||||
window.removeEventListener('message', handleMessage);
|
||||
overlay.remove();
|
||||
});
|
||||
} else {
|
||||
const container = overlay.querySelector('#picker-container') as HTMLElement;
|
||||
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
|
||||
container.addEventListener('click', (e) => {
|
||||
const rectBound = container.getBoundingClientRect();
|
||||
const clickX = ((e.clientX - rectBound.left) / rectBound.width) * 100;
|
||||
const clickY = ((e.clientY - rectBound.top) / rectBound.height) * 100;
|
||||
|
||||
let snapped = false;
|
||||
overlay.querySelectorAll('rect').forEach(rect => {
|
||||
const rx = parseFloat(rect.getAttribute('x') || '0');
|
||||
const ry = parseFloat(rect.getAttribute('y') || '0');
|
||||
const rw = parseFloat(rect.getAttribute('width') || '0');
|
||||
const rh = parseFloat(rect.getAttribute('height') || '0');
|
||||
|
||||
if (clickX >= rx && clickX <= rx + rw && clickY >= ry && clickY <= ry + rh) {
|
||||
overlay.querySelectorAll('rect').forEach(r => {
|
||||
r.style.fill = 'rgba(30,81,73,0.05)';
|
||||
r.style.stroke = 'rgba(30,81,73,0.2)';
|
||||
r.style.strokeWidth = '0.2';
|
||||
});
|
||||
rect.style.fill = 'rgba(255, 61, 0, 0.4)';
|
||||
rect.style.stroke = '#FF3D00';
|
||||
rect.style.strokeWidth = '0.8';
|
||||
|
||||
selectedX = rx.toFixed(2);
|
||||
selectedY = ry.toFixed(2);
|
||||
|
||||
marker.style.left = `${rx + rw/2}%`;
|
||||
marker.style.top = `${ry + rh/2}%`;
|
||||
marker.classList.remove('hidden');
|
||||
snapped = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!snapped) {
|
||||
selectedX = '';
|
||||
selectedY = '';
|
||||
marker.classList.add('hidden');
|
||||
overlay.querySelectorAll('rect').forEach(r => {
|
||||
r.style.fill = 'rgba(30,81,73,0.05)';
|
||||
r.style.stroke = 'rgba(30,81,73,0.2)';
|
||||
r.style.strokeWidth = '0.2';
|
||||
});
|
||||
}
|
||||
});
|
||||
overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove());
|
||||
overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => {
|
||||
if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; }
|
||||
setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY);
|
||||
setFieldValue('hw-location_photo', imagePaths[currentIdx]);
|
||||
this.updateMapButtonVisibility(); overlay.remove();
|
||||
});
|
||||
}
|
||||
};
|
||||
renderContent(); document.body.appendChild(overlay);
|
||||
}
|
||||
@@ -706,13 +895,26 @@ class HwAssetModal extends BaseModal {
|
||||
private openImagePreview(imagePath: string, title: string, x: string, y: string) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.className = 'image-picker-overlay';
|
||||
const digitalMap = this.generateDynamicSVG(imagePath);
|
||||
const isHtmlMap = imagePath.toLowerCase().endsWith('.html');
|
||||
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imagePath);
|
||||
|
||||
// HTML 지도인 경우 좌표를 쿼리 파라미터로 전달
|
||||
const finalPath = isHtmlMap ? `${imagePath}?markerX=${x}&markerY=${y}` : imagePath;
|
||||
|
||||
overlay.innerHTML = `
|
||||
<div class="image-picker-header"><h3>${title}</h3><button class="btn-close-picker" style="background:none; border:none; color:white; font-size:24px; cursor:pointer;">×</button></div>
|
||||
<div class="image-picker-content"><div class="layout-map-container readonly"><img src="${imagePath}" class="layout-map-img" /><div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div><div class="digital-overlay-layer">${digitalMap}</div></div></div>
|
||||
<div class="image-picker-content">
|
||||
<div class="layout-map-container readonly">
|
||||
${isHtmlMap
|
||||
? `<iframe src="${finalPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
|
||||
: `<img src="${imagePath}" class="layout-map-img" /><div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div><div class="digital-overlay-layer">${digitalMap}</div>`
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>`;
|
||||
|
||||
document.body.appendChild(overlay);
|
||||
if (digitalMap) {
|
||||
if (!isHtmlMap && digitalMap) {
|
||||
const curX = parseFloat(x || '0'); const curY = parseFloat(y || '0');
|
||||
overlay.querySelectorAll('rect').forEach(rect => {
|
||||
const sx = parseFloat(rect.getAttribute('x') || '0');
|
||||
@@ -733,9 +935,51 @@ class HwAssetModal extends BaseModal {
|
||||
private renderHistory(assetId: string) {
|
||||
const container = document.getElementById('hw-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('');
|
||||
|
||||
// state.masterData.logs에서 해당 자산의 이력 필터링 (최신순)
|
||||
const logs = (state.masterData.logs || [])
|
||||
.filter(l => l.asset_id === assetId)
|
||||
.sort((a, b) => new Date(b.created_at || b.log_date || '').getTime() - new Date(a.created_at || a.log_date || '').getTime());
|
||||
|
||||
if (logs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-history">기록된 변동 이력이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = logs.map(l => {
|
||||
let eventTag = '기타';
|
||||
let tagClass = 'tag-default';
|
||||
let itemClass = '';
|
||||
|
||||
switch(l.event_type) {
|
||||
case 'DEPT_CHANGE':
|
||||
eventTag = '조직'; tagClass = 'tag-dept'; itemClass = 'evt-dept';
|
||||
break;
|
||||
case 'USER_CHANGE':
|
||||
eventTag = '사용자'; tagClass = 'tag-user'; itemClass = 'evt-user';
|
||||
break;
|
||||
case 'ROLE_CHANGE':
|
||||
eventTag = '용도'; tagClass = 'tag-role'; itemClass = 'evt-role';
|
||||
break;
|
||||
case 'STATUS_CHANGE':
|
||||
eventTag = '상태'; tagClass = 'tag-status'; itemClass = 'evt-status';
|
||||
break;
|
||||
}
|
||||
|
||||
// 화살표 기호(➔)를 사용하여 변경 사항 강조
|
||||
const formattedDetails = (l.details || '').replace(' -> ', ' <span class="history-arrow">➔</span> ');
|
||||
|
||||
return `
|
||||
<div class="history-item ${itemClass}">
|
||||
<div class="history-header-row">
|
||||
<span class="history-tag ${tagClass}">${eventTag}</span>
|
||||
<span class="history-date">${l.log_date || ''}</span>
|
||||
</div>
|
||||
<span class="history-user">${l.log_user || '시스템'}</span>
|
||||
<div class="history-details">${formattedDetails}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
private getCategoryKey(asset: any): string {
|
||||
@@ -750,6 +994,77 @@ class HwAssetModal extends BaseModal {
|
||||
if (cat === 'PC부품') return 'pcParts';
|
||||
return (cat === 'PC' || code.startsWith('PC')) ? 'pc' : 'officeSupplies';
|
||||
}
|
||||
|
||||
private async fetchMasterComponents(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/hardware-components`);
|
||||
this.masterComponents = await res.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch master components:', err);
|
||||
}
|
||||
}
|
||||
|
||||
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 = this.masterComponents.filter(c => c.category === category);
|
||||
const filtered = filterText
|
||||
? items.filter(c => 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 => `<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.updatePcGradeBadge(); // 뱃지 즉시 업데이트
|
||||
}
|
||||
});
|
||||
|
||||
// 아웃사이드 클릭 시 닫기
|
||||
document.addEventListener('mousedown', (e) => {
|
||||
if (e.target !== input && !list.contains(e.target as Node)) {
|
||||
list.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private updatePcGradeBadge(): void {
|
||||
const cpu = (document.getElementById('hw-cpu') as HTMLInputElement)?.value || '';
|
||||
const ram = (document.getElementById('hw-ram') as HTMLInputElement)?.value || '';
|
||||
const gpu = (document.getElementById('hw-gpu') as HTMLInputElement)?.value || '';
|
||||
const date = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
|
||||
|
||||
const score = calculatePcScoreDeductive(cpu, ram, gpu, date);
|
||||
const grade = getPcGrade(score);
|
||||
|
||||
const badge = document.getElementById('hw-pc-grade-badge');
|
||||
if (badge) {
|
||||
badge.textContent = `${grade.name} (${score}점)`;
|
||||
badge.className = `badge ${grade.class}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hwModal = new HwAssetModal();
|
||||
|
||||
625
src/components/Modal/PCFlowModal.ts
Normal file
625
src/components/Modal/PCFlowModal.ts
Normal file
@@ -0,0 +1,625 @@
|
||||
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;
|
||||
|
||||
// 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 style="padding: 10px; color: var(--text-muted); font-size: 13px;">일치하는 사원이 없습니다.</div>';
|
||||
container.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
users.forEach(u => {
|
||||
const item = document.createElement('div');
|
||||
item.style.padding = '8px 12px';
|
||||
item.style.cursor = 'pointer';
|
||||
item.style.fontSize = '13px';
|
||||
item.style.borderBottom = '1px solid #F3F4F6';
|
||||
item.className = 'suggestion-item';
|
||||
item.innerHTML = `
|
||||
<div style="font-weight: 700; color: var(--text-main);">${u.user_name}</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); display: flex; gap: 8px;">
|
||||
<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 style="padding: 10px; color: var(--text-muted); font-size: 13px;">불출 가능한 대기 PC 재고가 없습니다.</div>';
|
||||
container.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
pcs.forEach(p => {
|
||||
const item = document.createElement('div');
|
||||
item.style.padding = '8px 12px';
|
||||
item.style.cursor = 'pointer';
|
||||
item.style.fontSize = '13px';
|
||||
item.style.borderBottom = '1px solid #F3F4F6';
|
||||
item.className = 'suggestion-item';
|
||||
item.innerHTML = `
|
||||
<div style="font-weight: 700; color: var(--primary-color);">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted);">
|
||||
사양: 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 style="font-size: 12px; color: var(--text-muted); padding: 8px 0;">이 사용자가 소유한 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}" style="padding: 10px; border: 1px solid ${isSelected ? 'var(--primary-color)' : 'var(--border-color)'}; border-radius: 4px; cursor: pointer; background: ${isSelected ? 'var(--primary-light)' : 'white'}; transition: all 0.2s;">
|
||||
<div style="font-weight: 700; font-size: 13px; color: ${isSelected ? 'var(--primary-color)' : 'var(--text-main)'};">${p.asset_code}</div>
|
||||
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">
|
||||
${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 {
|
||||
const overlayStyle = `
|
||||
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center;
|
||||
z-index: 1000; transition: opacity 0.3s;
|
||||
`;
|
||||
const contentStyle = `
|
||||
background: white; border-radius: 12px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
|
||||
overflow: hidden; max-height: 90vh; width: 950px; display: flex; flex-direction: column;
|
||||
`;
|
||||
const labelStyle = 'display: block; font-size: 13px; font-weight: 700; color: var(--text-muted); margin-bottom: 8px;';
|
||||
const inputStyle = 'width: 100%; height: 38px; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
|
||||
const inputWithIconStyle = 'width: 100%; height: 38px; padding: 0 12px 0 36px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
|
||||
|
||||
return `
|
||||
<div id="pc-flow-modal" class="modal-overlay hidden" style="${overlayStyle}">
|
||||
<div class="modal-content" style="${contentStyle}">
|
||||
|
||||
<div class="modal-header" style="background: var(--primary-color); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color);">
|
||||
<h2 style="margin: 0; font-size: 18px; font-weight: 800; color: white; display: flex; align-items: center; gap: 8px;">
|
||||
<i data-lucide="refresh-cw"></i> PC 이동/반납 (불출/반납/이동)
|
||||
</h2>
|
||||
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body" style="padding: 24px; overflow-y: auto; display: flex; gap: 24px;">
|
||||
<!-- 왼쪽 영역: 입력 폼 -->
|
||||
<div style="flex: 1.2; display: flex; flex-direction: column; gap: 20px;">
|
||||
|
||||
<!-- 1. 처리 유형 -->
|
||||
<div>
|
||||
<label style="${labelStyle}">1. 처리 유형 선택</label>
|
||||
<div style="display: flex; gap: 12px;">
|
||||
<label class="flow-type-label active" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||
<input type="radio" name="flow-type" value="checkout" checked style="display:none;" />
|
||||
불출 (지급)
|
||||
</label>
|
||||
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||
<input type="radio" name="flow-type" value="return" style="display:none;" />
|
||||
입고 (반납)
|
||||
</label>
|
||||
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
|
||||
<input type="radio" name="flow-type" value="move" style="display:none;" />
|
||||
이동 (이관)
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 2. 대상 사용자 검색 -->
|
||||
<div style="position: relative;">
|
||||
<label id="user-search-label" style="${labelStyle}">2. 대상 사원 검색</label>
|
||||
<div style="position: relative; display: flex; align-items: center;">
|
||||
<input type="text" id="pc-flow-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
|
||||
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||
</div>
|
||||
<div id="pc-flow-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
|
||||
<div id="target-user-search-container" class="hidden" style="position: relative;">
|
||||
<label style="${labelStyle}">새 인수 사원 검색</label>
|
||||
<div style="position: relative; display: flex; align-items: center;">
|
||||
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
|
||||
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||
</div>
|
||||
<div id="pc-flow-target-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 4. 재고 PC 검색 (불출 시 노출) -->
|
||||
<div id="stock-pc-search-container" style="position: relative;">
|
||||
<label style="${labelStyle}">3. 불출할 재고 PC 선택</label>
|
||||
<div style="position: relative; display: flex; align-items: center;">
|
||||
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." style="${inputWithIconStyle}" />
|
||||
<i data-lucide="monitor" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
|
||||
</div>
|
||||
<div id="pc-flow-stock-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
|
||||
</div>
|
||||
|
||||
<!-- 5. 상세 공통 입력 -->
|
||||
<div style="display: flex; gap: 16px;">
|
||||
<div style="flex: 1;">
|
||||
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">처리 일자</label>
|
||||
<input type="date" id="pc-flow-date" style="${inputStyle}" />
|
||||
</div>
|
||||
<div style="flex: 2;">
|
||||
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">상세 사유</label>
|
||||
<textarea id="pc-flow-details" rows="2" placeholder="미입력 시 기본 문구로 자동 입력됩니다." style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; font-family: inherit; font-size: 13px; resize: none; box-sizing: border-box; outline: none;"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- 오른쪽 영역: 선택 요약 & 사원 소유 자산 목록 -->
|
||||
<div style="flex: 0.8; border-left: 1px solid var(--border-color); padding-left: 24px; display: flex; flex-direction: column; gap: 16px;">
|
||||
<h3 style="margin: 0; font-size: 14px; font-weight: 800; border-bottom: 1px solid var(--border-color); padding-bottom: 8px;">선택 내역 요약</h3>
|
||||
|
||||
<!-- 사원 요약 카드 -->
|
||||
<div id="summary-user-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="font-size: 11px; color: var(--text-muted);">대상 사원</div>
|
||||
<div id="summary-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
|
||||
<div id="summary-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||
</div>
|
||||
|
||||
<!-- 인수 사원 요약 카드 (이동 전용) -->
|
||||
<div id="summary-target-user-card" class="summary-card hidden" style="padding: 12px; background: #EEF2F6; border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="font-size: 11px; color: var(--text-muted);">새 인수 사원</div>
|
||||
<div id="summary-target-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
|
||||
<div id="summary-target-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||
</div>
|
||||
|
||||
<!-- 대상 PC 자산 요약 카드 -->
|
||||
<div id="summary-pc-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
|
||||
<div style="font-size: 11px; color: var(--text-muted);">대상 PC 자산</div>
|
||||
<div id="summary-pc-code" style="font-weight: 700; font-size: 14px; color: var(--primary-color);">선택된 PC 없음</div>
|
||||
<div id="summary-pc-model" style="font-size: 12px; color: var(--text-muted);">-</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 보유 PC 목록 선택 (반납/이동 시) -->
|
||||
<div id="user-pcs-container" class="hidden" style="display: flex; flex-direction: column; gap: 8px;">
|
||||
<div style="font-size: 12px; font-weight: 700; color: var(--text-muted);">사원 보유 PC 선택 (클릭하여 매핑)</div>
|
||||
<div id="user-pcs-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 200px; overflow-y: auto;"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" style="padding: 16px 24px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 12px; background: var(--bg-light);">
|
||||
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline" style="height: 42px;">취소</button>
|
||||
<button id="btn-submit-pc-flow" class="btn btn-primary" style="height: 42px;">이동/반납 처리 완료</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.flow-type-label {
|
||||
transition: all 0.2s;
|
||||
border-color: var(--border-color);
|
||||
background: white;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.flow-type-label:hover {
|
||||
border-color: var(--primary-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.flow-type-label.active {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-light);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.suggestion-item:hover {
|
||||
background-color: var(--primary-light) !important;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export const pcFlowModal = PCFlowModal.getInstance();
|
||||
166
src/components/Modal/PartsMasterModal.ts
Normal file
166
src/components/Modal/PartsMasterModal.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { state, savePartsMaster, deletePartsMaster } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
|
||||
import { createIcons, X, Save, Database, Edit2, Plus } from 'lucide';
|
||||
import { UI_TEXT } from '../../core/schema';
|
||||
|
||||
class PartsMasterModal extends BaseModal {
|
||||
constructor() {
|
||||
super('parts-master', '부품 표준 정보');
|
||||
}
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
|
||||
const inputStyle = sharedStyle;
|
||||
const selectStyle = sharedStyle;
|
||||
|
||||
return `
|
||||
<div id="parts-master-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content" style="max-width: 500px; width: 100%;">
|
||||
<div class="modal-header">
|
||||
<h2 id="parts-master-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">${this.title}</h2>
|
||||
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
|
||||
<form id="parts-master-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<input type="hidden" id="parts-master-id" name="id" />
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 분류</label>
|
||||
<select id="parts-master-category" name="category" style="${selectStyle}">
|
||||
<option value="CPU">CPU</option>
|
||||
<option value="GPU">GPU</option>
|
||||
<option value="RAM">RAM</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 표준 명칭</label>
|
||||
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required style="${inputStyle} width: 100%;" />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">성능 등급</label>
|
||||
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required style="${inputStyle} width: 100%;" />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">감점 점수 (양수로 입력)</label>
|
||||
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required style="${inputStyle} width: 100%;" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer" style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #f8fafc; border-top: 1px solid var(--border-color);">
|
||||
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
|
||||
<div class="footer-actions" style="display: flex; gap: 8px;">
|
||||
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
|
||||
<button id="btn-cancel-parts-master-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
|
||||
<button id="btn-save-parts-master-asset" class="btn btn-primary" style="height: 42px;">수정</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(this.currentAsset.id)) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
const titleEl = document.getElementById('parts-master-modal-title');
|
||||
|
||||
if (titleEl) {
|
||||
if (mode === 'add') {
|
||||
titleEl.textContent = '신규 부품 마스터 등록';
|
||||
} else {
|
||||
titleEl.textContent = '부품 마스터 상세 편집';
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
saveBtn.textContent = '등록';
|
||||
saveBtn.style.display = 'block';
|
||||
} else {
|
||||
this.setEditLockMode('view');
|
||||
this.isEditMode = false;
|
||||
saveBtn.textContent = '수정';
|
||||
saveBtn.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
*/
|
||||
|
||||
// 구매법인 목록
|
||||
export const CORP_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '바론'];
|
||||
export const CORP_LIST = ['한맥', '삼안', 'PTC', '바론'];
|
||||
|
||||
// 사용조직 목록
|
||||
export const ORG_LIST = ['한맥', '삼안', '장헌', '한라', 'PTC', '기술개발센터', '총괄기획실'];
|
||||
@@ -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']
|
||||
@@ -60,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',
|
||||
|
||||
171
src/components/Modal/UserModal.ts
Normal file
171
src/components/Modal/UserModal.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
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 {
|
||||
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
|
||||
const inputStyle = sharedStyle;
|
||||
|
||||
return `
|
||||
<div id="user-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content" style="max-width: 500px; width: 100%;">
|
||||
<div class="modal-header">
|
||||
<h2 id="user-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">\${this.title}</h2>
|
||||
<button id="btn-close-user-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
|
||||
<form id="user-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
|
||||
<input type="hidden" id="user-id" name="id" />
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사번</label>
|
||||
<input type="text" id="user-emp-no" name="emp_no" placeholder="예: HM202601" required style="\${inputStyle} width: 100%;" />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용자명</label>
|
||||
<input type="text" id="user-name-input" name="user_name" placeholder="예: 홍길동" required style="\${inputStyle} width: 100%;" />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">사용조직 (부서)</label>
|
||||
<input type="text" id="user-dept" name="dept_name" placeholder="예: 기술개발센터" required style="\${inputStyle} width: 100%;" />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">직무 (직급)</label>
|
||||
<input type="text" id="user-position-input" name="position" placeholder="예: BIM모델러" required style="\${inputStyle} width: 100%;" />
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">상태</label>
|
||||
<select id="user-status" name="status" style="\${sharedStyle}">
|
||||
<option value="재직">재직</option>
|
||||
<option value="퇴직">퇴직</option>
|
||||
</select>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer" style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #f8fafc; border-top: 1px solid var(--border-color);">
|
||||
<button id="btn-delete-user-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
|
||||
<div class="footer-actions" style="display: flex; gap: 8px;">
|
||||
<button id="btn-revert-user-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
|
||||
<button id="btn-cancel-user-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
|
||||
<button id="btn-save-user-asset" class="btn btn-primary" style="height: 42px;">수정</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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 || '재직');
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
const titleEl = document.getElementById('user-modal-title');
|
||||
|
||||
if (titleEl) {
|
||||
if (mode === 'add') {
|
||||
titleEl.textContent = '신규 임직원 등록';
|
||||
} else {
|
||||
titleEl.textContent = '임직원 정보 수정';
|
||||
}
|
||||
}
|
||||
|
||||
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') {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
saveBtn.textContent = '등록';
|
||||
saveBtn.style.display = 'block';
|
||||
} else {
|
||||
this.setEditLockMode('view');
|
||||
this.isEditMode = false;
|
||||
saveBtn.textContent = '수정';
|
||||
saveBtn.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { state } from '../core/state';
|
||||
const MENU_CONFIG: any = {
|
||||
hw: {
|
||||
label: '하드웨어',
|
||||
tabs: ['서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비']
|
||||
tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '부품 마스터', '네트워크', '업무지원장비']
|
||||
},
|
||||
sw: {
|
||||
label: '소프트웨어',
|
||||
@@ -11,7 +11,7 @@ const MENU_CONFIG: any = {
|
||||
},
|
||||
ops: {
|
||||
label: '운영지원',
|
||||
tabs: ['클라우드', '도메인', '비용관리']
|
||||
tabs: ['클라우드', '도메인', '비용관리', '사용자']
|
||||
},
|
||||
vip: {
|
||||
label: '내빈/외빈',
|
||||
@@ -32,6 +32,23 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
// 기존 메뉴 렌더링
|
||||
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
|
||||
const config = MENU_CONFIG[catKey];
|
||||
|
||||
// 역할에 따라 노출할 서브탭 필터링
|
||||
const visibleTabs = config.tabs.filter((tab: string) => {
|
||||
if (state.currentUserRole === 'admin') {
|
||||
// 관리자(admin)일 경우 대시보드 탭만 노출
|
||||
return tab === '대시보드';
|
||||
} else {
|
||||
// 실무자(user)일 경우 대시보드 제외한 모든 탭 노출
|
||||
return tab !== '대시보드';
|
||||
}
|
||||
});
|
||||
|
||||
// 노출할 서브탭이 없으면 해당 대분류 GNB 메뉴도 렌더링하지 않음
|
||||
if (visibleTabs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isActive = state.activeCategory === catKey;
|
||||
|
||||
const group = document.createElement('div');
|
||||
@@ -40,11 +57,11 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
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];
|
||||
const firstTab = visibleTabs[0] || config.tabs[0];
|
||||
state.activeSubTab = firstTab;
|
||||
render();
|
||||
onTabChange(firstTab);
|
||||
@@ -55,7 +72,8 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
const shelf = document.createElement('div');
|
||||
shelf.className = 'lnb-shelf';
|
||||
|
||||
config.tabs.forEach((tab: string) => {
|
||||
visibleTabs.forEach((tab: string) => {
|
||||
if (tab === '부품 마스터') return; // 메뉴바에서 표시 생략
|
||||
const item = document.createElement('div');
|
||||
item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`;
|
||||
item.textContent = tab;
|
||||
@@ -73,20 +91,26 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
navContainer.appendChild(group);
|
||||
});
|
||||
|
||||
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일) ───
|
||||
const adminGroup = document.createElement('div');
|
||||
adminGroup.className = 'nav-group';
|
||||
|
||||
const adminTrigger = document.createElement('div');
|
||||
adminTrigger.className = 'gnb-trigger admin-trigger';
|
||||
adminTrigger.innerHTML = '관리자';
|
||||
|
||||
adminTrigger.addEventListener('click', () => {
|
||||
window.open('/map_editor.html', '_blank');
|
||||
});
|
||||
|
||||
adminGroup.appendChild(adminTrigger);
|
||||
navContainer.appendChild(adminGroup);
|
||||
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일 - 관리자 역할일 때만 노출) ───
|
||||
if (state.currentUserRole === 'admin') {
|
||||
const adminGroup = document.createElement('div');
|
||||
adminGroup.className = 'nav-group';
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
adminGroup.appendChild(adminTrigger);
|
||||
navContainer.appendChild(adminGroup);
|
||||
}
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
10695
src/core/dummyData.ts
Normal file
10695
src/core/dummyData.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -27,10 +27,15 @@ export interface SWUser {
|
||||
|
||||
export interface HardwareLog {
|
||||
id: string;
|
||||
assetId: string;
|
||||
date: string;
|
||||
assetId?: string;
|
||||
asset_id?: string;
|
||||
date?: string;
|
||||
log_date?: string;
|
||||
created_at?: string;
|
||||
details: string;
|
||||
user: string;
|
||||
user?: string;
|
||||
log_user?: string;
|
||||
event_type?: string;
|
||||
}
|
||||
|
||||
export interface MasterAssetData {
|
||||
|
||||
@@ -15,17 +15,30 @@ export interface FilterOptions {
|
||||
showLoc?: boolean;
|
||||
showField?: boolean;
|
||||
showType?: boolean;
|
||||
showStatus?: boolean;
|
||||
extraHTML?: string;
|
||||
onFilterChange: (filters: any) => void;
|
||||
initialFilters?: any;
|
||||
}
|
||||
|
||||
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: '' }
|
||||
} = options;
|
||||
|
||||
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">
|
||||
@@ -34,21 +47,28 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
||||
<option value="">전체 유형</option>
|
||||
</select>
|
||||
</div>` : ''}
|
||||
${showStatus ? `
|
||||
<div class="search-item">
|
||||
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
|
||||
<select id="filter-status">
|
||||
<option value="">전체 상태</option>
|
||||
</select>
|
||||
</div>` : ''}
|
||||
${showField ? `
|
||||
<div class="search-item">
|
||||
<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">
|
||||
@@ -75,7 +95,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 +107,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 +131,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: '자산위치' },
|
||||
@@ -154,6 +155,11 @@ export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: str
|
||||
title: '사무용 가구 관리',
|
||||
description: '책상, 의자, 캐비닛 등 사무 환경 구성을 위한 가구 자산의 배치 현황을 관리합니다.',
|
||||
icon: 'armchair'
|
||||
},
|
||||
'사용자': {
|
||||
title: '임직원 사용자 관리',
|
||||
description: 'IT 자산 할당 및 관리의 기준이 되는 사내 임직원(사용자) 정보를 데이터베이스 기반으로 직접 등록하고 수정합니다.',
|
||||
icon: 'users'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { HardwareAsset, SoftwareAsset, SWUser, HardwareLog } from './excelHandler';
|
||||
import { API_BASE_URL } from './utils';
|
||||
import { dummyPCs, dummyServers, dummyStorages, dummyEquips, dummySubSw, dummyPermSw, dummyCloud, dummyDomain, dummySwUsers, dummyLogs } from './dummyData';
|
||||
|
||||
// --- State Definitions ---
|
||||
export interface MasterAssetData {
|
||||
@@ -10,6 +11,7 @@ export interface MasterAssetData {
|
||||
network: any[];
|
||||
survey: any[];
|
||||
pcParts: any[];
|
||||
partsMaster: any[];
|
||||
equipment: any[];
|
||||
officeSupplies: any[];
|
||||
swInternal: any[];
|
||||
@@ -40,19 +42,21 @@ export interface AppState {
|
||||
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: [],
|
||||
@@ -74,9 +78,22 @@ export async function loadMasterDataFromDB() {
|
||||
// 전역 상태 업데이트
|
||||
state.masterData = {
|
||||
...state.masterData,
|
||||
...data
|
||||
...data,
|
||||
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.hw = [
|
||||
...state.masterData.pc,
|
||||
@@ -98,7 +115,7 @@ export async function loadMasterDataFromDB() {
|
||||
console.log('✅ V2 Normalized data loaded successfully');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('⚠️ 서버 연결 실패:', err);
|
||||
console.warn('⚠️ Dummy 로드 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -146,3 +163,69 @@ 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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -158,3 +158,201 @@ export function dynamicSort<T>(list: T[], key: string, direction: 'asc' | 'desc'
|
||||
export function getActionButtonsHTML(): string {
|
||||
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): { 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' };
|
||||
return { name: '보급', class: 'b-yellow', color: '#F59E0B' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
||||
198
src/main.ts
198
src/main.ts
@@ -8,46 +8,24 @@ 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 { initUserModal, openUserModal } from './components/Modal/UserModal';
|
||||
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() {
|
||||
const mainContent = document.getElementById('main-content')!;
|
||||
if (!mainContent) return;
|
||||
|
||||
if (state.activeSubTab === '대시보드') {
|
||||
renderDashboard(mainContent);
|
||||
return;
|
||||
}
|
||||
|
||||
// 서버 탭이 아닐 경우 '자산현황(위치)' 뷰 진입 방지 및 강제 리스트 모드 전환
|
||||
if (state.activeSubTab !== '서버' && state.viewMode === 'location') {
|
||||
state.viewMode = 'list';
|
||||
@@ -82,13 +60,8 @@ function refreshView() {
|
||||
}
|
||||
}
|
||||
|
||||
// 통합 저장 및 갱신
|
||||
async function saveAllDataToDB() {
|
||||
await Promise.all([
|
||||
savePcToDB(), saveServerToDB(), saveStorageToDB(), saveNetworkToDB(),
|
||||
saveEquipToDB(), saveSwInternalToDB(), saveSwExternalToDB(),
|
||||
saveCloudToDB(), saveSwUsersToDB(), saveLogsToDB(), saveUsersToDB()
|
||||
]);
|
||||
// 통합 갱신 (저장은 이미 개별 모달에서 처리됨)
|
||||
async function refreshAllData() {
|
||||
await loadMasterDataFromDB();
|
||||
refreshView();
|
||||
}
|
||||
@@ -105,17 +78,20 @@ function initApp() {
|
||||
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);
|
||||
initUserModal(() => refreshAllData(), closeAllModals);
|
||||
|
||||
initDashboardDetailModal();
|
||||
initGuide();
|
||||
pcFlowModal.init(() => {
|
||||
loadMasterDataFromDB().then(() => refreshView());
|
||||
});
|
||||
|
||||
loadMasterDataFromDB().then((success) => {
|
||||
if (success) {
|
||||
@@ -136,22 +112,36 @@ 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 === '부품 마스터') {
|
||||
openPartsMasterModal({ id: '' } as any, 'add');
|
||||
} else {
|
||||
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
|
||||
}
|
||||
} else if (cat === '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({
|
||||
@@ -171,66 +161,80 @@ function initRoleSwitcher() {
|
||||
if (!checkbox || !userLabel || !adminLabel) return;
|
||||
|
||||
checkbox.addEventListener('change', () => {
|
||||
const mainContent = document.getElementById('main-content')!;
|
||||
if (checkbox.checked) {
|
||||
alert('관리자 모드는 현재 준비 중입니다. 나중에 관리자 전용 페이지와 연결될 예정입니다.');
|
||||
checkbox.checked = false; // UI 강제 되돌리기
|
||||
return;
|
||||
state.currentUserRole = 'admin';
|
||||
userLabel.classList.remove('active');
|
||||
adminLabel.classList.add('active');
|
||||
document.body.classList.add('admin-mode');
|
||||
|
||||
// 관리자 모드 전환 시 대시보드로 이동
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '대시보드';
|
||||
refreshView();
|
||||
renderNavigation((tab) => {
|
||||
if (tab === '대시보드') {
|
||||
renderDashboard(mainContent);
|
||||
} else {
|
||||
renderSWTable(mainContent);
|
||||
}
|
||||
});
|
||||
} 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((tab) => {
|
||||
if (tab === '대시보드') {
|
||||
renderDashboard(mainContent);
|
||||
} else {
|
||||
renderSWTable(mainContent);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 실무자 모드 전환 (현재는 Admin 진입이 차단되므로 사실상 fallback 로직)
|
||||
state.currentUserRole = 'user';
|
||||
adminLabel.classList.remove('active');
|
||||
userLabel.classList.add('active');
|
||||
document.body.classList.remove('admin-mode');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 처리 로직
|
||||
* 앱 초기화 (로그인 과정 없이 즉시 시작)
|
||||
*/
|
||||
function handleLogin() {
|
||||
function initializeAppDirectly() {
|
||||
const loginContainer = document.getElementById('login-container');
|
||||
const appLayout = document.getElementById('app-layout');
|
||||
const roleCards = document.querySelectorAll('.role-card');
|
||||
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
||||
const userLabel = document.querySelector('.role-label.user');
|
||||
const adminLabel = document.querySelector('.role-label.admin');
|
||||
|
||||
if (!loginContainer || !appLayout || roleCards.length === 0) return;
|
||||
// 기본 권한 설정: 실무자 (User)
|
||||
state.currentUserRole = 'user';
|
||||
state.activeCategory = 'hw';
|
||||
state.activeSubTab = '서버'; // 실무자 기본 탭
|
||||
|
||||
roleCards.forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const role = card.getAttribute('data-role');
|
||||
|
||||
if (role === 'admin') {
|
||||
alert('관리자 모드는 현재 준비 중입니다. 실무자 모드를 이용해 주세요.');
|
||||
return;
|
||||
}
|
||||
// UI 상태 동기화
|
||||
if (checkbox) checkbox.checked = false;
|
||||
if (userLabel) userLabel.classList.add('active');
|
||||
if (adminLabel) adminLabel.classList.remove('active');
|
||||
document.body.classList.remove('admin-mode');
|
||||
|
||||
if (role === 'user') {
|
||||
console.log('🔓 Entering as Practitioner');
|
||||
|
||||
// 초기 토글 상태 설정 (실무자 고정)
|
||||
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
||||
if (checkbox) checkbox.checked = false;
|
||||
state.currentUserRole = 'user';
|
||||
// 화면 전환
|
||||
if (loginContainer) loginContainer.style.display = 'none';
|
||||
if (appLayout) appLayout.style.display = 'flex';
|
||||
|
||||
// UI 전환
|
||||
loginContainer.style.display = 'none';
|
||||
appLayout.style.display = 'flex';
|
||||
|
||||
// 역할 스위처 및 앱 초기화 시작
|
||||
initRoleSwitcher();
|
||||
initApp();
|
||||
|
||||
// 로고 클릭 시 초기화면 복귀 로직 (한 번만 등록)
|
||||
const brand = document.querySelector('.brand') as HTMLElement;
|
||||
if (brand) {
|
||||
brand.style.cursor = 'pointer';
|
||||
brand.onclick = () => {
|
||||
location.reload(); // 즉시 초기화면으로 복귀
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
// 앱 초기화
|
||||
initRoleSwitcher();
|
||||
initApp();
|
||||
|
||||
// 로고 클릭 시 새로고침 (초기 화면 복귀 효과)
|
||||
const brand = document.querySelector('.brand') as HTMLElement;
|
||||
if (brand) {
|
||||
brand.style.cursor = 'pointer';
|
||||
brand.onclick = () => location.reload();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', handleLogin);
|
||||
document.addEventListener('DOMContentLoaded', initializeAppDirectly);
|
||||
|
||||
@@ -177,17 +177,126 @@ input:checked + .slider:before { transform: translateX(16px); }
|
||||
.icon-btn:hover { background-color: var(--primary-light); border-color: var(--primary-color); color: var(--primary-color); }
|
||||
|
||||
/* --- Footer --- */
|
||||
.main-footer { height: 40px; background-color: var(--white); border-top: 1px solid var(--border-color); display: flex; align-items: center; justify-content: flex-end; padding: 0 1.5rem; flex-shrink: 0; }
|
||||
.main-footer p { font-size: 0.75rem; color: var(--text-muted); }
|
||||
.main-footer {
|
||||
height: 28px;
|
||||
background-color: var(--white);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
padding: 0 1.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* --- Utility --- */
|
||||
.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: #777777;
|
||||
user-select: none;
|
||||
pointer-events: all;
|
||||
-webkit-user-drag: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.text-nowrap {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* --- Utility Styles --- */
|
||||
.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; }
|
||||
.btn-primary { background-color: var(--primary-color); color: var(--white); border: none; }
|
||||
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
|
||||
.badge { padding: 2px 6px; border-radius: 4px; font-size: 11px; font-weight: 700; }
|
||||
.badge-primary { background-color: var(--primary-color); color: white; }
|
||||
.badge-light { background: var(--bg-color); color: var(--text-muted); border: 1px solid var(--border-color); }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-muted {
|
||||
background-color: #9CA3AF;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-light {
|
||||
background: var(--bg-color);
|
||||
color: var(--text-muted);
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* PC 성능 등급 뱃지 컬러 스타일 */
|
||||
.badge.b-purple {
|
||||
background-color: #EDE9FE;
|
||||
color: #7C3AED;
|
||||
border: 1px solid #DDD6FE;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.badge.b-primary {
|
||||
background-color: #DBEAFE;
|
||||
color: #1D4ED8;
|
||||
border: 1px solid #BFDBFE;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.badge.b-green {
|
||||
background-color: #D1FAE5;
|
||||
color: #047857;
|
||||
border: 1px solid #A7F3D0;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.badge.b-yellow {
|
||||
background-color: #FEF3C7;
|
||||
color: #D97706;
|
||||
border: 1px solid #FDE68A;
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
|
||||
.text-tag {
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* --- 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; }
|
||||
}
|
||||
|
||||
@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; }
|
||||
}
|
||||
|
||||
.gnb-trigger.admin-trigger {
|
||||
color: var(--text-muted);
|
||||
|
||||
@@ -1,39 +1,41 @@
|
||||
/* --- Dashboard View Specific Styles --- */
|
||||
|
||||
/* --- Premium Executive Dashboard View Specific Styles --- */
|
||||
.dashboard-section-title {
|
||||
padding: 0 0 1rem 0;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 700;
|
||||
padding: 0 0 0 8px;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.02em;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
margin-bottom: 1rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.dashboard-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 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;
|
||||
/* Premium Executive Divider-based Style (Line-based Division) */
|
||||
.dashboard-card, .stat-card {
|
||||
background: transparent;
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
box-shadow: none;
|
||||
border-radius: 0;
|
||||
padding: 1.5rem 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.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-card:hover, .stat-card:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.dashboard-layout-2col {
|
||||
@@ -42,14 +44,14 @@
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.dashboard-layout-3col {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 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;
|
||||
min-height: 380px;
|
||||
}
|
||||
|
||||
.dashboard-stats-grid {
|
||||
@@ -59,93 +61,169 @@
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.dashboard-card-mini {
|
||||
min-height: auto;
|
||||
/* Premium KPI Value Styling */
|
||||
.stat-value {
|
||||
font-size: 2.41rem;
|
||||
font-weight: 800;
|
||||
background: linear-gradient(135deg, #1E5149 0%, #3B82F6 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.dashboard-card h4 {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0.25rem;
|
||||
.stat-value-danger {
|
||||
background: linear-gradient(135deg, #E11D48 0%, #F59E0B 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.dashboard-card .description {
|
||||
font-size: 0.8125rem;
|
||||
.stat-label {
|
||||
font-size: 1.36rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.dashboard-card .big-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.icon-blue { background: rgba(59, 130, 246, 0.1); color: #3B82F6; }
|
||||
.icon-green { background: rgba(30, 81, 73, 0.1); color: #1E5149; }
|
||||
.icon-red { background: rgba(225, 29, 72, 0.1); color: #E11D48; }
|
||||
.icon-yellow { background: rgba(245, 158, 11, 0.1); color: #F59E0B; }
|
||||
|
||||
.dashboard-card .big-value.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.progress-container {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background-color: var(--border-color);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.progress-fill.danger {
|
||||
background-color: var(--danger);
|
||||
}
|
||||
|
||||
.chart-wrapper {
|
||||
height: 250px;
|
||||
}
|
||||
|
||||
.dashboard-table-container {
|
||||
.table-premium {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-table-container table {
|
||||
.table-premium table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.dashboard-table-container th {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
.table-premium th {
|
||||
background: #F8FAFC;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.96rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.dashboard-table-container td {
|
||||
padding: 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: center;
|
||||
.table-premium td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
color: #1E293B;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.badge-type {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-color);
|
||||
.table-premium tr:hover td {
|
||||
background: #F1F5F9;
|
||||
}
|
||||
|
||||
/* --- Slider/Carousel Specific Styles --- */
|
||||
.dashboard-header-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.slider-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.slider-nav-btn {
|
||||
background: white;
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 0.75rem;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
border-radius: 50%;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
color: var(--text-main);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.slider-nav-btn:hover {
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.slider-nav-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.96rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-btns button {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--white);
|
||||
border-radius: 4px;
|
||||
font-size: 0.96rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-btns button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.slider-indicator {
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.41rem;
|
||||
}
|
||||
|
||||
.dashboard-slider-viewport {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.dashboard-slider-track {
|
||||
display: flex;
|
||||
transition: transform 0.5s cubic-bezier(0.25, 0.8, 0.25, 1);
|
||||
width: 400%; /* For 4 pages */
|
||||
}
|
||||
|
||||
.dashboard-slide {
|
||||
width: 25%; /* 100% / 4 pages */
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px; /* Slight padding to avoid cutting off box-shadows */
|
||||
height: calc(100vh - 150px);
|
||||
min-height: 520px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* --- Location View Styles --- */
|
||||
.location-layout {
|
||||
@@ -302,26 +380,6 @@
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-btns button {
|
||||
padding: 0.3rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--white);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.page-btns button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.location-main-content {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
|
||||
.guide-tab {
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
@@ -72,7 +72,7 @@
|
||||
}
|
||||
|
||||
.guide-section h3 {
|
||||
font-size: 1rem;
|
||||
font-size: 1.3rem;
|
||||
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: 18px;
|
||||
color: var(--text-main);
|
||||
line-height: 1.7;
|
||||
margin: 0;
|
||||
@@ -127,7 +127,7 @@
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -138,12 +138,12 @@
|
||||
.flow-step .step-label {
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.flow-step .step-desc {
|
||||
font-size: 12px;
|
||||
font-size: 17px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
@@ -159,7 +159,7 @@
|
||||
.guide-info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.guide-info-table th {
|
||||
@@ -182,7 +182,7 @@
|
||||
background: var(--primary-light);
|
||||
border-left: 4px solid var(--primary-color);
|
||||
padding: 1rem;
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
color: var(--primary-color);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@@ -40,10 +40,9 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-header h2, .modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 800;
|
||||
.modal-header h2 {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
color: var(--white);
|
||||
line-height: 1.2;
|
||||
@@ -99,7 +98,7 @@
|
||||
/* Section Title for Grouping */
|
||||
.form-section-title {
|
||||
grid-column: span 2;
|
||||
font-size: 0.875rem;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
padding: 1.5rem 0 0.5rem 0;
|
||||
@@ -114,7 +113,7 @@
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -127,7 +126,7 @@
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.15rem;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
background-color: var(--white);
|
||||
@@ -210,7 +209,7 @@
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -267,7 +266,7 @@
|
||||
.preview-table th {
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 12px;
|
||||
font-size: 17px;
|
||||
font-weight: 600;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--text-muted);
|
||||
@@ -275,7 +274,7 @@
|
||||
|
||||
.preview-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: var(--text-main);
|
||||
}
|
||||
@@ -310,7 +309,7 @@
|
||||
}
|
||||
|
||||
.history-header h3 {
|
||||
font-size: 0.9375rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -351,16 +350,21 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: 500px;
|
||||
padding-right: 0.5rem;
|
||||
padding-right: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.history-item {
|
||||
position: relative;
|
||||
padding-left: 1.25rem;
|
||||
padding-bottom: 1.5rem;
|
||||
padding-left: 20px;
|
||||
padding-bottom: 20px;
|
||||
border-left: 2px solid var(--border-color);
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.history-item::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -371,39 +375,73 @@
|
||||
border-radius: 50%;
|
||||
background-color: var(--white);
|
||||
border: 2px solid var(--primary-color);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.history-item:last-child {
|
||||
border-left: 2px solid transparent;
|
||||
/* Event Specific Markers */
|
||||
.history-item.evt-dept::before { border-color: #3b82f6; }
|
||||
.history-item.evt-user::before { border-color: #8b5cf6; }
|
||||
.history-item.evt-role::before { border-color: #10b981; }
|
||||
.history-item.evt-status::before { border-color: #f59e0b; }
|
||||
|
||||
.history-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.history-date {
|
||||
font-size: 0.75rem;
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.history-tag {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tag-dept { background: #eff6ff; color: #3b82f6; }
|
||||
.tag-user { background: #f5f3ff; color: #8b5cf6; }
|
||||
.tag-role { background: #ecfdf5; color: #10b981; }
|
||||
.tag-status { background: #fffbeb; color: #f59e0b; }
|
||||
.tag-default { background: #f3f4f6; color: #6b7280; }
|
||||
|
||||
.history-user {
|
||||
font-size: 0.75rem;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 6px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.history-details {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-main);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
background: #f8fafc;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #f1f5f9;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.history-arrow {
|
||||
display: inline-block;
|
||||
margin: 0 4px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.empty-history {
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Dashboard Detail Modal Table Fixed Header */
|
||||
@@ -436,7 +474,7 @@
|
||||
border-bottom: 2px solid var(--border-color);
|
||||
box-shadow: none;
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-main);
|
||||
text-align: left;
|
||||
@@ -446,7 +484,7 @@
|
||||
#dashboard-detail-modal tbody td {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-main);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -464,7 +502,7 @@
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.5;
|
||||
}
|
||||
@@ -663,3 +701,100 @@
|
||||
.location-detail-container select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Dynamic Remote Info Row */
|
||||
.remote-info-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px dashed var(--border-color);
|
||||
}
|
||||
|
||||
.remote-info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.ri-line {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ri-line select,
|
||||
.ri-line input {
|
||||
height: 38px;
|
||||
box-sizing: border-box;
|
||||
font-size: 13px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
background-color: var(--white);
|
||||
color: var(--text-main);
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.ri-line select:disabled,
|
||||
.ri-line input[readonly] {
|
||||
background-color: var(--bg-muted);
|
||||
border-color: transparent;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.ri-line select:focus,
|
||||
.ri-line input:focus {
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(30, 81, 73, 0.1);
|
||||
}
|
||||
|
||||
.ri-type, .ri-tool {
|
||||
width: 110px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ri-val1, .ri-id, .ri-pw {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ri-remove-btn {
|
||||
height: 38px;
|
||||
width: 38px;
|
||||
padding: 0;
|
||||
color: #E11D48;
|
||||
border: 1px solid #E11D48;
|
||||
background: transparent;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.ri-remove-btn:hover {
|
||||
background-color: #FFF1F2;
|
||||
}
|
||||
|
||||
.ri-spacer {
|
||||
width: 46px; /* 38px btn + 8px gap */
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ri-connector {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-left: 1.5px solid #94a3b8;
|
||||
border-bottom: 1.5px solid #94a3b8;
|
||||
margin-top: -24px;
|
||||
margin-left: 12px;
|
||||
border-bottom-left-radius: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ri-cred-line {
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
@@ -15,18 +15,10 @@
|
||||
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;
|
||||
border-left: 4px solid var(--primary-color);
|
||||
padding-left: 8px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,34 +37,34 @@ export function renderSwDashboard(container: HTMLElement) {
|
||||
<h3 class="dashboard-section-title">소프트웨어 라이선스 현황</h3>
|
||||
|
||||
<div class="dashboard-layout-2col" style="margin-bottom: 1.5rem;">
|
||||
<div class="dashboard-card dashboard-card-mini clickable-card" data-action="ext-usage">
|
||||
<h4>외부 소프트웨어 사용율</h4>
|
||||
<div class="description">${extQty}카피 중 ${extUsed}개 할당</div>
|
||||
<div class="big-value">${extPer}%</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-fill" style="width: ${extPer}%;"></div>
|
||||
<div class="dashboard-card" data-action="ext-usage" style="cursor:pointer; min-height:auto;">
|
||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">외부 소프트웨어 사용율</span>
|
||||
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">${extQty}카피 중 ${extUsed}개 할당</div>
|
||||
<div style="font-size: 2.21rem; 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>
|
||||
</div>
|
||||
<div class="dashboard-card dashboard-card-mini clickable-card" data-action="int-usage">
|
||||
<h4>내부 소프트웨어 현황</h4>
|
||||
<div class="description">등록된 내부 솔루션: ${intTotal}개</div>
|
||||
<div class="big-value">${intPer}%</div>
|
||||
<div class="progress-container">
|
||||
<div class="progress-fill" style="width: ${intPer}%;"></div>
|
||||
<div class="dashboard-card" data-action="int-usage" style="cursor:pointer; min-height:auto;">
|
||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 소프트웨어 현황</span>
|
||||
<div style="font-size: 1.02rem; color:var(--text-muted); margin-bottom: 1rem;">등록된 내부 솔루션: ${intTotal}개</div>
|
||||
<div style="font-size: 2.21rem; 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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="dashboard-section-title">2026년 누적 도입 비용 분석</h3>
|
||||
|
||||
<div class="dashboard-layout-2col">
|
||||
<div class="dashboard-card dashboard-card-mini">
|
||||
<h4>외부 SW 누적 비용 (2026)</h4>
|
||||
<div class="big-value">₩ ${extCost2026.toLocaleString()}</div>
|
||||
<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:1.21rem; font-weight:700; color:var(--text-main);">외부 SW 누적 비용 (2026)</span>
|
||||
<div style="font-size: 2.21rem; font-weight:700; color:var(--dash-primary);">₩ ${extCost2026.toLocaleString()}</div>
|
||||
</div>
|
||||
<div class="dashboard-card dashboard-card-mini">
|
||||
<h4>내부 SW 누적 비용 (2026)</h4>
|
||||
<div class="big-value" style="color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
|
||||
<div class="dashboard-card" style="min-height:auto;">
|
||||
<span style="font-size:1.21rem; font-weight:700; color:var(--text-main);">내부 SW 누적 비용 (2026)</span>
|
||||
<div style="font-size: 2.21rem; font-weight:700; color:#3b82f6;">₩ ${intCost2026.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,133 @@ import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
|
||||
import { state } from '../../core/state';
|
||||
import { IMAGE_LOCATIONS } from '../../components/Modal/SharedData';
|
||||
|
||||
declare var Chart: any;
|
||||
let pcFlowChartInstance: any = null;
|
||||
|
||||
// ─── 100점 만점 감점형 성능 점수 계산 (CPU + RAM + GPU + 연식) ───
|
||||
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 interface ColumnDef {
|
||||
header: string;
|
||||
sortKey?: string;
|
||||
@@ -25,6 +152,7 @@ export interface ListViewConfig {
|
||||
showLoc?: boolean;
|
||||
showField?: boolean;
|
||||
showType?: boolean;
|
||||
showStatus?: boolean;
|
||||
};
|
||||
columns: ColumnDef[];
|
||||
onRowClick?: (asset: any) => void;
|
||||
@@ -39,10 +167,19 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
|
||||
const fullList = config.dataSource();
|
||||
let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' };
|
||||
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
|
||||
|
||||
const isServer = config.title === '서버';
|
||||
if (!isServer) {
|
||||
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];
|
||||
|
||||
// 서버 및 PC 탭이 아닐 경우 '자산 현황' 뷰 진입 방지 및 강제 'asset' 모드
|
||||
const isServerOrPc = config.title === '서버' || config.title === 'PC';
|
||||
if (!isServerOrPc) {
|
||||
(state as any).currentViewMode = 'asset';
|
||||
} else if (!(state as any).currentViewMode) {
|
||||
(state as any).currentViewMode = 'system';
|
||||
@@ -51,15 +188,27 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
// 2. 뷰 전환 토글 버튼 생성
|
||||
const toggleWrapper = document.createElement('div');
|
||||
toggleWrapper.className = 'view-toggle-container';
|
||||
|
||||
const showPcFlowBtn = config.title === 'PC';
|
||||
toggleWrapper.innerHTML = `
|
||||
<div class="view-toggle-flex-row">
|
||||
<div class="view-toggle" style="display: ${isServer ? 'flex' : 'none'};">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
|
||||
<div class="view-toggle" style="display: ${isServerOrPc ? 'flex' : 'none'}; gap: 0;">
|
||||
<button class="toggle-btn ${(state as any).currentViewMode === 'system' ? 'active' : ''}" data-mode="system">자산 현황</button>
|
||||
<button class="toggle-btn ${(state as any).currentViewMode === 'asset' ? 'active' : ''}" data-mode="asset">자산 목록</button>
|
||||
</div>
|
||||
<button id="btn-add-asset" class="btn btn-primary btn-add-new">
|
||||
<span class="plus-icon">+</span> 자산 추가
|
||||
</button>
|
||||
<div style="display: flex; gap: 8px;">
|
||||
${showPcFlowBtn ? `
|
||||
<button id="btn-goto-parts-master" style="padding: 6px 14px !important; font-size: 12px !important; font-weight: 700 !important; background: white !important; color: var(--primary-color) !important; border: 1px solid var(--primary-color) !important; border-radius: 4px !important; cursor: pointer !important; display: flex !important; align-items: center !important; justify-content: center !important; gap: 6px !important; width: fit-content !important; min-width: 0 !important; white-space: nowrap !important;">
|
||||
<i data-lucide="settings" style="width: 14px; height: 14px;"></i> 부품 마스터
|
||||
</button>
|
||||
<button id="btn-pc-flow" style="padding: 6px 14px; font-size: 12px; font-weight: 700; background: white; color: var(--primary-color); border: 1px solid var(--primary-color); border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 6px;">
|
||||
PC 이동/반납
|
||||
</button>
|
||||
` : ''}
|
||||
<button id="btn-add-asset" style="padding: 6px 14px; font-size: 12px; font-weight: 700; background: #1E5149; color: white; border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px;">
|
||||
<span style="font-size: 16px; line-height: 1;">+</span> 자산 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(toggleWrapper);
|
||||
@@ -75,7 +224,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
container.appendChild(contentWrapper);
|
||||
|
||||
// --- 내부 상태 ---
|
||||
let selectedLocation: string | null = '기술개발센터';
|
||||
let selectedLocation: string | null = null;
|
||||
let selectedDetailLocation: string | null = null;
|
||||
let dynamicMapConfig: Record<string, any[]> = {};
|
||||
|
||||
@@ -91,8 +240,37 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
const renderSystemStatus = () => {
|
||||
const isPcView = config.title === 'PC';
|
||||
|
||||
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>> };
|
||||
// 실제 보유 자산이 존재하는 위치 목록만 추출 (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 locationCounts: Record<string, number> = {};
|
||||
const pcTypeCounts = { public: 0, server: 0, personal: 0 };
|
||||
|
||||
// 동적 통계 수집 객체 (Hardcoding 제거)
|
||||
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 };
|
||||
@@ -159,38 +337,81 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
<div class="stat-group-item bordered">${generateDetailStatHTML('내부 (테스트) 상세', intStats)}</div>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-main-flex">
|
||||
<div class="list-section">
|
||||
<div class="section-header-flex">
|
||||
<h4 id="list-section-title">자산 현황 목록</h4>
|
||||
<div class="filter-controls">
|
||||
<span class="filter-label">위치:</span>
|
||||
<select id="select-loc" class="filter-select">
|
||||
<div style="display: flex; flex: 1; min-height: 0; border-top: 1px solid var(--border-color);">
|
||||
<!-- 좌측: 자산 현황 목록 (Border-based Separation) -->
|
||||
<div class="list-section" style="flex: 1.3; display: flex; flex-direction: column; min-height: 0; padding: 1rem 1.5rem 0 0; border-right: 1px solid var(--border-color);">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
|
||||
<h4 id="list-section-title" style="font-size: 14px; font-weight: 700; color: var(--text-main); margin:0;">
|
||||
${isPcView ? `🔄 PC 유동 이력 (${new Date().getMonth() + 1}월)` : '자산 현황 목록'}
|
||||
</h4>
|
||||
${!isPcView ? `
|
||||
<div style="display: flex; align-items: center; gap: 8px;">
|
||||
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">위치:</span>
|
||||
<select id="select-loc" style="padding: 2px 8px; font-size: 11px; border-radius: 4px; border: 1px solid var(--border-color); outline: none; background: white; cursor:pointer; font-family: 'Pretendard';">
|
||||
<option value="">전체</option>
|
||||
${Array.from(new Set(fullList.map(a => a[ASSET_SCHEMA.LOCATION.key] || '미지정'))).sort().map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
${validLocations.map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
|
||||
</select>
|
||||
<select id="select-detail-loc" class="filter-select select-detail-loc"></select>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="mini-table-container">
|
||||
<table class="mini-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 80px;">분류</th>
|
||||
<th>용도/자산명</th>
|
||||
<th style="width: 90px;">관리자(정)</th>
|
||||
<th style="width: 90px;">관리자(부)</th>
|
||||
<th style="width: 100px;">상세위치</th>
|
||||
</tr>
|
||||
<div style="flex: 1; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
|
||||
<thead style="position: sticky; top: 0; background: #fff; z-index: 10;">
|
||||
${isPcView ? `
|
||||
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 120px; background: #fff; white-space: nowrap;">일자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff; white-space: nowrap; text-align: center;">담당자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 60px; background: #fff; white-space: nowrap; text-align: center;">구분</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 90px; background: #fff; white-space: nowrap; text-align: center;">사용자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 90px; background: #fff; white-space: nowrap; text-align: center;">인수자</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 160px; background: #fff; white-space: nowrap; text-align: center;">자산번호</th>
|
||||
<th style="padding: 10px 8px; font-weight: 700; border-bottom: 2px solid var(--border-color); background: #fff; white-space: nowrap;">상세</th>
|
||||
</tr>
|
||||
` : `
|
||||
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 80px; text-align:center; background: #fff;">분류</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 130px; background: #fff;">용도/자산명</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(정)</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); text-align:center; width: 90px; background: #fff;">관리자(부)</th>
|
||||
<th style="padding: 10px 0; text-align: center; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 100px; background: #fff;">상세위치</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">
|
||||
<p>목록에서 자산을 선택하면 상세 정보가 표시됩니다.</p>
|
||||
<!-- 우측: 상세 정보 패널 (Box-less, Line-based) -->
|
||||
<div id="system-detail-panel" style="flex: 0.7; display: flex; flex-direction: column; min-height: 0; padding: 1rem 0 0 1.5rem; overflow: hidden;">
|
||||
<div id="detail-empty-state" style="height: 100%; display: flex; flex-direction: column; color: var(--text-muted); text-align: center; overflow-y: auto; box-sizing: border-box; width: 100%; justify-content: ${isPcView ? 'flex-start' : 'center'}; align-items: ${isPcView ? 'stretch' : 'center'};">
|
||||
${isPcView ? `
|
||||
<div style="display: flex; flex-direction: column; min-height: 0; height: 100%; text-align: left;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-shrink: 0;">
|
||||
<h4 style="margin: 0; font-size: 14px; font-weight: 700; color: #E11D48; display: flex; align-items: center; gap: 6px; white-space: nowrap;">
|
||||
⚠️ 사양 주의 장비 현황 (부족/오버스펙)
|
||||
</h4>
|
||||
</div>
|
||||
<div style="flex: 1; overflow-y: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; table-layout: fixed;">
|
||||
<thead style="position: sticky; top: 0; background: #fff; z-index: 10;">
|
||||
<tr style="text-align: left; font-size: 11px; color: var(--text-muted);">
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 65px; background: #fff; white-space: nowrap;">사용자</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 115px; background: #fff; white-space: nowrap;">부서 (직무)</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); width: 65px; background: #fff; white-space: nowrap; text-align: center;">상태</th>
|
||||
<th style="padding: 10px 0; font-weight: 700; border-bottom: 2px solid var(--border-color); background: #fff; white-space: nowrap;">자산코드</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="spec-mismatch-tbody" style="font-size: 12px;">
|
||||
<tr><td colspan="4" style="text-align:center; padding:1.5rem; color:#94A3B8;">사양 주의 자산이 없습니다.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
` : `
|
||||
<p style="font-size: 1.125rem; font-weight: 500; color: #94A3B8;">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
|
||||
`}
|
||||
</div>
|
||||
<div id="detail-content" class="detail-content hidden">
|
||||
<div class="detail-summary-header">
|
||||
@@ -199,15 +420,26 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
<div class="summary-item"><label>유형</label><div id="detail-asset-type" class="type-value"></div></div>
|
||||
<div class="summary-item flex-1"><label>메모 요약</label><div id="detail-memo" class="memo-value"></div></div>
|
||||
</div>
|
||||
<button id="btn-view-full-detail" class="btn btn-primary">상세 보기</button>
|
||||
<button id="btn-view-flow-logs" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: white; color: var(--primary-color); border: 1px solid var(--primary-color); border-radius: 4px; cursor: pointer; transition: opacity 0.2s; margin-right: 8px;">
|
||||
${isPcView ? '목록 보기' : '이력 보기'}
|
||||
</button>
|
||||
<button id="btn-view-full-detail" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: var(--primary-color); color: white; border: none; border-radius: 4px; cursor: pointer; transition: opacity 0.2s;">상세 보기</button>
|
||||
</div>
|
||||
<div class="detail-map-section">
|
||||
<div class="map-label-header"><label>설치 위치 배치도</label></div>
|
||||
<div id="detail-photo-wrapper" class="map-photo-wrapper">
|
||||
<div class="layout-map-container readonly">
|
||||
<img id="detail-photo" src="" class="map-photo" />
|
||||
<div id="detail-marker" class="layout-marker pulse-marker hidden"></div>
|
||||
<div id="detail-overlay-layer" class="digital-overlay-layer"></div>
|
||||
|
||||
<!-- 메인 배치도 영역 -->
|
||||
<div style="flex: 1; display: flex; flex-direction: column; min-height: 0; overflow: hidden;">
|
||||
<div style="margin-bottom: 0.75rem; flex-shrink: 0; display: flex; justify-content: space-between; align-items: center;">
|
||||
<label style="font-size: 11px; font-weight: 700; color: var(--text-main); text-transform: uppercase;">설치 위치 배치도</label>
|
||||
</div>
|
||||
<div id="detail-photo-wrapper" style="width: 100%; flex: 1; overflow: hidden; display: flex; align-items: center; justify-content: center; position: relative; border: 1px solid var(--border-color); background: #f0f0f0;">
|
||||
<div class="layout-map-container readonly" style="position: relative; display: flex; align-items: center; justify-content: center; width: 100%; height: 100%;">
|
||||
<img id="detail-photo" src="" style="display: block; max-width: 100%; max-height: 100%; width: auto; height: auto; object-fit: contain; pointer-events: none;" />
|
||||
<iframe id="detail-html-map" src="" style="display: none; width: 100%; height: 100%; border: none;"></iframe>
|
||||
<div id="detail-marker" class="layout-marker pulse-marker" style="display: none; position: absolute; z-index: 20;"></div>
|
||||
<div id="detail-overlay-layer" class="digital-overlay-layer" style="position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; display: flex; align-items: center; justify-content: center;"></div>
|
||||
</div>
|
||||
<div id="detail-no-photo" style="display: none; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;">
|
||||
<span style="color: #94A3B8; font-size: 13px; font-weight: 500;">등록된 배치도가 없습니다.</span>
|
||||
</div>
|
||||
<div id="detail-no-photo" class="no-photo-state hidden"><span>등록된 배치도가 없습니다.</span></div>
|
||||
</div>
|
||||
@@ -251,79 +483,472 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
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) {
|
||||
photo.src = imgPath;
|
||||
photo.classList.remove('hidden');
|
||||
if (noPhoto) noPhoto.classList.add('hidden');
|
||||
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.classList.remove('hidden');
|
||||
}
|
||||
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 => {
|
||||
const isSelected = b.x === x && b.y === y;
|
||||
return `<rect class="map-seat-obj" x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="0.5" style="fill:${isSelected ? 'rgba(255, 61, 0, 0.4)' : 'rgba(30, 81, 73, 0.02)'}; stroke:${isSelected ? '#FF3D00' : 'rgba(30, 81, 73, 0.15)'};" />`;
|
||||
}).join('')}</g></svg>`;
|
||||
if (isHtmlMap) {
|
||||
// HTML 지도 처리
|
||||
photo.style.display = 'none';
|
||||
if (marker) marker.style.display = 'none';
|
||||
if (overlayLayer) overlayLayer.innerHTML = '';
|
||||
if (htmlMap) {
|
||||
htmlMap.src = `${imgPath}?markerX=${x}&markerY=${y}`;
|
||||
htmlMap.style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
// 일반 이미지 지도 처리
|
||||
if (htmlMap) {
|
||||
htmlMap.src = '';
|
||||
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);
|
||||
};
|
||||
updateMarkerPos();
|
||||
window.addEventListener('resize', updateMarkerPos);
|
||||
};
|
||||
}
|
||||
} else {
|
||||
photo.classList.add('hidden');
|
||||
if (marker) marker.classList.add('hidden');
|
||||
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 flowLogsBtn = document.getElementById('btn-view-flow-logs');
|
||||
if (flowLogsBtn) {
|
||||
flowLogsBtn.onclick = () => {
|
||||
const emptyState = document.getElementById('detail-empty-state');
|
||||
const content = document.getElementById('detail-content');
|
||||
if (emptyState && content) {
|
||||
content.style.display = 'none';
|
||||
emptyState.style.display = 'flex';
|
||||
}
|
||||
const tbody = document.getElementById('system-status-tbody');
|
||||
if (tbody) {
|
||||
tbody.querySelectorAll('.mini-row').forEach(r => {
|
||||
const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
|
||||
(r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
|
||||
});
|
||||
}
|
||||
updateFlowLogsSection();
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const updateTableOnly = () => {
|
||||
let filtered = selectedLocation ? fullList.filter(a => (a[ASSET_SCHEMA.LOCATION.key] || '미지정') === selectedLocation) : fullList;
|
||||
if (selectedDetailLocation) filtered = filtered.filter(a => (a[ASSET_SCHEMA.LOC_DETAIL.key] || '미지정') === selectedDetailLocation);
|
||||
const displayList = (!selectedLocation && !selectedDetailLocation) ? filtered.slice(0, 10) : filtered;
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
const currentMonthNum = now.getMonth() + 1;
|
||||
const currentYearMonth = `${currentYear}-${String(currentMonthNum).padStart(2, '0')}`;
|
||||
|
||||
const tbody = document.getElementById('system-status-tbody');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = displayList.length === 0
|
||||
? `<tr><td colspan="5" class="empty-cell">조회된 자산이 없습니다.</td></tr>`
|
||||
: displayList.map(asset => {
|
||||
const serviceType = asset.service_type || '외부';
|
||||
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
|
||||
const isWarning = serviceType === '외부' && loc !== 'IDC' && loc !== '미지정';
|
||||
return `
|
||||
<tr class="mini-row ${isWarning ? 'warning' : ''}" data-id="${asset.id}">
|
||||
<td><div class="cell-service-type"><span class="fw-800">${serviceType}</span></div></td>
|
||||
<td title="${asset.asset_purpose || ''}">${asset.asset_purpose || '-'}</td>
|
||||
<td>${asset.manager_primary || '-'}</td>
|
||||
<td>${asset.manager_secondary || '-'}</td>
|
||||
<td>${asset.location_detail || '-'}</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 as HTMLElement).getAttribute('data-id'));
|
||||
if (asset) updateDetailPanel(asset);
|
||||
});
|
||||
if (isPcView) {
|
||||
// PC 뷰일 때: 해당월의 PC 유동 이력을 렌더링하고, 클릭 시 해당 자산 상세를 띄움
|
||||
const recentTbody = document.getElementById('system-status-tbody');
|
||||
if (!recentTbody) return;
|
||||
|
||||
const titleEl = document.getElementById('list-section-title');
|
||||
if (titleEl) {
|
||||
titleEl.textContent = `🔄 PC 유동 이력 (${currentMonthNum}월)`;
|
||||
}
|
||||
|
||||
const logs = state.masterData.logs || [];
|
||||
const flowLogs = logs.filter((log: any) => {
|
||||
const details = log.details || '';
|
||||
if (details.trim().startsWith('{')) {
|
||||
try {
|
||||
const info = JSON.parse(details);
|
||||
return info && (info.type === 'checkout' || info.type === 'return' || info.type === 'move');
|
||||
} catch (e) {}
|
||||
}
|
||||
return details.includes('[불출]') || details.includes('[반납]') || details.includes('[입고]') || details.includes('[이동]') || details.includes('[이관]');
|
||||
});
|
||||
|
||||
// 해당월(currentYearMonth)에 발생한 로그만 필터링
|
||||
const monthlyFlowLogs = flowLogs.filter((log: any) => {
|
||||
const logDate = log.log_date || '';
|
||||
return logDate.startsWith(currentYearMonth);
|
||||
});
|
||||
|
||||
if (monthlyFlowLogs.length === 0) {
|
||||
recentTbody.innerHTML = `<tr><td colspan="7" style="text-align:center; padding:1.5rem; color:#94A3B8;">${currentMonthNum}월 유동 이력이 없습니다.</td></tr>`;
|
||||
} else {
|
||||
recentTbody.innerHTML = monthlyFlowLogs.map((log: any) => {
|
||||
const details = log.details || '';
|
||||
|
||||
let typeDisplay = '-';
|
||||
let userDisplay = '-';
|
||||
let targetUserDisplay = '-';
|
||||
let assetCodeDisplay = '-';
|
||||
let memoDisplay = '-';
|
||||
|
||||
try {
|
||||
const info = JSON.parse(details);
|
||||
if (info.type === 'checkout') typeDisplay = 'checkout';
|
||||
else if (info.type === 'return') typeDisplay = 'return';
|
||||
else if (info.type === 'move') typeDisplay = 'move';
|
||||
|
||||
userDisplay = info.user || '-';
|
||||
targetUserDisplay = info.targetUser || '-';
|
||||
assetCodeDisplay = info.assetCode || '-';
|
||||
memoDisplay = info.memo || '-';
|
||||
} catch (e) {
|
||||
// 하위 호환 파싱 (기존 텍스트형 로그)
|
||||
if (details.includes('[불출]')) typeDisplay = 'checkout';
|
||||
else if (details.includes('[반납]') || details.includes('[입고]')) typeDisplay = 'return';
|
||||
else if (details.includes('[이동]') || details.includes('[이관]')) typeDisplay = 'move';
|
||||
|
||||
const codeMatch = details.match(/PC-\d{6}-\d{4}|HW-PC-\d+/i);
|
||||
if (codeMatch) assetCodeDisplay = codeMatch[0];
|
||||
|
||||
if (details.includes('[불출]')) {
|
||||
const match1 = details.match(/\[불출\]\s*([^\s\(]+)\s*사원/);
|
||||
if (match1) userDisplay = match1[1];
|
||||
else {
|
||||
const match2 = details.match(/\[불출\]\s*([a-zA-Z가-힣]+)/);
|
||||
userDisplay = match2 ? match2[1] : '-';
|
||||
}
|
||||
} else if (details.includes('[반납]') || details.includes('[입고]')) {
|
||||
const match1 = details.match(/\[(?:반납|입고)\]\s*([^\s\(]+)\s*사원/);
|
||||
if (match1) userDisplay = match1[1];
|
||||
else {
|
||||
const match2 = details.match(/\[(?:반납|입고)\]\s*([a-zA-Z가-힣]+)/);
|
||||
userDisplay = match2 ? match2[1] : '-';
|
||||
}
|
||||
} else if (details.includes('[이동]') || details.includes('[이관]')) {
|
||||
const prefixWord = details.includes('[이동]') ? '\\[이동\\]' : '\\[이관\\]';
|
||||
const parts = details.split('➡️');
|
||||
if (parts.length === 2) {
|
||||
const fromMatch = parts[0].match(new RegExp(`${prefixWord}\\s*([a-zA-Z가-힣]+)`));
|
||||
const toMatch = parts[1].match(/\s*([a-zA-Z가-힣]+)/);
|
||||
if (fromMatch && toMatch) {
|
||||
userDisplay = fromMatch[1];
|
||||
targetUserDisplay = toMatch[1];
|
||||
}
|
||||
}
|
||||
if (userDisplay === '-') {
|
||||
const match1 = details.match(new RegExp(`${prefixWord}\\s*([^\s\(]+)\\s*사원`));
|
||||
if (match1) {
|
||||
userDisplay = match1[1];
|
||||
} else {
|
||||
const match2 = details.match(new RegExp(`${prefixWord}\\s*([a-zA-Z가-힣0-9_]+)`));
|
||||
userDisplay = match2 ? match2[1] : '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanDetails = details.replace(/^\[(불출|반납|입고|이동|이관)\]\s*/, '');
|
||||
const memoParts = cleanDetails.split(' - ');
|
||||
if (memoParts.length >= 2) {
|
||||
memoDisplay = memoParts[memoParts.length - 1];
|
||||
} else {
|
||||
if (cleanDetails.includes('지급') || cleanDetails.includes('반납') || cleanDetails.includes('이관')) {
|
||||
memoDisplay = '-';
|
||||
} else {
|
||||
memoDisplay = cleanDetails || '-';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 구분 뱃지 생성
|
||||
let badgeHtml = '';
|
||||
if (typeDisplay === 'checkout') {
|
||||
badgeHtml = '<span style="background:#E0F2FE;color:#0369A1;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">불출</span>';
|
||||
} else if (typeDisplay === 'return') {
|
||||
badgeHtml = '<span style="background:#DCFCE7;color:#15803D;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">입고</span>';
|
||||
} else if (typeDisplay === 'move') {
|
||||
badgeHtml = '<span style="background:#FEF3C7;color:#B45309;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">이동</span>';
|
||||
} else {
|
||||
badgeHtml = '<span style="background:#F1F5F9;color:#475569;padding:2px 6px;border-radius:4px;font-size:11px;font-weight:700;">기타</span>';
|
||||
}
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid var(--border-color);">
|
||||
<td style="padding: 10px 8px; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 120px;">${log.log_date || '-'}</td>
|
||||
<td style="padding: 10px 8px; font-weight: 500; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100px; text-align: center;" title="${log.log_user || '시스템'}">${log.log_user || '시스템'}</td>
|
||||
<td style="padding: 10px 8px; white-space: nowrap; text-align: center;">${badgeHtml}</td>
|
||||
<td style="padding: 10px 8px; font-weight: 600; color: #1E293B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90px; text-align: center;" title="${userDisplay}">${userDisplay}</td>
|
||||
<td style="padding: 10px 8px; font-weight: 600; color: #1E293B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 90px; text-align: center;" title="${targetUserDisplay}">${targetUserDisplay}</td>
|
||||
<td style="padding: 10px 8px; font-family: monospace; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px; text-align: center;" title="${assetCodeDisplay}">${assetCodeDisplay}</td>
|
||||
<td style="padding: 10px 8px; color: #475569; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 160px;" title="${memoDisplay}">${memoDisplay}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
} else {
|
||||
// 기존의 자산 현황 목록 갱신
|
||||
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, 10) : 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" style="padding: 3rem; text-align: center; color: var(--text-muted);">조회된 자산이 없습니다.</td></tr>`
|
||||
: finalDisplayList.map(asset => {
|
||||
const purpose = asset[ASSET_SCHEMA.ASSET_PURPOSE.key] || '';
|
||||
const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type';
|
||||
const serviceType = asset[serviceTypeKey] || '외부';
|
||||
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
|
||||
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '';
|
||||
|
||||
const labelColor = serviceType === '내부' ? '#94A3B8' : '#35635C';
|
||||
const managerMain = asset[ASSET_SCHEMA.MANAGER_MAIN.key] || '-';
|
||||
const managerSub = asset[ASSET_SCHEMA.MANAGER_SUB.key] || '-';
|
||||
|
||||
// [경고 로직] 외부 운영인데 서버PC이거나 IDC가 아닌 경우
|
||||
const isLocWarning = serviceType === '외부SW' && loc !== 'IDC';
|
||||
const isTypeWarning = serviceType === '외부SW' && type.toLowerCase().replace(/\s/g, '').includes('서버pc');
|
||||
const isWarning = isLocWarning || isTypeWarning;
|
||||
const warningStyle = isWarning ? 'background-color: #FFF1F2; border-left: 3px solid #E11D48;' : '';
|
||||
|
||||
let warningReason = '';
|
||||
if (isLocWarning && isTypeWarning) warningReason = '위치/형식 부적절';
|
||||
else if (isLocWarning) warningReason = '위치 부적절';
|
||||
else if (isTypeWarning) warningReason = '형식 부적절';
|
||||
|
||||
return `
|
||||
<tr style="border-bottom: 1px solid var(--border-color); cursor: pointer; ${warningStyle}" class="mini-row" data-id="${asset.id}">
|
||||
<td style="padding: 10px 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align:center;">
|
||||
<div style="display:flex; flex-direction:column; align-items:center; gap:2px;">
|
||||
<span style="color: ${isWarning ? '#E11D48' : labelColor}; font-weight: 800; font-size: 12px;">${serviceType}</span>
|
||||
${isWarning ? `<span style="color: #E11D48; font-size: 9px; font-weight: 700; white-space: nowrap;">${warningReason}</span>` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td style="padding: 10px 0; font-weight: 600; color: ${isWarning ? '#991B1B' : 'var(--text-main)'}; font-size: 13px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${purpose}">${purpose || '-'}</td>
|
||||
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerMain}</td>
|
||||
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${managerSub}</td>
|
||||
<td style="padding: 10px 0; text-align: center; color: var(--text-main); font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${asset[ASSET_SCHEMA.LOC_DETAIL.key] || '-'}</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
|
||||
tbody.querySelectorAll('.mini-row').forEach(row => {
|
||||
row.addEventListener('click', () => {
|
||||
tbody.querySelectorAll('.mini-row').forEach(r => {
|
||||
const rIsWarning = (r as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)'; // E11D48
|
||||
(r as HTMLElement).style.backgroundColor = rIsWarning ? '#FFF1F2' : 'transparent';
|
||||
});
|
||||
(row as HTMLElement).style.backgroundColor = '#EBF2F1'; // 선택 하이라이트
|
||||
const id = (row as HTMLElement).getAttribute('data-id');
|
||||
const asset = fullList.find(a => a.id === id);
|
||||
if (asset) updateDetailPanel(asset);
|
||||
});
|
||||
row.addEventListener('mouseenter', () => {
|
||||
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
|
||||
(row as HTMLElement).style.backgroundColor = '#F8FAFA';
|
||||
}
|
||||
});
|
||||
row.addEventListener('mouseleave', () => {
|
||||
const isWarning = (row as HTMLElement).style.borderLeftColor === 'rgb(225, 29, 72)';
|
||||
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
|
||||
(row as HTMLElement).style.backgroundColor = isWarning ? '#FFF1F2' : 'transparent';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
// 기준 대비 사양 부족/오버스펙 분류
|
||||
const criticalPcList: any[] = [];
|
||||
pcs.forEach((pc: any) => {
|
||||
const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||
const score = pc['_pc_score'];
|
||||
const avg = jobScores[job].avg;
|
||||
|
||||
if (avg > 0) {
|
||||
if (score < avg * 0.6) {
|
||||
pc['_spec_status'] = '사양 부족';
|
||||
criticalPcList.push(pc);
|
||||
} else if (score > avg * 1.5) {
|
||||
pc['_spec_status'] = '오버스펙';
|
||||
criticalPcList.push(pc);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 정렬: 직무 평균 대비 사양 부족이 심한 순(비율이 낮은 순)으로 정렬
|
||||
criticalPcList.sort((a: any, b: any) => {
|
||||
const jobA = a[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||
const jobB = b[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
|
||||
const ratioA = jobScores[jobA].avg > 0 ? a['_pc_score'] / jobScores[jobA].avg : 1;
|
||||
const ratioB = jobScores[jobB].avg > 0 ? b['_pc_score'] / jobScores[jobB].avg : 1;
|
||||
return ratioA - ratioB;
|
||||
});
|
||||
|
||||
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}</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 id = (row as HTMLElement).getAttribute('data-id');
|
||||
const asset = fullList.find(a => String(a.id) === String(id));
|
||||
if (asset) {
|
||||
updateDetailPanel(asset);
|
||||
}
|
||||
});
|
||||
row.addEventListener('mouseenter', () => {
|
||||
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
|
||||
(row as HTMLElement).style.backgroundColor = '#F8FAFA';
|
||||
}
|
||||
});
|
||||
row.addEventListener('mouseleave', () => {
|
||||
if ((row as HTMLElement).style.backgroundColor !== 'rgb(235, 242, 241)') {
|
||||
(row as HTMLElement).style.backgroundColor = 'transparent';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
setTimeout(() => {
|
||||
document.getElementById('select-loc')?.addEventListener('change', (e) => { selectedLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly(); });
|
||||
document.getElementById('select-detail-loc')?.addEventListener('change', (e) => { selectedDetailLocation = (e.target as HTMLSelectElement).value || null; updateTableOnly(); });
|
||||
const selectLoc = document.getElementById('select-loc') as HTMLSelectElement;
|
||||
const selectDetailLoc = document.getElementById('select-detail-loc') as HTMLSelectElement;
|
||||
|
||||
selectLoc?.addEventListener('change', (e) => {
|
||||
selectedLocation = (e.target as HTMLSelectElement).value || null;
|
||||
selectedDetailLocation = null;
|
||||
updateTableOnly();
|
||||
updateFlowLogsSection();
|
||||
});
|
||||
selectDetailLoc?.addEventListener('change', (e) => {
|
||||
selectedDetailLocation = (e.target as HTMLSelectElement).value || null;
|
||||
updateTableOnly();
|
||||
updateFlowLogsSection();
|
||||
});
|
||||
updateTableOnly();
|
||||
updateFlowLogsSection();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
@@ -360,13 +985,47 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
|
||||
toggleWrapper.addEventListener('click', (e) => {
|
||||
const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement;
|
||||
if (btn) {
|
||||
toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active'); (state as any).currentViewMode = btn.getAttribute('data-mode');
|
||||
switchView();
|
||||
if (!btn) return;
|
||||
toggleWrapper.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
(state as any).currentViewMode = btn.getAttribute('data-mode') as 'asset' | 'system';
|
||||
switchView();
|
||||
});
|
||||
|
||||
// 필터 바 초기화
|
||||
renderFilterBar(filterBar, {
|
||||
...config.filterOptions,
|
||||
initialFilters: currentFilters,
|
||||
onFilterChange: (filters) => {
|
||||
Object.assign(currentFilters, filters);
|
||||
updateTable();
|
||||
}
|
||||
});
|
||||
|
||||
renderFilterBar(filterBar, { ...config.filterOptions, onFilterChange: (filters) => { currentFilters = { ...currentFilters, ...filters }; updateTable(); } });
|
||||
// 셀렉트 박스 채우기
|
||||
const populateSelect = (selector: string, dataKey: string, initialValue?: string) => {
|
||||
const select = container.querySelector(selector) as HTMLSelectElement;
|
||||
if (select) {
|
||||
const getVal = (a: any) => dataKey === ASSET_SCHEMA.CURRENT_DEPT.key ? (a[dataKey] || a['현사용부서'] || a['현사용조직']) : 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);
|
||||
if (initialValue && String(val) === initialValue) {
|
||||
opt.selected = true;
|
||||
}
|
||||
select.appendChild(opt);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if (config.filterOptions.showLoc) populateSelect('#filter-loc', ASSET_SCHEMA.LOCATION.key, currentFilters.loc);
|
||||
if (config.filterOptions.showDept) populateSelect('#filter-dept', ASSET_SCHEMA.CURRENT_DEPT.key, currentFilters.dept);
|
||||
if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key, currentFilters.corp);
|
||||
if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key, currentFilters.type);
|
||||
if (config.filterOptions.showStatus) populateSelect('#filter-status', ASSET_SCHEMA.HW_STATUS.key, currentFilters.status);
|
||||
|
||||
// 초기 실행
|
||||
switchView();
|
||||
}
|
||||
|
||||
66
src/views/List/PartsMasterListView.ts
Normal file
66
src/views/List/PartsMasterListView.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import { state } from '../../core/state';
|
||||
import { openPartsMasterModal } from '../../components/Modal/PartsMasterModal';
|
||||
import { formatInline } from '../../core/utils';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export function renderPartsMasterList(container: HTMLElement) {
|
||||
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 = 'b-primary';
|
||||
else if (c.category === 'GPU') badgeClass = 'b-purple';
|
||||
else if (c.category === 'RAM') badgeClass = 'b-green';
|
||||
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>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -1,14 +1,102 @@
|
||||
import { state } from '../../core/state';
|
||||
import { openHwModal } from '../../components/Modal/HWModal';
|
||||
import { sortAssets, formatInline } from '../../core/utils';
|
||||
import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade } from '../../core/utils';
|
||||
import { ASSET_SCHEMA } from '../../core/schema';
|
||||
import { createListView } from './ListFactory';
|
||||
|
||||
export function renderPcList(container: HTMLElement) {
|
||||
container.innerHTML = `
|
||||
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 100%; color: var(--text-muted);">
|
||||
<div style="font-size: 1.5rem; font-weight: 600; margin-bottom: 1rem;">PC 관리</div>
|
||||
<p>해당 페이지는 다른 작업자에 의해 개발 중입니다.</p>
|
||||
</div>
|
||||
`;
|
||||
createListView(container, {
|
||||
title: 'PC',
|
||||
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);
|
||||
});
|
||||
return sortAssets(list);
|
||||
},
|
||||
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,
|
||||
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] || '-' },
|
||||
{ header: ASSET_SCHEMA.RAM.ui, sortKey: ASSET_SCHEMA.RAM.key, align: 'center', render: a => a[ASSET_SCHEMA.RAM.key] || '' },
|
||||
{ header: ASSET_SCHEMA.GPU.ui, sortKey: ASSET_SCHEMA.GPU.key, align: 'center', render: a => a[ASSET_SCHEMA.GPU.key] || '-' },
|
||||
{
|
||||
header: 'SSD',
|
||||
align: 'center',
|
||||
width: '8%',
|
||||
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 => {
|
||||
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,
|
||||
sortKey: ASSET_SCHEMA.MAC_ADDR.key,
|
||||
align: 'center',
|
||||
render: a => `<span style="font-family:monospace; font-size:11px;">${a[ASSET_SCHEMA.MAC_ADDR.key] || '-'}</span>`
|
||||
},
|
||||
{
|
||||
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 grade = getPcGrade(score);
|
||||
return `<span class="badge ${grade.class}" title="성능 점수: ${score}점">${grade.name}</span>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
60
src/views/List/UserListView.ts
Normal file
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>`;
|
||||
}
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
@@ -9,11 +9,13 @@ 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, Settings } from 'lucide';
|
||||
|
||||
/**
|
||||
* 자산 목록 테이블 렌더링 통합 허브
|
||||
@@ -36,6 +38,7 @@ 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>`;
|
||||
@@ -50,6 +53,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>`;
|
||||
}
|
||||
@@ -69,7 +73,7 @@ export function renderSWTable(mainContent: HTMLElement) {
|
||||
|
||||
// 전역 아이콘 초기화 (한 번 더 실행하여 누락 방지)
|
||||
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, Settings }
|
||||
});
|
||||
} catch (err: any) {
|
||||
console.error('❌ Error rendering table view:', err);
|
||||
|
||||
71
test_data_generator.js
Normal file
71
test_data_generator.js
Normal file
@@ -0,0 +1,71 @@
|
||||
import mysql from 'mysql2/promise';
|
||||
import dotenv from 'dotenv';
|
||||
import crypto from 'crypto';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
|
||||
|
||||
const CATEGORIES = ['PC', '서버', '노트북', '모니터', '업무지원장비'];
|
||||
const DEPTS = ['기술개발센터', '총괄기획실', '한맥', '삼안', '장헌', '한라'];
|
||||
const USERS = ['홍길동', '김철수', '이영희', '박지성', '손흥민', '봉준호', '싸이'];
|
||||
const STATUSES = ['운영', '재고', '수리', '폐기', '기타'];
|
||||
const CORPS = ['한맥', '삼안', '장헌', '한라', 'PTC', '바론'];
|
||||
|
||||
async function generateTestData() {
|
||||
const connection = await mysql.createConnection({
|
||||
host: DB_HOST,
|
||||
user: DB_USER,
|
||||
password: DB_PASS,
|
||||
database: DB_NAME,
|
||||
port: parseInt(DB_PORT || '3306')
|
||||
});
|
||||
|
||||
console.log('🚀 무작위 테스트 데이터 생성을 시작합니다 (Crypto UUID 방식)...');
|
||||
|
||||
for (let i = 1; i <= 20; i++) {
|
||||
const category = CATEGORIES[Math.floor(Math.random() * CATEGORIES.length)];
|
||||
const dept = DEPTS[Math.floor(Math.random() * DEPTS.length)];
|
||||
const user = USERS[Math.floor(Math.random() * USERS.length)];
|
||||
const status = STATUSES[Math.floor(Math.random() * STATUSES.length)];
|
||||
const corp = CORPS[Math.floor(Math.random() * CORPS.length)];
|
||||
|
||||
// Crypto UUID 생성
|
||||
const id = crypto.randomUUID();
|
||||
const assetCode = `TEST-${Date.now().toString().slice(-6)}-${String(i).padStart(3, '0')}`;
|
||||
|
||||
try {
|
||||
// 1. asset_core 삽입 (id 수동 지정)
|
||||
await connection.query(
|
||||
`INSERT INTO asset_core
|
||||
(id, asset_code, category, asset_type, purchase_corp, current_dept, user_current, purchase_date, service_type)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, assetCode, category, category, corp, dept, user, '2026-06-10', '내부']
|
||||
);
|
||||
|
||||
// 2. asset_spec 삽입
|
||||
await connection.query(
|
||||
`INSERT INTO asset_spec
|
||||
(asset_id, hw_status, model_name, cpu, ram)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[id, status, `${category} Model ${i}`, 'Intel i7', '16GB']
|
||||
);
|
||||
|
||||
// 3. 초기 이력 삽입
|
||||
await connection.query(
|
||||
`INSERT INTO asset_history (asset_id, event_type, details, log_date, log_user)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
[id, 'STATUS_CHANGE', `[최초 등록] 테스트 데이터 생성 (${status})`, '2026-06-10', '시스템']
|
||||
);
|
||||
|
||||
console.log(`✅ 생성 완료: ${assetCode} (${category} / ${dept} / ${user})`);
|
||||
} catch (err) {
|
||||
console.error(`❌ 생성 실패 (${i}):`, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
await connection.end();
|
||||
console.log('\n✨ 20개의 테스트 데이터 생성이 완료되었습니다.');
|
||||
}
|
||||
|
||||
generateTestData().catch(console.error);
|
||||
Reference in New Issue
Block a user