Compare commits
2 Commits
login
...
34d99dc4b6
| Author | SHA1 | Date | |
|---|---|---|---|
| 34d99dc4b6 | |||
| bb859dddfc |
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
@@ -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>
|
||||
@@ -51,6 +51,6 @@
|
||||
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
|
||||
* **Modal (모달 공통 규칙)**:
|
||||
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
|
||||
* **Interaction**: 사용자의 오입력(실수로 바깥을 클릭하여 입력 내용이 날아가는 현상)을 방지하기 위해 **모달 바깥 영역(Overlay) 클릭 시 모달이 닫히지 않도록** 설정합니다. 닫기는 오직 'ESC' 키 또는 명시적인 'X' 및 '닫기' 버튼을 통해서만 가능합니다.
|
||||
* **Interaction**: 사용자의 편의를 위해 `ESC` 키를 누르거나 모달 바깥 영역(Overlay)을 클릭하면 모달이 닫히도록 구현합니다.
|
||||
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.
|
||||
|
||||
|
||||
BIN
SampleData_PC.xlsx
Normal file
BIN
SampleData_SVR.xlsx
Normal file
|
Before Width: | Height: | Size: 10 MiB |
|
Before Width: | Height: | Size: 6.3 MiB |
|
Before Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 3.9 MiB |
|
Before Width: | Height: | Size: 11 MiB |
|
Before Width: | Height: | Size: 6.1 MiB |
|
Before Width: | Height: | Size: 9.5 MiB |
|
Before Width: | Height: | Size: 9.8 MiB |
|
Before Width: | Height: | Size: 8.1 MiB |
|
Before Width: | Height: | Size: 5.8 MiB |
41
index.html
@@ -9,7 +9,6 @@
|
||||
<link rel="stylesheet"
|
||||
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
|
||||
<link rel="stylesheet" href="/src/styles/common.css" />
|
||||
<link rel="stylesheet" href="/src/styles/login.css" />
|
||||
<link rel="stylesheet" href="/src/styles/guide.css" />
|
||||
<link rel="stylesheet" href="/src/styles/modal.css" />
|
||||
<link rel="stylesheet" href="/src/styles/dashboard.css" />
|
||||
@@ -19,37 +18,7 @@
|
||||
</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;">
|
||||
<div class="app-layout">
|
||||
<!-- Single-Line Integrated Header -->
|
||||
<header class="main-header">
|
||||
<div class="header-container" id="nav-container">
|
||||
@@ -64,14 +33,6 @@
|
||||
</nav>
|
||||
|
||||
<div class="header-actions">
|
||||
<div class="role-switcher" id="role-switcher">
|
||||
<span class="role-label user active">실무자</span>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="role-toggle-checkbox">
|
||||
<span class="slider round"></span>
|
||||
</label>
|
||||
<span class="role-label admin">관리자</span>
|
||||
</div>
|
||||
<button id="btn-admin-page" class="hidden"></button> <!-- JS 호환용 숨김 -->
|
||||
<button id="btn-open-guide-header" class="btn btn-outline" title="프로세스 가이드">
|
||||
<i data-lucide="book-open"></i> 가이드
|
||||
|
||||
768
map_config.json
@@ -1,768 +0,0 @@
|
||||
{
|
||||
"img/location_photo/IDC/서관205.png": [
|
||||
{
|
||||
"x": "50.78",
|
||||
"y": "1.53",
|
||||
"w": "45.83",
|
||||
"h": "6.10"
|
||||
},
|
||||
{
|
||||
"x": "50.67",
|
||||
"y": "10.35",
|
||||
"w": "45.95",
|
||||
"h": "5.99"
|
||||
},
|
||||
{
|
||||
"x": "50.78",
|
||||
"y": "19.06",
|
||||
"w": "45.83",
|
||||
"h": "6.32"
|
||||
},
|
||||
{
|
||||
"x": "50.67",
|
||||
"y": "27.89",
|
||||
"w": "46.06",
|
||||
"h": "6.32"
|
||||
},
|
||||
{
|
||||
"x": "50.78",
|
||||
"y": "36.71",
|
||||
"w": "45.95",
|
||||
"h": "6.21"
|
||||
},
|
||||
{
|
||||
"x": "50.78",
|
||||
"y": "45.64",
|
||||
"w": "45.83",
|
||||
"h": "6.32"
|
||||
},
|
||||
{
|
||||
"x": "50.67",
|
||||
"y": "54.25",
|
||||
"w": "46.06",
|
||||
"h": "6.54"
|
||||
},
|
||||
{
|
||||
"x": "50.90",
|
||||
"y": "63.29",
|
||||
"w": "45.72",
|
||||
"h": "5.99"
|
||||
},
|
||||
{
|
||||
"x": "50.90",
|
||||
"y": "72.00",
|
||||
"w": "45.72",
|
||||
"h": "6.32"
|
||||
},
|
||||
{
|
||||
"x": "50.78",
|
||||
"y": "81.92",
|
||||
"w": "18.40",
|
||||
"h": "15.58"
|
||||
},
|
||||
{
|
||||
"x": "78.67",
|
||||
"y": "82.03",
|
||||
"w": "17.94",
|
||||
"h": "15.25"
|
||||
}
|
||||
],
|
||||
"img/location_photo/IDC/서관202.png": [
|
||||
{
|
||||
"x": "56.35",
|
||||
"y": "64.02",
|
||||
"w": "40.41",
|
||||
"h": "5.89"
|
||||
},
|
||||
{
|
||||
"x": "56.35",
|
||||
"y": "71.57",
|
||||
"w": "40.66",
|
||||
"h": "5.89"
|
||||
},
|
||||
{
|
||||
"x": "56.23",
|
||||
"y": "79.25",
|
||||
"w": "40.53",
|
||||
"h": "5.76"
|
||||
},
|
||||
{
|
||||
"x": "55.98",
|
||||
"y": "86.42",
|
||||
"w": "41.15",
|
||||
"h": "6.27"
|
||||
}
|
||||
],
|
||||
"img/location_photo/IDC/서관203.png": [
|
||||
{
|
||||
"x": "56.07",
|
||||
"y": "2.44",
|
||||
"w": "40.91",
|
||||
"h": "6.40"
|
||||
},
|
||||
{
|
||||
"x": "56.07",
|
||||
"y": "10.12",
|
||||
"w": "40.79",
|
||||
"h": "6.27"
|
||||
},
|
||||
{
|
||||
"x": "55.95",
|
||||
"y": "17.80",
|
||||
"w": "41.04",
|
||||
"h": "6.14"
|
||||
},
|
||||
{
|
||||
"x": "55.95",
|
||||
"y": "63.51",
|
||||
"w": "40.91",
|
||||
"h": "6.14"
|
||||
},
|
||||
{
|
||||
"x": "55.95",
|
||||
"y": "71.19",
|
||||
"w": "41.04",
|
||||
"h": "6.14"
|
||||
},
|
||||
{
|
||||
"x": "56.07",
|
||||
"y": "87.70",
|
||||
"w": "40.91",
|
||||
"h": "6.02"
|
||||
}
|
||||
],
|
||||
"img/location_photo/IDC/서관204.png": [
|
||||
{
|
||||
"x": "48.87",
|
||||
"y": "2.57",
|
||||
"w": "47.40",
|
||||
"h": "6.14"
|
||||
},
|
||||
{
|
||||
"x": "49.01",
|
||||
"y": "10.38",
|
||||
"w": "47.40",
|
||||
"h": "5.89"
|
||||
},
|
||||
{
|
||||
"x": "48.87",
|
||||
"y": "17.93",
|
||||
"w": "47.40",
|
||||
"h": "5.89"
|
||||
},
|
||||
{
|
||||
"x": "48.73",
|
||||
"y": "25.49",
|
||||
"w": "47.69",
|
||||
"h": "6.27"
|
||||
},
|
||||
{
|
||||
"x": "48.87",
|
||||
"y": "33.17",
|
||||
"w": "47.40",
|
||||
"h": "6.02"
|
||||
},
|
||||
{
|
||||
"x": "48.87",
|
||||
"y": "40.59",
|
||||
"w": "47.54",
|
||||
"h": "6.40"
|
||||
},
|
||||
{
|
||||
"x": "48.87",
|
||||
"y": "48.40",
|
||||
"w": "47.54",
|
||||
"h": "6.14"
|
||||
},
|
||||
{
|
||||
"x": "48.73",
|
||||
"y": "55.95",
|
||||
"w": "47.69",
|
||||
"h": "6.14"
|
||||
},
|
||||
{
|
||||
"x": "49.01",
|
||||
"y": "63.63",
|
||||
"w": "47.40",
|
||||
"h": "6.14"
|
||||
},
|
||||
{
|
||||
"x": "48.73",
|
||||
"y": "71.06",
|
||||
"w": "47.54",
|
||||
"h": "6.27"
|
||||
},
|
||||
{
|
||||
"x": "48.87",
|
||||
"y": "78.74",
|
||||
"w": "47.40",
|
||||
"h": "6.27"
|
||||
},
|
||||
{
|
||||
"x": "49.01",
|
||||
"y": "86.68",
|
||||
"w": "18.76",
|
||||
"h": "12.29"
|
||||
}
|
||||
],
|
||||
"img/location_photo/IDC/동관53.png": [
|
||||
{
|
||||
"x": "61.62",
|
||||
"y": "3.08",
|
||||
"w": "35.63",
|
||||
"h": "7.55"
|
||||
},
|
||||
{
|
||||
"x": "61.53",
|
||||
"y": "12.68",
|
||||
"w": "35.80",
|
||||
"h": "7.30"
|
||||
},
|
||||
{
|
||||
"x": "61.70",
|
||||
"y": "21.65",
|
||||
"w": "35.63",
|
||||
"h": "7.68"
|
||||
}
|
||||
],
|
||||
"img/location_photo/IDC/동관54.png": [
|
||||
{
|
||||
"x": "54.71",
|
||||
"y": "2.57",
|
||||
"w": "42.21",
|
||||
"h": "6.27"
|
||||
},
|
||||
{
|
||||
"x": "54.71",
|
||||
"y": "10.38",
|
||||
"w": "42.21",
|
||||
"h": "6.14"
|
||||
},
|
||||
{
|
||||
"x": "54.71",
|
||||
"y": "27.15",
|
||||
"w": "41.97",
|
||||
"h": "6.27"
|
||||
},
|
||||
{
|
||||
"x": "54.71",
|
||||
"y": "43.54",
|
||||
"w": "42.09",
|
||||
"h": "6.02"
|
||||
},
|
||||
{
|
||||
"x": "54.71",
|
||||
"y": "54.93",
|
||||
"w": "42.09",
|
||||
"h": "6.40"
|
||||
},
|
||||
{
|
||||
"x": "54.83",
|
||||
"y": "70.16",
|
||||
"w": "42.09",
|
||||
"h": "6.27"
|
||||
},
|
||||
{
|
||||
"x": "54.71",
|
||||
"y": "79.51",
|
||||
"w": "42.09",
|
||||
"h": "6.14"
|
||||
}
|
||||
],
|
||||
"img/location_photo/기술개발센터/서버실_1.png": [
|
||||
{
|
||||
"x": "69.45",
|
||||
"y": "1.10",
|
||||
"w": "8.58",
|
||||
"h": "11.45"
|
||||
},
|
||||
{
|
||||
"x": "79.21",
|
||||
"y": "1.10",
|
||||
"w": "11.65",
|
||||
"h": "11.45"
|
||||
},
|
||||
{
|
||||
"x": "90.16",
|
||||
"y": "23.23",
|
||||
"w": "8.43",
|
||||
"h": "21.11"
|
||||
},
|
||||
{
|
||||
"x": "52.91",
|
||||
"y": "53.35",
|
||||
"w": "8.66",
|
||||
"h": "21.11"
|
||||
},
|
||||
{
|
||||
"x": "62.36",
|
||||
"y": "53.47",
|
||||
"w": "8.43",
|
||||
"h": "21.11"
|
||||
},
|
||||
{
|
||||
"x": "71.65",
|
||||
"y": "53.47",
|
||||
"w": "8.50",
|
||||
"h": "20.98"
|
||||
},
|
||||
{
|
||||
"x": "80.87",
|
||||
"y": "53.35",
|
||||
"w": "8.35",
|
||||
"h": "21.23"
|
||||
},
|
||||
{
|
||||
"x": "90.08",
|
||||
"y": "53.35",
|
||||
"w": "8.58",
|
||||
"h": "21.11"
|
||||
},
|
||||
{
|
||||
"x": "43.78",
|
||||
"y": "76.38",
|
||||
"w": "8.50",
|
||||
"h": "21.11"
|
||||
},
|
||||
{
|
||||
"x": "53.15",
|
||||
"y": "76.38",
|
||||
"w": "8.43",
|
||||
"h": "21.23"
|
||||
},
|
||||
{
|
||||
"x": "62.44",
|
||||
"y": "76.51",
|
||||
"w": "8.35",
|
||||
"h": "20.98"
|
||||
},
|
||||
{
|
||||
"x": "71.57",
|
||||
"y": "76.25",
|
||||
"w": "8.43",
|
||||
"h": "21.11"
|
||||
},
|
||||
{
|
||||
"x": "81.02",
|
||||
"y": "76.64",
|
||||
"w": "8.27",
|
||||
"h": "20.85"
|
||||
},
|
||||
{
|
||||
"x": "90.24",
|
||||
"y": "76.64",
|
||||
"w": "8.50",
|
||||
"h": "20.98"
|
||||
}
|
||||
],
|
||||
"img/location_photo/기술개발센터/서버실_2.png": [
|
||||
{
|
||||
"x": "49.60",
|
||||
"y": "1.93",
|
||||
"w": "46.96",
|
||||
"h": "6.53"
|
||||
},
|
||||
{
|
||||
"x": "49.34",
|
||||
"y": "11.92",
|
||||
"w": "47.09",
|
||||
"h": "6.66"
|
||||
},
|
||||
{
|
||||
"x": "49.34",
|
||||
"y": "21.39",
|
||||
"w": "47.35",
|
||||
"h": "6.40"
|
||||
},
|
||||
{
|
||||
"x": "49.47",
|
||||
"y": "30.73",
|
||||
"w": "47.22",
|
||||
"h": "6.40"
|
||||
},
|
||||
{
|
||||
"x": "49.34",
|
||||
"y": "39.82",
|
||||
"w": "47.22",
|
||||
"h": "6.53"
|
||||
},
|
||||
{
|
||||
"x": "49.47",
|
||||
"y": "49.68",
|
||||
"w": "47.09",
|
||||
"h": "6.91"
|
||||
},
|
||||
{
|
||||
"x": "49.60",
|
||||
"y": "59.28",
|
||||
"w": "46.82",
|
||||
"h": "6.27"
|
||||
},
|
||||
{
|
||||
"x": "49.34",
|
||||
"y": "68.63",
|
||||
"w": "47.35",
|
||||
"h": "6.40"
|
||||
},
|
||||
{
|
||||
"x": "49.47",
|
||||
"y": "77.84",
|
||||
"w": "46.82",
|
||||
"h": "6.40"
|
||||
},
|
||||
{
|
||||
"x": "49.60",
|
||||
"y": "86.93",
|
||||
"w": "46.82",
|
||||
"h": "6.53"
|
||||
}
|
||||
],
|
||||
"img/location_photo/한맥빌딩/MDF실/MDF_1.png": [
|
||||
{
|
||||
"x": "49.33",
|
||||
"y": "14.99",
|
||||
"w": "7.13",
|
||||
"h": "11.01"
|
||||
},
|
||||
{
|
||||
"x": "59.23",
|
||||
"y": "14.73",
|
||||
"w": "7.13",
|
||||
"h": "11.14"
|
||||
},
|
||||
{
|
||||
"x": "69.22",
|
||||
"y": "14.86",
|
||||
"w": "7.13",
|
||||
"h": "11.14"
|
||||
},
|
||||
{
|
||||
"x": "78.96",
|
||||
"y": "14.99",
|
||||
"w": "7.30",
|
||||
"h": "11.01"
|
||||
},
|
||||
{
|
||||
"x": "89.03",
|
||||
"y": "14.99",
|
||||
"w": "7.05",
|
||||
"h": "11.14"
|
||||
},
|
||||
{
|
||||
"x": "48.57",
|
||||
"y": "34.19",
|
||||
"w": "7.39",
|
||||
"h": "11.14"
|
||||
},
|
||||
{
|
||||
"x": "56.80",
|
||||
"y": "34.06",
|
||||
"w": "7.22",
|
||||
"h": "11.27"
|
||||
},
|
||||
{
|
||||
"x": "64.94",
|
||||
"y": "34.19",
|
||||
"w": "7.30",
|
||||
"h": "11.01"
|
||||
},
|
||||
{
|
||||
"x": "72.83",
|
||||
"y": "34.19",
|
||||
"w": "7.47",
|
||||
"h": "10.88"
|
||||
},
|
||||
{
|
||||
"x": "81.22",
|
||||
"y": "34.06",
|
||||
"w": "7.22",
|
||||
"h": "11.14"
|
||||
},
|
||||
{
|
||||
"x": "89.36",
|
||||
"y": "34.19",
|
||||
"w": "7.13",
|
||||
"h": "11.01"
|
||||
},
|
||||
{
|
||||
"x": "48.66",
|
||||
"y": "53.52",
|
||||
"w": "9.06",
|
||||
"h": "20.99"
|
||||
},
|
||||
{
|
||||
"x": "58.48",
|
||||
"y": "53.27",
|
||||
"w": "9.15",
|
||||
"h": "21.12"
|
||||
},
|
||||
{
|
||||
"x": "68.55",
|
||||
"y": "53.27",
|
||||
"w": "9.06",
|
||||
"h": "21.12"
|
||||
},
|
||||
{
|
||||
"x": "78.54",
|
||||
"y": "53.39",
|
||||
"w": "8.90",
|
||||
"h": "21.25"
|
||||
},
|
||||
{
|
||||
"x": "89.36",
|
||||
"y": "53.27",
|
||||
"w": "7.39",
|
||||
"h": "9.99"
|
||||
},
|
||||
{
|
||||
"x": "89.36",
|
||||
"y": "64.92",
|
||||
"w": "7.39",
|
||||
"h": "9.60"
|
||||
},
|
||||
{
|
||||
"x": "48.57",
|
||||
"y": "77.08",
|
||||
"w": "9.40",
|
||||
"h": "21.38"
|
||||
},
|
||||
{
|
||||
"x": "58.56",
|
||||
"y": "77.20",
|
||||
"w": "9.23",
|
||||
"h": "21.12"
|
||||
},
|
||||
{
|
||||
"x": "68.63",
|
||||
"y": "77.33",
|
||||
"w": "9.06",
|
||||
"h": "21.12"
|
||||
},
|
||||
{
|
||||
"x": "78.71",
|
||||
"y": "77.46",
|
||||
"w": "8.98",
|
||||
"h": "20.99"
|
||||
}
|
||||
],
|
||||
"img/location_photo/한맥빌딩/MDF실/MDF_2.png": [
|
||||
{
|
||||
"x": "56.59",
|
||||
"y": "44.43",
|
||||
"w": "40.35",
|
||||
"h": "6.78"
|
||||
},
|
||||
{
|
||||
"x": "56.71",
|
||||
"y": "54.80",
|
||||
"w": "40.24",
|
||||
"h": "6.53"
|
||||
},
|
||||
{
|
||||
"x": "56.71",
|
||||
"y": "65.94",
|
||||
"w": "40.24",
|
||||
"h": "6.40"
|
||||
}
|
||||
],
|
||||
"img/location_photo/한맥빌딩/MDF실/MDF_3.png": [
|
||||
{
|
||||
"x": "56.71",
|
||||
"y": "13.20",
|
||||
"w": "40.24",
|
||||
"h": "6.78"
|
||||
},
|
||||
{
|
||||
"x": "56.48",
|
||||
"y": "23.57",
|
||||
"w": "40.58",
|
||||
"h": "6.53"
|
||||
},
|
||||
{
|
||||
"x": "56.59",
|
||||
"y": "34.57",
|
||||
"w": "40.58",
|
||||
"h": "6.27"
|
||||
},
|
||||
{
|
||||
"x": "56.59",
|
||||
"y": "44.69",
|
||||
"w": "40.46",
|
||||
"h": "6.66"
|
||||
},
|
||||
{
|
||||
"x": "56.71",
|
||||
"y": "54.80",
|
||||
"w": "40.24",
|
||||
"h": "6.66"
|
||||
},
|
||||
{
|
||||
"x": "56.71",
|
||||
"y": "65.81",
|
||||
"w": "40.24",
|
||||
"h": "6.53"
|
||||
},
|
||||
{
|
||||
"x": "56.59",
|
||||
"y": "76.05",
|
||||
"w": "40.35",
|
||||
"h": "6.53"
|
||||
}
|
||||
],
|
||||
"img/location_photo/한맥빌딩/MDF실/MDF_4.png": [
|
||||
{
|
||||
"x": "52.36",
|
||||
"y": "64.02",
|
||||
"w": "44.38",
|
||||
"h": "6.53"
|
||||
}
|
||||
],
|
||||
"img/location_photo/기술개발센터/서버실/서버실_1.png": [
|
||||
{
|
||||
"x": "69.53",
|
||||
"y": "1.42",
|
||||
"w": "8.58",
|
||||
"h": "11.45"
|
||||
},
|
||||
{
|
||||
"x": "79.21",
|
||||
"y": "1.55",
|
||||
"w": "11.97",
|
||||
"h": "11.32"
|
||||
},
|
||||
{
|
||||
"x": "90.24",
|
||||
"y": "23.30",
|
||||
"w": "8.50",
|
||||
"h": "21.49"
|
||||
},
|
||||
{
|
||||
"x": "53.07",
|
||||
"y": "53.28",
|
||||
"w": "8.74",
|
||||
"h": "21.62"
|
||||
},
|
||||
{
|
||||
"x": "62.28",
|
||||
"y": "53.41",
|
||||
"w": "8.82",
|
||||
"h": "21.49"
|
||||
},
|
||||
{
|
||||
"x": "71.50",
|
||||
"y": "53.28",
|
||||
"w": "8.90",
|
||||
"h": "21.75"
|
||||
},
|
||||
{
|
||||
"x": "80.87",
|
||||
"y": "53.15",
|
||||
"w": "8.66",
|
||||
"h": "21.75"
|
||||
},
|
||||
{
|
||||
"x": "90.08",
|
||||
"y": "53.54",
|
||||
"w": "8.90",
|
||||
"h": "21.49"
|
||||
},
|
||||
{
|
||||
"x": "43.86",
|
||||
"y": "76.32",
|
||||
"w": "8.82",
|
||||
"h": "21.75"
|
||||
},
|
||||
{
|
||||
"x": "53.15",
|
||||
"y": "76.45",
|
||||
"w": "8.66",
|
||||
"h": "21.49"
|
||||
},
|
||||
{
|
||||
"x": "62.52",
|
||||
"y": "76.57",
|
||||
"w": "8.58",
|
||||
"h": "21.62"
|
||||
},
|
||||
{
|
||||
"x": "71.65",
|
||||
"y": "76.45",
|
||||
"w": "8.66",
|
||||
"h": "21.62"
|
||||
},
|
||||
{
|
||||
"x": "80.94",
|
||||
"y": "76.57",
|
||||
"w": "8.74",
|
||||
"h": "21.49"
|
||||
},
|
||||
{
|
||||
"x": "90.24",
|
||||
"y": "76.57",
|
||||
"w": "8.50",
|
||||
"h": "21.36"
|
||||
}
|
||||
],
|
||||
"img/location_photo/기술개발센터/서버실/서버실_2.png": [
|
||||
{
|
||||
"x": "49.47",
|
||||
"y": "1.80",
|
||||
"w": "47.49",
|
||||
"h": "7.04"
|
||||
},
|
||||
{
|
||||
"x": "49.47",
|
||||
"y": "12.04",
|
||||
"w": "47.49",
|
||||
"h": "6.91"
|
||||
},
|
||||
{
|
||||
"x": "49.60",
|
||||
"y": "21.52",
|
||||
"w": "47.35",
|
||||
"h": "6.91"
|
||||
},
|
||||
{
|
||||
"x": "49.47",
|
||||
"y": "30.48",
|
||||
"w": "47.49",
|
||||
"h": "7.04"
|
||||
},
|
||||
{
|
||||
"x": "49.60",
|
||||
"y": "39.82",
|
||||
"w": "47.49",
|
||||
"h": "6.91"
|
||||
},
|
||||
{
|
||||
"x": "49.47",
|
||||
"y": "50.06",
|
||||
"w": "47.62",
|
||||
"h": "6.91"
|
||||
},
|
||||
{
|
||||
"x": "49.74",
|
||||
"y": "59.28",
|
||||
"w": "47.22",
|
||||
"h": "6.91"
|
||||
},
|
||||
{
|
||||
"x": "49.34",
|
||||
"y": "68.37",
|
||||
"w": "47.75",
|
||||
"h": "7.04"
|
||||
},
|
||||
{
|
||||
"x": "49.60",
|
||||
"y": "77.97",
|
||||
"w": "47.22",
|
||||
"h": "6.91"
|
||||
},
|
||||
{
|
||||
"x": "49.60",
|
||||
"y": "86.93",
|
||||
"w": "47.35",
|
||||
"h": "7.17"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ITAM Map Coordinate Editor v3.0</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
|
||||
</head>
|
||||
<body style="margin: 0; display: flex; height: 100vh; overflow: hidden; font-family: sans-serif;">
|
||||
|
||||
<!-- Left: File Selector -->
|
||||
<div class="file-sidebar" id="file-sidebar">
|
||||
<!-- Rendered by MapEditor.ts -->
|
||||
</div>
|
||||
|
||||
<!-- Center: Main Editor -->
|
||||
<div class="editor-container" id="container">
|
||||
<div class="img-wrapper" id="wrapper">
|
||||
<img src="" id="target-img" alt="Map Image">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right: Control Panel -->
|
||||
<div class="sidebar">
|
||||
<h2>Map Editor <small style="font-size: 0.6em; color: #888;">v3.0</small></h2>
|
||||
<div class="current-path" id="current-path">파일을 선택하세요</div>
|
||||
<p>
|
||||
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
|
||||
</p>
|
||||
|
||||
<div class="box-list" id="box-list"></div>
|
||||
|
||||
<div class="actions">
|
||||
<button id="btn-clear-all" class="btn btn-outline" style="height:38px;">전체 삭제</button>
|
||||
<button id="btn-save-server" class="btn btn-primary" style="height:38px;">서버에 즉시 저장</button>
|
||||
<div id="save-status"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module" src="/src/map-editor-main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
}
|
||||
51
server.js
@@ -2,7 +2,6 @@ import express from 'express';
|
||||
import mysql from 'mysql2/promise';
|
||||
import cors from 'cors';
|
||||
import dotenv from 'dotenv';
|
||||
import fs from 'fs';
|
||||
|
||||
dotenv.config();
|
||||
|
||||
@@ -58,18 +57,22 @@ const saveAssetsBatch = async (tableName, items, res, context) => {
|
||||
const [cols] = await connection.query(`DESCRIBE ${tableName}`);
|
||||
const validColumns = cols.map(c => c.Field);
|
||||
|
||||
// 1. Clear existing
|
||||
// 1. Clear existing (or we could use UPSERT logic, but existing code used DELETE-INSERT pattern)
|
||||
await connection.query(`DELETE FROM ${tableName}`);
|
||||
|
||||
// 2. Insert new items
|
||||
for (const item of items) {
|
||||
const filteredRow = {};
|
||||
validColumns.forEach(col => {
|
||||
// Exclude auto-managed timestamps from manual insertion
|
||||
if (col === 'created_at' || col === 'updated_at') return;
|
||||
|
||||
if (item[col] !== undefined) filteredRow[col] = item[col];
|
||||
});
|
||||
|
||||
// Auto-generate ID if missing
|
||||
if (!filteredRow.id) filteredRow.id = Math.random().toString(36).substring(2, 9);
|
||||
|
||||
await connection.query(`INSERT INTO ${tableName} SET ?`, [filteredRow]);
|
||||
}
|
||||
|
||||
@@ -104,13 +107,16 @@ const routeMap = {
|
||||
'/api/asset/software/assignment': { table: 'asset_software_assignment', context: 'SW ASSIGN' }
|
||||
};
|
||||
|
||||
// 동적 라우팅 생성 (Dynamic Routing)
|
||||
Object.entries(routeMap).forEach(([route, { table, context }]) => {
|
||||
app.get(route, (req, res) => fetchAssets(table, res, context));
|
||||
app.post(`${route}/batch`, (req, res) => saveAssetsBatch(table, req.body, res, `${context} BATCH`));
|
||||
});
|
||||
|
||||
// 4. Legacy/Auxiliary (History & Assignment)
|
||||
app.get('/api/asset/history', (req, res) => fetchAssets('asset_history', res, 'HISTORY'));
|
||||
app.post('/api/asset/history/batch', async (req, res) => {
|
||||
// Custom logic for history as it might not follow the random-id pattern
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
@@ -130,16 +136,23 @@ app.post('/api/asset/history/batch', async (req, res) => {
|
||||
} catch (err) { await connection.rollback(); handleError(res, err, 'BATCH HISTORY'); } finally { connection.release(); }
|
||||
});
|
||||
|
||||
// 5. Utility
|
||||
app.get('/api/generate-asset-code', async (req, res) => {
|
||||
try {
|
||||
const { prefix } = req.query;
|
||||
if (!prefix) return res.status(400).json({ error: 'Prefix is required' });
|
||||
|
||||
// Search in multiple tables if necessary, but typically prefix-based tables are known
|
||||
const tables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_network', 'asset_survey', 'asset_pc_parts', 'asset_equipment', 'asset_office_supplies', 'asset_vip'];
|
||||
let lastCode = '';
|
||||
|
||||
for (const table of tables) {
|
||||
const [rows] = await pool.query(`SELECT asset_code FROM ${table} WHERE asset_code LIKE ? ORDER BY asset_code DESC LIMIT 1`, [`${prefix}%`]);
|
||||
if (rows.length > 0 && rows[0].asset_code > lastCode) lastCode = rows[0].asset_code;
|
||||
if (rows.length > 0 && rows[0].asset_code > lastCode) {
|
||||
lastCode = rows[0].asset_code;
|
||||
}
|
||||
}
|
||||
|
||||
let nextNum = 1;
|
||||
if (lastCode) {
|
||||
const lastNum = parseInt(lastCode.split('-').pop() || '0');
|
||||
@@ -149,38 +162,6 @@ app.get('/api/generate-asset-code', async (req, res) => {
|
||||
} catch (err) { handleError(res, err, 'GENERATE CODE'); }
|
||||
});
|
||||
|
||||
// 6. Map Config API (Real-time Save)
|
||||
app.get('/api/maps', (req, res) => {
|
||||
try {
|
||||
if (!fs.existsSync('map_config.json')) {
|
||||
return res.json({});
|
||||
}
|
||||
const data = fs.readFileSync('map_config.json', 'utf8');
|
||||
res.json(JSON.parse(data || '{}'));
|
||||
} catch (err) {
|
||||
handleError(res, err, 'GET MAPS');
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/maps/save', (req, res) => {
|
||||
try {
|
||||
const { path, boxes } = req.body;
|
||||
if (!path) return res.status(400).json({ error: 'Path is required' });
|
||||
|
||||
let config = {};
|
||||
if (fs.existsSync('map_config.json')) {
|
||||
config = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
|
||||
}
|
||||
|
||||
config[path] = boxes;
|
||||
fs.writeFileSync('map_config.json', JSON.stringify(config, null, 2));
|
||||
console.log(`💾 [MAP SAVE] Updated config for: ${path}`);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
handleError(res, err, 'SAVE MAPS');
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(3000, '0.0.0.0', () => {
|
||||
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (Multi-Table Optimized)');
|
||||
});
|
||||
|
||||
@@ -1,106 +1,5 @@
|
||||
import { createIcons, X } from 'lucide';
|
||||
import { setEditLock } from './ModalUtils';
|
||||
|
||||
/**
|
||||
* 모든 모달의 공통 기능을 관리하는 베이스 추상 클래스입니다.
|
||||
*/
|
||||
export abstract class BaseModal {
|
||||
protected idPrefix: string;
|
||||
protected title: string;
|
||||
protected currentAsset: any | null = null;
|
||||
protected isEditMode: boolean = false;
|
||||
protected modalEl: HTMLElement | null = null;
|
||||
protected formEl: HTMLFormElement | null = null;
|
||||
|
||||
constructor(idPrefix: string, title: string) {
|
||||
this.idPrefix = idPrefix;
|
||||
this.title = title;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 초기화: HTML 삽입 및 공통 이벤트 바인딩
|
||||
*/
|
||||
public init(onSave: () => void, closeModalsFn: () => void) {
|
||||
// 1. 프레임 HTML 삽입 (자식 클래스에서 정의한 HTML 사용)
|
||||
if (!document.getElementById(`${this.idPrefix}-asset-modal`)) {
|
||||
document.body.insertAdjacentHTML('beforeend', this.renderFrameHTML());
|
||||
}
|
||||
|
||||
this.modalEl = document.getElementById(`${this.idPrefix}-asset-modal`);
|
||||
this.formEl = document.getElementById(`${this.idPrefix}-asset-form`) as HTMLFormElement;
|
||||
|
||||
// 2. 공통 버튼 이벤트 바인딩 (닫기, 취소 등)
|
||||
const btnCloseHeader = document.getElementById(`btn-close-${this.idPrefix}-modal`);
|
||||
const btnCancelFooter = document.getElementById(`btn-cancel-${this.idPrefix}-modal`);
|
||||
|
||||
const closeAction = () => {
|
||||
this.close();
|
||||
closeModalsFn(); // 전역 모달 상태 해제 콜백
|
||||
};
|
||||
|
||||
btnCloseHeader?.addEventListener('click', closeAction);
|
||||
btnCancelFooter?.addEventListener('click', closeAction);
|
||||
|
||||
// 3. 자식 클래스 전용 초기화 로직 실행
|
||||
this.initChildLogic(onSave, closeModalsFn);
|
||||
|
||||
// 4. 아이콘 초기화
|
||||
createIcons({ icons: { X } });
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 열기: 데이터 바인딩 및 모드 설정
|
||||
*/
|
||||
public open(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||
this.currentAsset = asset;
|
||||
this.isEditMode = (mode === 'add' || mode === 'edit');
|
||||
|
||||
this.setEditLockMode(mode);
|
||||
this.fillFormData(asset);
|
||||
|
||||
if (this.modalEl) {
|
||||
this.modalEl.classList.remove('hidden');
|
||||
}
|
||||
|
||||
this.onAfterOpen(asset, mode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 모달 닫기: 상태 초기화
|
||||
*/
|
||||
public close() {
|
||||
if (this.modalEl) {
|
||||
this.modalEl.classList.add('hidden');
|
||||
}
|
||||
this.isEditMode = false;
|
||||
this.currentAsset = null;
|
||||
this.onAfterClose();
|
||||
}
|
||||
|
||||
/**
|
||||
* 조회/수정 모드에 따른 UI 잠금 및 버튼 제어
|
||||
*/
|
||||
protected setEditLockMode(mode: 'view' | 'edit' | 'add') {
|
||||
setEditLock(`${this.idPrefix}-asset-form`, mode, {
|
||||
saveBtnId: `btn-save-${this.idPrefix}-asset`,
|
||||
revertBtnId: `btn-revert-${this.idPrefix}-edit`,
|
||||
addLogBtnId: `btn-add-${this.idPrefix}-log`
|
||||
});
|
||||
}
|
||||
|
||||
// --- 추상 메서드: 자식 클래스에서 구현해야 함 ---
|
||||
protected abstract renderFrameHTML(): string;
|
||||
protected abstract initChildLogic(onSave: () => void, closeModals: () => void): void;
|
||||
protected abstract fillFormData(asset: any): void;
|
||||
protected abstract onAfterOpen(asset: any, mode: string): void;
|
||||
|
||||
// --- 훅(Hook) 메서드: 필요 시 오버라이드 ---
|
||||
protected onAfterClose(): void {}
|
||||
}
|
||||
|
||||
/**
|
||||
* --- 레거시 호환성을 위한 함수형 익스포트 ---
|
||||
* 기존 코드들이 참조하고 있는 함수들을 유지합니다.
|
||||
* 모든 모달의 공통 기능 (닫기, ESC 처리, 배경 클릭 등)을 관리하는 베이스 모듈입니다.
|
||||
*/
|
||||
export function closeModals() {
|
||||
const modals = document.querySelectorAll('.modal-overlay');
|
||||
@@ -108,14 +7,26 @@ export function closeModals() {
|
||||
}
|
||||
|
||||
export function initBaseModal() {
|
||||
// ESC 키로 모든 모달 닫기
|
||||
// ESC 키로 닫기
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeModals();
|
||||
});
|
||||
|
||||
// 배경(Overlay) 클릭 시 닫기
|
||||
document.addEventListener('click', (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.classList.contains('modal-overlay')) {
|
||||
closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
return { closeAllModals: closeModals };
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 모달을 엽니다.
|
||||
* @param modalId 모달 엘리먼트의 ID
|
||||
*/
|
||||
export function openModal(modalId: string) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
|
||||
@@ -1,188 +1,121 @@
|
||||
import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { closeModals, openModal } from './BaseModal';
|
||||
import { CORP_LIST } from './SharedData';
|
||||
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
|
||||
import { createIcons, X, Save, Database, CalendarClock, Edit2, History, Plus } from 'lucide';
|
||||
import { generateOptionsHTML, setEditLock } from './ModalUtils';
|
||||
import { createIcons, X, Save, Database, CalendarClock, Edit2 } from 'lucide';
|
||||
import { formatExcelDate } from '../../core/excelHandler';
|
||||
import { UI_TEXT } from '../../core/schema';
|
||||
import { API_BASE_URL } from '../../core/utils';
|
||||
|
||||
class DomainAssetModal extends BaseModal {
|
||||
constructor() {
|
||||
super('domain', '도메인 정보');
|
||||
let currentItem: any = null;
|
||||
|
||||
const DOMAIN_MODAL_HTML = `
|
||||
... (rest of DOMAIN_MODAL_HTML remains same) ...
|
||||
`;
|
||||
|
||||
export function initDomainModal() {
|
||||
if (!document.getElementById('domain-asset-modal')) {
|
||||
document.body.insertAdjacentHTML('beforeend', DOMAIN_MODAL_HTML);
|
||||
}
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
return `
|
||||
<div id="domain-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="domain-modal-title">${this.title}</h2>
|
||||
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-body-split">
|
||||
<div class="modal-form-area">
|
||||
<form id="domain-asset-form" class="grid-form">
|
||||
<input type="hidden" id="domain-id" name="id" />
|
||||
|
||||
<div class="form-section-title">기본 정보</div>
|
||||
<div class="form-group">
|
||||
<label>구분</label>
|
||||
<select id="domain-type" name="type">
|
||||
<option value="호스팅">호스팅</option>
|
||||
<option value="도메인">도메인</option>
|
||||
<option value="기타">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>관리법인</label>
|
||||
<select id="domain-corp" name="corp">${generateOptionsHTML(CORP_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label>서비스명</label>
|
||||
<input type="text" id="domain-service-name" name="service_name" required />
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label>관리도메인</label>
|
||||
<input type="text" id="domain-name" name="domain_name" required />
|
||||
</div>
|
||||
const modal = document.getElementById('domain-asset-modal')!;
|
||||
document.getElementById('btn-close-domain-modal')?.addEventListener('click', () => closeModals());
|
||||
document.getElementById('btn-cancel-domain')?.addEventListener('click', () => closeModals());
|
||||
|
||||
const saveBtn = document.getElementById('btn-save-domain');
|
||||
const revertBtn = document.getElementById('btn-revert-domain');
|
||||
const deleteBtn = document.getElementById('btn-delete-domain');
|
||||
const headerEditBtn = document.getElementById('btn-edit-domain-header');
|
||||
|
||||
<div class="form-section-title">계약 및 비용</div>
|
||||
<div class="form-group">
|
||||
<label>계약시작일</label>
|
||||
<input type="date" id="domain-start-date" name="start_date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>만료예정일</label>
|
||||
<input type="date" id="domain-expiry-date" name="expiry_date" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>비용 (연간/월간)</label>
|
||||
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
|
||||
</div>
|
||||
saveBtn?.addEventListener('click', () => {
|
||||
if (!currentItem) return;
|
||||
if (saveBtn.textContent?.includes('수정')) {
|
||||
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
|
||||
return;
|
||||
}
|
||||
saveDomain();
|
||||
});
|
||||
|
||||
<div class="form-section-title">담당자 및 비고</div>
|
||||
<div class="form-group">
|
||||
<label>정담당자</label>
|
||||
<input type="text" id="domain-manager-main" name="manager_main" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>부담당자</label>
|
||||
<input type="text" id="domain-manager-sub" name="manager_sub" />
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label>비고</label>
|
||||
<textarea id="domain-remarks" name="remarks" rows="3"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-history-area">
|
||||
<div class="history-header">
|
||||
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 변경 이력</h3>
|
||||
<button type="button" id="btn-add-domain-log" class="btn btn-outline btn-sm">
|
||||
이력 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="domain-history-list" class="history-timeline"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-delete-domain-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-revert-domain-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||
<button id="btn-cancel-domain-modal" class="btn btn-outline">닫기</button>
|
||||
<button id="btn-save-domain-asset" class="btn btn-primary">수정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
headerEditBtn?.addEventListener('click', () => {
|
||||
setEditLock('domain-asset-form', 'edit', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
|
||||
});
|
||||
|
||||
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||
const saveBtn = document.getElementById('btn-save-domain-asset')!;
|
||||
const revertBtn = document.getElementById('btn-revert-domain-edit')!;
|
||||
const deleteBtn = document.getElementById('btn-delete-domain-asset')!;
|
||||
revertBtn?.addEventListener('click', () => {
|
||||
setEditLock('domain-asset-form', 'view', { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
|
||||
if (currentItem) openDomainModal(currentItem);
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset) return;
|
||||
if (!this.isEditMode) {
|
||||
this.setEditLockMode('edit');
|
||||
this.isEditMode = true;
|
||||
return;
|
||||
deleteBtn?.addEventListener('click', async () => {
|
||||
if (currentItem && confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) {
|
||||
const success = await deleteAsset('domain', currentItem.id);
|
||||
if (success) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
closeModals();
|
||||
window.dispatchEvent(new CustomEvent('refresh-view'));
|
||||
}
|
||||
|
||||
const formData = new FormData(this.formEl!);
|
||||
const updated = { ...this.currentAsset };
|
||||
formData.forEach((value, key) => { updated[key] = value; });
|
||||
|
||||
if (!updated.service_name || !updated.domain_name) {
|
||||
alert('서비스명과 관리도메인은 필수 입력 사항입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (await saveAsset('domain', 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 || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
|
||||
if (await deleteAsset('domain', this.currentAsset.id)) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
createIcons({ icons: { History, Plus, Save, CalendarClock, Database } });
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
setFieldValue('domain-id', asset.id);
|
||||
setFieldValue('domain-type', asset.type || '호스팅');
|
||||
setFieldValue('domain-corp', asset.corp || '');
|
||||
setFieldValue('domain-service-name', asset.service_name || '');
|
||||
setFieldValue('domain-name', asset.domain_name || '');
|
||||
setFieldValue('domain-start-date', formatExcelDate(asset.start_date));
|
||||
setFieldValue('domain-expiry-date', formatExcelDate(asset.expiry_date));
|
||||
setFieldValue('domain-price', asset.price || '');
|
||||
setFieldValue('domain-manager-main', asset.manager_main || '');
|
||||
setFieldValue('domain-manager-sub', asset.manager_sub || '');
|
||||
setFieldValue('domain-remarks', asset.remarks || '');
|
||||
|
||||
this.renderHistory(asset.id);
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
const titleEl = document.getElementById('domain-modal-title');
|
||||
if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세';
|
||||
|
||||
const deleteBtn = document.getElementById('btn-delete-domain-asset');
|
||||
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
|
||||
}
|
||||
|
||||
private renderHistory(assetId: string) {
|
||||
const container = document.getElementById('domain-history-list');
|
||||
if (!container) return;
|
||||
const logs = (state.masterData.logs || []).filter(l => l.assetId === assetId);
|
||||
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">이력이 없습니다.</div>'; return; }
|
||||
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export const domainModal = new DomainAssetModal();
|
||||
export function openDomainModal(item: any = null) {
|
||||
currentItem = item;
|
||||
const isEdit = !!item;
|
||||
const mode = isEdit ? 'view' : 'add';
|
||||
|
||||
const titleEl = document.getElementById('domain-modal-title');
|
||||
if (titleEl) titleEl.textContent = isEdit ? '도메인 정보 상세' : '신규 도메인 등록';
|
||||
|
||||
export function initDomainModal(onSave: () => void, closeModals: () => void) {
|
||||
domainModal.init(onSave, closeModals);
|
||||
setEditLock('domain-asset-form', mode, { saveBtnId: 'btn-save-domain', revertBtnId: 'btn-revert-domain' });
|
||||
|
||||
const setVal = (id: string, val: any) => {
|
||||
const el = document.getElementById(id) as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
|
||||
if (el) el.value = val || '';
|
||||
};
|
||||
|
||||
setVal('domain-type', item?.type || '호스팅');
|
||||
setVal('domain-corp', item?.corp || '');
|
||||
setVal('domain-service-name', item?.service_name || '');
|
||||
setVal('domain-name', item?.domain_name || '');
|
||||
setVal('domain-start-date', formatExcelDate(item?.start_date));
|
||||
setVal('domain-expiry-date', formatExcelDate(item?.expiry_date));
|
||||
setVal('domain-price', item?.price || '');
|
||||
setVal('domain-manager-main', item?.manager_main || '');
|
||||
setVal('domain-manager-sub', item?.manager_sub || '');
|
||||
setVal('domain-remarks', item?.remarks || '');
|
||||
|
||||
const deleteBtn = document.getElementById('btn-delete-domain');
|
||||
if (deleteBtn) deleteBtn.style.display = isEdit ? 'block' : 'none';
|
||||
|
||||
openModal('domain-asset-modal');
|
||||
createIcons({ icons: { X, Save, Database, CalendarClock, Edit2 } });
|
||||
}
|
||||
|
||||
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
|
||||
domainModal.open(asset, mode);
|
||||
async function saveDomain() {
|
||||
const getVal = (id: string) => (document.getElementById(id) as HTMLInputElement)?.value || '';
|
||||
|
||||
const newDomain = {
|
||||
id: currentItem ? currentItem.id : `DOM-${Date.now()}`,
|
||||
type: getVal('domain-type'),
|
||||
corp: getVal('domain-corp'),
|
||||
service_name: getVal('domain-service-name'),
|
||||
domain_name: getVal('domain-name'),
|
||||
start_date: getVal('domain-start-date'),
|
||||
expiry_date: getVal('domain-expiry-date'),
|
||||
price: getVal('domain-price'),
|
||||
manager_main: getVal('domain-manager-main'),
|
||||
manager_sub: getVal('domain-manager-sub'),
|
||||
remarks: getVal('domain-remarks')
|
||||
};
|
||||
|
||||
if (!newDomain.service_name || !newDomain.domain_name) {
|
||||
alert('서비스명과 관리도메인은 필수 입력 사항입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await saveAsset('domain', newDomain);
|
||||
if (success) {
|
||||
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
|
||||
closeModals();
|
||||
window.dispatchEvent(new CustomEvent('refresh-view'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { state, saveAsset, deleteAsset } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { openModal, closeModals } from './BaseModal';
|
||||
import { openSwUserModal } from './SWUserModal';
|
||||
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Calendar, Users } from 'lucide';
|
||||
import { createIcons, History, Plus, X, Save, Edit2, RotateCcw, Calendar } from 'lucide';
|
||||
import { CORP_LIST } from './SharedData';
|
||||
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
|
||||
import { API_BASE_URL } from '../../core/utils';
|
||||
@@ -9,363 +9,438 @@ import {
|
||||
generateOptionsHTML,
|
||||
setFieldValue,
|
||||
getFieldValue,
|
||||
setEditLock,
|
||||
applyDateMask
|
||||
} from './ModalUtils';
|
||||
|
||||
class SwAssetModal extends BaseModal {
|
||||
constructor() {
|
||||
super('sw', '소프트웨어 상세 정보');
|
||||
}
|
||||
let currentSwAsset: any | null = null;
|
||||
let isEditMode = false;
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
return `
|
||||
<div id="sw-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="sw-modal-title">${this.title}</h2>
|
||||
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-body-split">
|
||||
<div class="modal-form-area">
|
||||
<form id="sw-asset-form" class="grid-form">
|
||||
<input type="hidden" id="sw-asset-id" name="id" />
|
||||
|
||||
<div class="form-section-title">기본 정보 (Identity)</div>
|
||||
<div class="form-group">
|
||||
<label>자산 유형</label>
|
||||
<select id="sw-asset-type" name="asset_type" required>
|
||||
<option value="내부SW">내부SW</option>
|
||||
<option value="외부SW">외부SW</option>
|
||||
<option value="클라우드">클라우드</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
|
||||
<select id="sw-분야" name="sw_field" required>
|
||||
<option value="업무공통">업무공통</option>
|
||||
<option value="개발S/W">개발S/W</option>
|
||||
<option value="디자인">디자인</option>
|
||||
<option value="설계S/W">설계S/W</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
|
||||
<select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label>${ASSET_SCHEMA.PRODUCT_NAME.ui}</label>
|
||||
<input type="text" id="sw-제품명" name="product_name" required />
|
||||
</div>
|
||||
<div class="form-group cloud-only">
|
||||
<label>${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label>
|
||||
<input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
||||
<input type="text" id="sw-부서" name="current_dept" />
|
||||
</div>
|
||||
<div class="form-group sw-user-tracking">
|
||||
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
|
||||
<input type="text" id="sw-user-current" name="user_current" />
|
||||
</div>
|
||||
<div class="form-group sw-user-tracking">
|
||||
<label>${ASSET_SCHEMA.PREV_USER.ui}</label>
|
||||
<input type="text" id="sw-previous-user" name="previous_user" />
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">라이선스 및 계약 정보</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label>${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
|
||||
<input type="number" id="sw-수량" name="asset_count" min="0" />
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
|
||||
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
|
||||
</div>
|
||||
|
||||
<div class="form-group cloud-only">
|
||||
<label>${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label>
|
||||
<input type="text" id="sw-계정명" name="email_account" />
|
||||
</div>
|
||||
<div class="form-group cloud-only">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label>
|
||||
<select id="sw-결제수단" name="purchase_method">
|
||||
<option value="">선택안함</option>
|
||||
<option value="법인카드">법인카드</option>
|
||||
<option value="인보이스">인보이스</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-section-title">관리 및 비고</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
</button>
|
||||
<input type="date" id="sw-구매일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
|
||||
<input type="text" id="sw-납품업체" name="purchase_vendor" />
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label>${ASSET_SCHEMA.DEV_MGR.ui}</label>
|
||||
<input type="text" id="sw-개발담당자" name="dev_manager" />
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label>${ASSET_SCHEMA.PLANNING_MGR.ui}</label>
|
||||
<input type="text" id="sw-기획담당자" name="planning_manager" />
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label>${ASSET_SCHEMA.SALES_MGR.ui}</label>
|
||||
<input type="text" id="sw-영업담당자" name="sales_manager" />
|
||||
</div>
|
||||
<div class="form-group sw-standard-field" id="sw-expiry-group">
|
||||
<label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
</button>
|
||||
<input type="date" id="sw-만료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label>${ASSET_SCHEMA.MEMO.ui}</label>
|
||||
<textarea id="sw-비고" name="memo" rows="2"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
|
||||
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
|
||||
<i data-lucide="users" style="width:16px; height:16px; margin-right:4px;"></i> 사용자 관리
|
||||
</button>
|
||||
</div>
|
||||
const SW_MODAL_HTML = `
|
||||
<div id="sw-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="sw-modal-title">소프트웨어 상세 정보</h2>
|
||||
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="modal-body-split">
|
||||
<div class="modal-form-area">
|
||||
<form id="sw-asset-form" class="grid-form">
|
||||
<input type="hidden" id="sw-asset-id" name="id" />
|
||||
|
||||
<!-- Group 1: 기본 정보 (Identity) -->
|
||||
<div class="form-section-title">기본 정보 (Identity)</div>
|
||||
<div class="form-group">
|
||||
<label for="sw-asset-type">자산 유형</label>
|
||||
<select id="sw-asset-type" name="asset_type" required>
|
||||
<option value="내부SW">내부SW</option>
|
||||
<option value="외부SW">외부SW</option>
|
||||
<option value="클라우드">클라우드</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sw-분야">${ASSET_SCHEMA.SW_FIELD.ui}</label>
|
||||
<select id="sw-분야" name="sw_field" required>
|
||||
<option value="업무공통">업무공통</option>
|
||||
<option value="개발S/W">개발S/W</option>
|
||||
<option value="디자인">디자인</option>
|
||||
<option value="설계S/W">설계S/W</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="modal-history-area">
|
||||
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
|
||||
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
|
||||
계약 업데이트 <i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sw-history-list" class="history-timeline"></div>
|
||||
<div class="form-group">
|
||||
<label for="sw-법인">${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
|
||||
<select id="sw-법인" name="purchase_corp" required>${generateOptionsHTML(CORP_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="sw-제품명">${ASSET_SCHEMA.PRODUCT_NAME.ui}</label>
|
||||
<input type="text" id="sw-제품명" name="product_name" required />
|
||||
</div>
|
||||
<div class="form-group cloud-only">
|
||||
<label for="sw-플랫폼명">${ASSET_SCHEMA.DEV_OBJ.ui} / 플랫폼</label>
|
||||
<input type="text" id="sw-플랫폼명" name="dev_objective" placeholder="개발목적 또는 플랫폼명" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="sw-부서">${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
|
||||
<input type="text" id="sw-부서" name="current_dept" />
|
||||
</div>
|
||||
<div class="form-group sw-user-tracking">
|
||||
<label for="sw-user-current">${ASSET_SCHEMA.CURRENT_USER.ui}</label>
|
||||
<input type="text" id="sw-user-current" name="user_current" />
|
||||
</div>
|
||||
<div class="form-group sw-user-tracking">
|
||||
<label for="sw-previous-user">${ASSET_SCHEMA.PREV_USER.ui}</label>
|
||||
<input type="text" id="sw-previous-user" name="previous_user" />
|
||||
</div>
|
||||
|
||||
<!-- Group 2: 라이선스 및 계약 (License/Contract) -->
|
||||
<div class="form-section-title">라이선스 및 계약 정보</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label for="sw-수량">${ASSET_SCHEMA.ASSET_COUNT.ui}</label>
|
||||
<input type="number" id="sw-수량" name="asset_count" min="0" />
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label for="sw-금액">${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
|
||||
<input type="text" id="sw-금액" name="purchase_amount" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
|
||||
</div>
|
||||
|
||||
<!-- Group 3: 클라우드 전용 정보 (Cloud Specific) -->
|
||||
<div class="form-group cloud-only">
|
||||
<label for="sw-계정명">${ASSET_SCHEMA.EMAIL_ACCOUNT.ui}</label>
|
||||
<input type="text" id="sw-계정명" name="email_account" />
|
||||
</div>
|
||||
<div class="form-group cloud-only">
|
||||
<label for="sw-결제수단">${ASSET_SCHEMA.PURCHASE_METHOD.ui}</label>
|
||||
<select id="sw-결제수단" name="purchase_method">
|
||||
<option value="">선택안함</option>
|
||||
<option value="법인카드">법인카드</option>
|
||||
<option value="인보이스">인보이스</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Group 4: 관리 정보 (Management) -->
|
||||
<div class="form-section-title">관리 및 비고</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label for="sw-구매일">${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
</button>
|
||||
<input type="date" id="sw-구매일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label for="sw-납품업체">${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
|
||||
<input type="text" id="sw-납품업체" name="purchase_vendor" />
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label for="sw-개발담당자">${ASSET_SCHEMA.DEV_MGR.ui}</label>
|
||||
<input type="text" id="sw-개발담당자" name="dev_manager" />
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label for="sw-기획담당자">${ASSET_SCHEMA.PLANNING_MGR.ui}</label>
|
||||
<input type="text" id="sw-기획담당자" name="planning_manager" />
|
||||
</div>
|
||||
<div class="form-group sw-standard-field">
|
||||
<label for="sw-영업담당자">${ASSET_SCHEMA.SALES_MGR.ui}</label>
|
||||
<input type="text" id="sw-영업담당자" name="sales_manager" />
|
||||
</div>
|
||||
<div class="form-group sw-standard-field" id="sw-expiry-group">
|
||||
<label for="sw-만료일">${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
</button>
|
||||
<input type="date" id="sw-만료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label for="sw-비고">${ASSET_SCHEMA.MEMO.ui}</label>
|
||||
<textarea id="sw-비고" name="memo" rows="2"></textarea>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem;">
|
||||
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
|
||||
<i data-lucide="users" style="width:16px; height:16px; margin-right:4px;"></i> 사용자 관리
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-revert-sw-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||
<button id="btn-cancel-sw-modal" class="btn btn-outline">닫기</button>
|
||||
<button id="btn-save-sw-asset" class="btn btn-primary">수정</button>
|
||||
|
||||
<div class="modal-history-area">
|
||||
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;">
|
||||
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3>
|
||||
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
|
||||
계약 업데이트 <i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div id="sw-history-list" class="history-timeline"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-delete-sw-asset" class="btn btn-outline btn-danger">삭제</button>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-revert-sw-edit" class="btn btn-outline hidden">수정 취소</button>
|
||||
<button id="btn-cancel-sw-modal" class="btn btn-outline">닫기</button>
|
||||
<button id="btn-save-sw-asset" class="btn btn-primary">수정</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 계약 업데이트 서브 모달 -->
|
||||
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2>계약 업데이트 반영</h2>
|
||||
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
|
||||
<!-- 계약/유지보수 기간 갱신 및 업데이트 모달 -->
|
||||
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
|
||||
<div class="modal-content" style="max-width: 500px;">
|
||||
<div class="modal-header">
|
||||
<h2>계약 업데이트 반영</h2>
|
||||
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid-form" style="grid-template-columns: 1fr;">
|
||||
<div class="form-group">
|
||||
<label>업데이트 일자</label>
|
||||
<input type="date" id="sw-update-date" />
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="grid-form" style="grid-template-columns: 1fr;">
|
||||
<div class="form-group">
|
||||
<label>업데이트 일자</label>
|
||||
<input type="date" id="sw-update-date" />
|
||||
</div>
|
||||
<div class="form-group sub-sw-update">
|
||||
<label>새로운 계약 기간</label>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
||||
<span>~</span>
|
||||
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>발생 비용</label>
|
||||
<input type="text" id="sw-update-cost" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" placeholder="ex) 500,000" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>상세 내용 (메모)</label>
|
||||
<input type="text" id="sw-update-note" placeholder="예: 25년도 구독 연장 결제 완료" />
|
||||
</div>
|
||||
<div class="form-group sub-sw-update">
|
||||
<label>새로운 계약 기간</label>
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem;">
|
||||
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
||||
<span>~</span>
|
||||
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" style="flex: 1;" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div></div>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-cancel-sw-update" class="btn btn-outline">취소</button>
|
||||
<button id="btn-save-sw-update" class="btn btn-primary">반영하기</button>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>발생 비용</label>
|
||||
<input type="text" id="sw-update-cost" oninput="this.value = this.value.replace(/[^0-9]/g, '') ? Number(this.value.replace(/[^0-9]/g, '')).toLocaleString() : ''" placeholder="ex) 500,000" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>상세 내용 (메모)</label>
|
||||
<input type="text" id="sw-update-note" placeholder="예: 25년도 구독 연장 결제 완료" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
<div class="modal-footer">
|
||||
<div></div>
|
||||
<div class="footer-actions">
|
||||
<button id="btn-cancel-sw-update" class="btn btn-outline">취소</button>
|
||||
<button id="btn-save-sw-update" class="btn btn-primary">반영하기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||
const saveBtn = document.getElementById('btn-save-sw-asset')!;
|
||||
const revertBtn = document.getElementById('btn-revert-sw-edit')!;
|
||||
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
|
||||
const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement;
|
||||
const userAssignBtn = document.getElementById('btn-open-sw-user')!;
|
||||
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
|
||||
function applySwTypeUI(type: string) {
|
||||
const cloudFields = document.querySelectorAll('.cloud-only');
|
||||
const swFields = document.querySelectorAll('.sw-standard-field');
|
||||
const userSection = document.getElementById('sw-user-section');
|
||||
const expiryGroup = document.getElementById('sw-expiry-group');
|
||||
const userTracking = document.querySelectorAll('.sw-user-tracking');
|
||||
|
||||
typeSelect?.addEventListener('change', () => this.applySwTypeUI(typeSelect.value));
|
||||
if (type === '클라우드') {
|
||||
cloudFields.forEach(el => (el as HTMLElement).style.display = 'flex');
|
||||
swFields.forEach(el => (el as HTMLElement).style.display = 'none');
|
||||
if (userSection) userSection.style.display = 'none';
|
||||
userTracking.forEach(el => (el as HTMLElement).style.display = 'none');
|
||||
} else {
|
||||
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
|
||||
swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
|
||||
if (userSection) userSection.style.display = 'block';
|
||||
|
||||
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
|
||||
const el = document.getElementById(id) as HTMLInputElement;
|
||||
if (el) applyDateMask(el);
|
||||
});
|
||||
|
||||
userAssignBtn.addEventListener('click', () => {
|
||||
if (this.currentAsset) openSwUserModal(this.currentAsset);
|
||||
});
|
||||
|
||||
// 업데이트 모달 로직
|
||||
const subModal = document.getElementById('sw-update-modal')!;
|
||||
const closeUpdate = () => subModal.classList.add('hidden');
|
||||
document.getElementById('btn-close-sw-update')?.addEventListener('click', closeUpdate);
|
||||
document.getElementById('btn-cancel-sw-update')?.addEventListener('click', closeUpdate);
|
||||
|
||||
btnOpenUpdate?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (!this.isEditMode) { alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.'); return; }
|
||||
subModal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
document.getElementById('btn-save-sw-update')?.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const date = (document.getElementById('sw-update-date') as HTMLInputElement).value;
|
||||
const start = (document.getElementById('sw-update-start') as HTMLInputElement).value;
|
||||
const end = (document.getElementById('sw-update-end') as HTMLInputElement).value;
|
||||
const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value;
|
||||
const note = (document.getElementById('sw-update-note') as HTMLInputElement).value;
|
||||
|
||||
if (start) setFieldValue('sw-시작일', start);
|
||||
if (end) setFieldValue('sw-만료일', end);
|
||||
if (cost) setFieldValue('sw-금액', cost);
|
||||
|
||||
const log = { assetId: this.currentAsset.id, date, details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`, user: '관리자' };
|
||||
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([...state.masterData.logs, log])
|
||||
});
|
||||
|
||||
closeUpdate(); onSave();
|
||||
});
|
||||
|
||||
revertBtn.addEventListener('click', () => {
|
||||
this.setEditLockMode('view');
|
||||
if (this.currentAsset) this.fillFormData(this.currentAsset);
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset) return;
|
||||
if (!this.isEditMode) { this.setEditLockMode('edit'); this.isEditMode = true; return; }
|
||||
|
||||
const type = getFieldValue('sw-asset-type');
|
||||
const formData = new FormData(this.formEl!);
|
||||
const updated = { ...this.currentAsset };
|
||||
formData.forEach((value, key) => { updated[key] = value; });
|
||||
if (type === '외부SW' || type === '내부SW') {
|
||||
if (expiryGroup) expiryGroup.style.display = 'flex';
|
||||
|
||||
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
|
||||
if (await saveAsset(categoryKey, updated)) { onSave(); this.close(); closeModals(); }
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
|
||||
const type = this.currentAsset.asset_type || this.currentAsset.type;
|
||||
let categoryKey = (type === '내부SW') ? 'swInternal' : (type === '클라우드' ? 'cloud' : 'swExternal');
|
||||
if (await deleteAsset(categoryKey, this.currentAsset.id)) {
|
||||
alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals();
|
||||
}
|
||||
});
|
||||
|
||||
createIcons({ icons: { History, Plus, Save, Calendar, Users, RotateCcw } });
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
setFieldValue('sw-asset-id', asset.id);
|
||||
setFieldValue('sw-asset-type', asset.asset_type || asset.type);
|
||||
setFieldValue('sw-분야', asset.sw_field || '');
|
||||
setFieldValue('sw-법인', asset.purchase_corp || '');
|
||||
setFieldValue('sw-부서', asset.current_dept || '');
|
||||
setFieldValue('sw-user-current', asset.user_current || '');
|
||||
setFieldValue('sw-previous-user', asset.previous_user || '');
|
||||
setFieldValue('sw-제품명', asset.product_name || '');
|
||||
setFieldValue('sw-수량', asset.asset_count || '');
|
||||
setFieldValue('sw-금액', asset.purchase_amount || '');
|
||||
setFieldValue('sw-구매일', asset.purchase_date || '');
|
||||
setFieldValue('sw-납품업체', asset.purchase_vendor || '');
|
||||
setFieldValue('sw-개발담당자', asset.dev_manager || '');
|
||||
setFieldValue('sw-기획담당자', asset.planning_manager || '');
|
||||
setFieldValue('sw-영업담당자', asset.sales_manager || '');
|
||||
setFieldValue('sw-비고', asset.memo || '');
|
||||
|
||||
if (asset.type === '클라우드' || asset.asset_type === '클라우드') {
|
||||
setFieldValue('sw-플랫폼명', asset.dev_objective || '');
|
||||
setFieldValue('sw-계정명', asset.email_account || '');
|
||||
setFieldValue('sw-결제수단', asset.purchase_method || '');
|
||||
} else {
|
||||
setFieldValue('sw-만료일', asset.expiry_date || '');
|
||||
// 외부SW에만 현 사용자/직전 사용자 표시 (내부SW는 user tracking 제외 요청됨)
|
||||
userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none');
|
||||
}
|
||||
|
||||
this.renderHistory(asset.id);
|
||||
}
|
||||
|
||||
protected onAfterOpen(asset: any, mode: string): void {
|
||||
this.applySwTypeUI(asset.asset_type || asset.type);
|
||||
}
|
||||
|
||||
private applySwTypeUI(type: string) {
|
||||
const cloudFields = document.querySelectorAll('.cloud-only');
|
||||
const swFields = document.querySelectorAll('.sw-standard-field');
|
||||
const userSection = document.getElementById('sw-user-section');
|
||||
const expiryGroup = document.getElementById('sw-expiry-group');
|
||||
const userTracking = document.querySelectorAll('.sw-user-tracking');
|
||||
|
||||
if (type === '클라우드') {
|
||||
cloudFields.forEach(el => (el as HTMLElement).style.display = 'flex');
|
||||
swFields.forEach(el => (el as HTMLElement).style.display = 'none');
|
||||
if (userSection) userSection.style.display = 'none';
|
||||
userTracking.forEach(el => (el as HTMLElement).style.display = 'none');
|
||||
} else {
|
||||
cloudFields.forEach(el => (el as HTMLElement).style.display = 'none');
|
||||
swFields.forEach(el => (el as HTMLElement).style.display = 'flex');
|
||||
if (userSection) userSection.style.display = 'block';
|
||||
if (type === '외부SW' || type === '내부SW') {
|
||||
if (expiryGroup) expiryGroup.style.display = 'flex';
|
||||
userTracking.forEach(el => (el as HTMLElement).style.display = (type === '외부SW') ? 'flex' : 'none');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private renderHistory(swId: string) {
|
||||
const container = document.getElementById('sw-history-list');
|
||||
if (!container) return;
|
||||
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
|
||||
if (logs.length === 0) { container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>'; return; }
|
||||
container.innerHTML = logs.map(l => `<div class=\"history-item\"><div class=\"history-date\">${l.date}</div><div class=\"history-user\">${l.user}</div><div class=\"history-details\">${l.details}</div></div>`).join('');
|
||||
}
|
||||
}
|
||||
|
||||
export const swModal = new SwAssetModal();
|
||||
function fillSwFormData(asset: any) {
|
||||
setFieldValue('sw-asset-id', asset.id);
|
||||
setFieldValue('sw-asset-type', asset.asset_type || asset.type);
|
||||
setFieldValue('sw-분야', asset.sw_field || '');
|
||||
setFieldValue('sw-법인', asset.purchase_corp || '');
|
||||
|
||||
export function initSwModal(onSave: () => void, closeModals: () => void) {
|
||||
swModal.init(onSave, closeModals);
|
||||
setFieldValue('sw-부서', asset.current_dept || '');
|
||||
setFieldValue('sw-user-current', asset.user_current || '');
|
||||
setFieldValue('sw-previous-user', asset.previous_user || '');
|
||||
setFieldValue('sw-previous_dept', asset.previous_dept || '');
|
||||
setFieldValue('sw-제품명', asset.product_name || '');
|
||||
setFieldValue('sw-수량', asset.asset_count || '');
|
||||
setFieldValue('sw-금액', asset.purchase_amount || '');
|
||||
setFieldValue('sw-구매일', asset.purchase_date || '');
|
||||
setFieldValue('sw-시작일', asset.start_date || '');
|
||||
setFieldValue('sw-납품업체', asset.purchase_vendor || '');
|
||||
setFieldValue('sw-개발담당자', asset.dev_manager || '');
|
||||
setFieldValue('sw-기획담당자', asset.planning_manager || '');
|
||||
setFieldValue('sw-영업담당자', asset.sales_manager || '');
|
||||
setFieldValue('sw-비고', asset.memo || '');
|
||||
|
||||
if (asset.type === '클라우드' || asset.asset_type === '클라우드') {
|
||||
setFieldValue('sw-플랫폼명', asset.dev_objective || '');
|
||||
setFieldValue('sw-계정명', asset.email_account || '');
|
||||
setFieldValue('sw-결제수단', asset.purchase_method || '');
|
||||
} else {
|
||||
setFieldValue('sw-만료일', asset.expiry_date || '');
|
||||
}
|
||||
|
||||
renderSwHistory(asset.id);
|
||||
}
|
||||
|
||||
function renderSwHistory(swId: string) {
|
||||
const container = document.getElementById('sw-history-list');
|
||||
if (!container) return;
|
||||
const logs = (state.masterData.logs || []).filter(l => l.assetId === swId);
|
||||
if (logs.length === 0) {
|
||||
container.innerHTML = '<div class="empty-history">수정 이력이 없습니다.</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = logs.map(l => `
|
||||
<div class="history-item">
|
||||
<div class="history-date">${l.date}</div>
|
||||
<div class="history-user">${l.user}</div>
|
||||
<div class="history-details">${l.details}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
export function openSwModal(asset: any, mode: 'view' | 'add' | 'edit' = 'view') {
|
||||
swModal.open(asset, mode);
|
||||
currentSwAsset = asset;
|
||||
const modal = document.getElementById('sw-asset-modal')!;
|
||||
|
||||
setEditLock('sw-asset-form', mode, {
|
||||
saveBtnId: 'btn-save-sw-asset',
|
||||
revertBtnId: 'btn-revert-sw-edit'
|
||||
});
|
||||
|
||||
isEditMode = (mode === 'add' || mode === 'edit');
|
||||
|
||||
fillSwFormData(asset);
|
||||
applySwTypeUI(asset.asset_type || asset.type);
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
createIcons({ icons: { X, History, Plus } });
|
||||
}
|
||||
|
||||
export function initSwModal(onSave: () => void, closeModals: () => void) {
|
||||
if (!document.getElementById('sw-asset-modal')) {
|
||||
document.body.insertAdjacentHTML('beforeend', SW_MODAL_HTML);
|
||||
}
|
||||
|
||||
const form = document.getElementById('sw-asset-form') as HTMLFormElement;
|
||||
const saveBtn = document.getElementById('btn-save-sw-asset')!;
|
||||
const revertBtn = document.getElementById('btn-revert-sw-edit')!;
|
||||
const deleteBtn = document.getElementById('btn-delete-sw-asset')!;
|
||||
const userAssignBtn = document.getElementById('btn-open-sw-user')!;
|
||||
const btnOpenUpdate = document.getElementById('btn-open-sw-update')!;
|
||||
const typeSelect = document.getElementById('sw-asset-type') as HTMLSelectElement;
|
||||
|
||||
typeSelect?.addEventListener('change', () => {
|
||||
applySwTypeUI(typeSelect.value);
|
||||
});
|
||||
|
||||
['sw-구매일', 'sw-시작일', 'sw-만료일', 'sw-update-start', 'sw-update-end'].forEach(id => {
|
||||
applyDateMask(document.getElementById(id) as HTMLInputElement);
|
||||
});
|
||||
|
||||
createIcons({ icons: { Calendar } });
|
||||
|
||||
const closeModalAction = () => { closeModals(); isEditMode = false; };
|
||||
document.getElementById('btn-close-sw-modal')?.addEventListener('click', closeModalAction);
|
||||
document.getElementById('btn-cancel-sw-modal')?.addEventListener('click', closeModalAction);
|
||||
|
||||
revertBtn.addEventListener('click', () => {
|
||||
setEditLock('sw-asset-form', 'view', {
|
||||
saveBtnId: 'btn-save-sw-asset',
|
||||
revertBtnId: 'btn-revert-sw-edit'
|
||||
});
|
||||
isEditMode = false;
|
||||
if (currentSwAsset) fillSwFormData(currentSwAsset);
|
||||
});
|
||||
|
||||
saveBtn.addEventListener('click', async () => {
|
||||
if (!currentSwAsset) return;
|
||||
if (!isEditMode) {
|
||||
setEditLock('sw-asset-form', 'edit', {
|
||||
saveBtnId: 'btn-save-sw-asset',
|
||||
revertBtnId: 'btn-revert-sw-edit'
|
||||
});
|
||||
isEditMode = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const type = getFieldValue('sw-asset-type');
|
||||
const formData = new FormData(form);
|
||||
const updated: any = { ...currentSwAsset };
|
||||
formData.forEach((value, key) => {
|
||||
updated[key] = value;
|
||||
});
|
||||
|
||||
// Mapping for generic saveAsset
|
||||
let categoryKey = 'swExternal';
|
||||
if (type === '내부SW') categoryKey = 'swInternal';
|
||||
else if (type === '클라우드') categoryKey = 'cloud';
|
||||
|
||||
const success = await saveAsset(categoryKey, updated);
|
||||
if (success) {
|
||||
onSave();
|
||||
closeModalAction();
|
||||
}
|
||||
});
|
||||
|
||||
deleteBtn.addEventListener('click', async () => {
|
||||
if (!currentSwAsset) return;
|
||||
if (!confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return;
|
||||
|
||||
const type = currentSwAsset.asset_type || currentSwAsset.type;
|
||||
let categoryKey = 'swExternal';
|
||||
if (type === '내부SW') categoryKey = 'swInternal';
|
||||
else if (type === '클라우드') categoryKey = 'cloud';
|
||||
|
||||
const success = await deleteAsset(categoryKey, currentSwAsset.id);
|
||||
if (success) {
|
||||
alert('성공적으로 삭제되었습니다.');
|
||||
onSave(); // Refresh list
|
||||
closeModalAction();
|
||||
}
|
||||
});
|
||||
|
||||
userAssignBtn.addEventListener('click', () => {
|
||||
if (currentSwAsset) openSwUserModal(currentSwAsset);
|
||||
});
|
||||
|
||||
// 자산 업데이트(계약 갱신) 모달 로직
|
||||
const subModal = document.getElementById('sw-update-modal')!;
|
||||
const btnCloseUpdate = document.getElementById('btn-close-sw-update')!;
|
||||
const btnCancelUpdate = document.getElementById('btn-cancel-sw-update')!;
|
||||
const btnSaveUpdate = document.getElementById('btn-save-sw-update')!;
|
||||
|
||||
const closeUpdateModal = () => subModal.classList.add('hidden');
|
||||
btnCloseUpdate?.addEventListener('click', closeUpdateModal);
|
||||
btnCancelUpdate?.addEventListener('click', closeUpdateModal);
|
||||
|
||||
btnOpenUpdate?.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (!isEditMode) {
|
||||
alert('자산을 수정 모드로 변경한 후 업데이트를 진행해주세요.');
|
||||
return;
|
||||
}
|
||||
subModal.classList.remove('hidden');
|
||||
});
|
||||
|
||||
btnSaveUpdate?.addEventListener('click', async (e) => {
|
||||
e.preventDefault();
|
||||
const date = (document.getElementById('sw-update-date') as HTMLInputElement).value;
|
||||
const start = (document.getElementById('sw-update-start') as HTMLInputElement).value;
|
||||
const end = (document.getElementById('sw-update-end') as HTMLInputElement).value;
|
||||
const cost = (document.getElementById('sw-update-cost') as HTMLInputElement).value;
|
||||
const note = (document.getElementById('sw-update-note') as HTMLInputElement).value;
|
||||
|
||||
if (start) setFieldValue('sw-시작일', start);
|
||||
if (end) setFieldValue('sw-만료일', end);
|
||||
if (cost) setFieldValue('sw-금액', cost);
|
||||
|
||||
// Save as log
|
||||
const log = {
|
||||
assetId: currentSwAsset.id,
|
||||
date,
|
||||
details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`,
|
||||
user: '관리자'
|
||||
};
|
||||
|
||||
// Call generic API for logs (could be added to state.ts)
|
||||
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify([...state.masterData.logs, log])
|
||||
});
|
||||
|
||||
closeUpdateModal();
|
||||
onSave();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,267 +1,280 @@
|
||||
import { state } from '../../core/state';
|
||||
import { BaseModal } from './BaseModal';
|
||||
import { createIcons, Edit2, X, Paperclip, Calendar, Plus } from 'lucide';
|
||||
import { ORG_LIST } from './SharedData';
|
||||
import { SoftwareAsset, SWUser } from '../../core/excelHandler';
|
||||
import { openModal } from './BaseModal';
|
||||
import { createIcons, Edit2, X, Paperclip, Calendar } from 'lucide';
|
||||
import { CORP_LIST, ORG_LIST } from './SharedData';
|
||||
import { generateOptionsHTML, setFieldValue, getFieldValue, applyDateMask } from './ModalUtils';
|
||||
|
||||
class SwUserModal extends BaseModal {
|
||||
private tempSwUsers: any[] = [];
|
||||
let currentSwUserAsset: SoftwareAsset | null = null;
|
||||
let tempSwUsers: any[] = [];
|
||||
|
||||
constructor() {
|
||||
super('sw-user', '소프트웨어 사용자 관리');
|
||||
}
|
||||
const SW_USER_MODAL_HTML = `
|
||||
<div id="sw-user-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="sw-user-title">소프트웨어 사용자 관리</h2>
|
||||
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="sw-info-summary" id="sw-user-sw-info"></div>
|
||||
|
||||
<div class="user-list-toolbar" style="display:flex; justify-content:space-between; margin-bottom:1rem; align-items:center;">
|
||||
<h3 style="font-size:1rem; font-weight:600;">할당된 사용자 목록</h3>
|
||||
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
|
||||
</div>
|
||||
|
||||
protected renderFrameHTML(): string {
|
||||
return `
|
||||
<div id="sw-user-asset-modal" class="modal-overlay hidden">
|
||||
<div class="modal-content wide">
|
||||
<div class="modal-header">
|
||||
<h2 id="sw-user-title">${this.title}</h2>
|
||||
<button id="btn-close-sw-user-modal" class="btn-icon"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="sw-info-summary" id="sw-user-sw-info"></div>
|
||||
|
||||
<div class="user-list-toolbar" style="display:flex; justify-content:space-between; margin-bottom:1rem; align-items:center;">
|
||||
<h3 style="font-size:1rem; font-weight:600;">할당된 사용자 목록</h3>
|
||||
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>조직</th>
|
||||
<th>부서</th>
|
||||
<th>직위</th>
|
||||
<th>이름</th>
|
||||
<th>사용기간</th>
|
||||
<th>신청서</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sw-user-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- 더미 폼 (BaseModal 필수 요건 충족용) -->
|
||||
<form id="sw-user-asset-form" class="hidden"></form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-cancel-sw-user" class="btn btn-outline">취소</button>
|
||||
<button id="btn-save-sw-user" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>조직</th>
|
||||
<th>부서</th>
|
||||
<th>직위</th>
|
||||
<th>이름</th>
|
||||
<th>사용기간</th>
|
||||
<th>신청서</th>
|
||||
<th>관리</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sw-user-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 사용자 추가/수정 서브 모달 -->
|
||||
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index: 1100;">
|
||||
<div class="modal-content" style="width: 400px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="sw-user-edit-title">사용자 정보</h3>
|
||||
<button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form id="sw-user-edit-form" class="grid-form" style="grid-template-columns: 1fr;">
|
||||
<input type="hidden" id="edit-user-index" value="-1" />
|
||||
<div class="form-group">
|
||||
<label>조직</label>
|
||||
<select id="new-user-조직">${generateOptionsHTML(ORG_LIST)}</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>부서</label>
|
||||
<input type="text" id="new-user-부서" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>직위</label>
|
||||
<input type="text" id="new-user-직위" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>이름</label>
|
||||
<input type="text" id="new-user-이름" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>사용 시작일</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="new-user-시작일" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
</button>
|
||||
<input type="date" id="new-user-시작일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>사용 종료일</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="new-user-종료일" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
</button>
|
||||
<input type="date" id="new-user-종료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>신청서 (증빙)</label>
|
||||
<input type="file" id="new-user-신청서" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-close-user-sub" class="btn btn-outline">취소</button>
|
||||
<button id="btn-confirm-user-edit" class="btn btn-primary">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-cancel-sw-user" class="btn btn-outline">취소</button>
|
||||
<button id="btn-save-sw-user" class="btn btn-primary">저장</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
|
||||
const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
|
||||
const addUserBtn = document.getElementById('btn-open-add-user')!;
|
||||
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
|
||||
|
||||
['new-user-시작일', 'new-user-종료일'].forEach(id => {
|
||||
const el = document.getElementById(id) as HTMLInputElement;
|
||||
if (el) applyDateMask(el);
|
||||
});
|
||||
|
||||
addUserBtn.addEventListener('click', () => this.openUserEditSubModal());
|
||||
confirmUserBtn.addEventListener('click', () => this.saveUserDataToList());
|
||||
|
||||
mainSaveBtn.addEventListener('click', () => {
|
||||
if (!this.currentAsset) return;
|
||||
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === this.currentAsset!.id);
|
||||
const newMapping = {
|
||||
sw_id: this.currentAsset!.id,
|
||||
userData: this.tempSwUsers.map(u => [u.조직, u.부서, u.직위, u.이름, u.사용기간, u.신청서명])
|
||||
};
|
||||
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
|
||||
else state.masterData.swUsers.push(newMapping as any);
|
||||
|
||||
onSave(); this.close(); closeModals();
|
||||
});
|
||||
|
||||
// 닫기 이벤트들 (BaseModal의 공통 버튼 외 추가분)
|
||||
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => this.close());
|
||||
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => this.close());
|
||||
|
||||
const subModal = document.getElementById('sw-user-edit-modal')!;
|
||||
const closeSub = () => subModal.classList.add('hidden');
|
||||
document.getElementById('btn-close-user-edit')?.addEventListener('click', closeSub);
|
||||
document.getElementById('btn-close-user-sub')?.addEventListener('click', closeSub);
|
||||
|
||||
createIcons({ icons: { X, Plus, Calendar, Edit2, Paperclip } });
|
||||
}
|
||||
|
||||
protected fillFormData(asset: any): void {
|
||||
const swInfo = document.getElementById('sw-user-sw-info')!;
|
||||
swInfo.innerHTML = `
|
||||
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
|
||||
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.purchase_corp || asset.법인 || ''}</div>
|
||||
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.product_name || asset.제품명 || ''}</div>
|
||||
<!-- 사용자 추가/수정 서브 모달 -->
|
||||
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index:1100;">
|
||||
<div class="modal-content" style="width:400px;">
|
||||
<div class="modal-header">
|
||||
<h3 id="sw-user-edit-title">사용자 정보</h3>
|
||||
<button id="btn-close-user-edit" class="btn-icon"><i data-lucide="x"></i></button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
|
||||
this.tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
|
||||
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
|
||||
})) : [];
|
||||
|
||||
this.renderUserList();
|
||||
}
|
||||
|
||||
protected onAfterOpen(): void {}
|
||||
|
||||
private renderUserList() {
|
||||
const tbody = document.getElementById('sw-user-table-body')!;
|
||||
tbody.innerHTML = '';
|
||||
if (this.tempSwUsers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
this.tempSwUsers.forEach((user, idx) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${user.조직 || ''}</td>
|
||||
<td>${user.부서 || ''}</td>
|
||||
<td>${user.직위 || ''}</td>
|
||||
<td>${user.이름 || ''}</td>
|
||||
<td>${user.사용기간 || ''}</td>
|
||||
<td style="text-align:center;">${user.신청서명 ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
|
||||
<td>
|
||||
<div style="display:flex; gap:0.5rem;">
|
||||
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
|
||||
<button class="btn btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button>
|
||||
<div class="modal-body">
|
||||
<form id="sw-user-edit-form" class="grid-form" style="grid-template-columns: 1fr;">
|
||||
<input type="hidden" id="edit-user-index" value="-1" />
|
||||
<div class="form-group">
|
||||
<label>조직</label>
|
||||
<select id="new-user-조직">${generateOptionsHTML(ORG_LIST)}</select>
|
||||
</div>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
<div class="form-group">
|
||||
<label>부서</label>
|
||||
<input type="text" id="new-user-부서" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>직위</label>
|
||||
<input type="text" id="new-user-직위" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>이름</label>
|
||||
<input type="text" id="new-user-이름" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>사용 시작일</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="new-user-시작일" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
</button>
|
||||
<input type="date" id="new-user-시작일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>사용 종료일</label>
|
||||
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;">
|
||||
<input type="text" id="new-user-종료일" style="flex:1;" />
|
||||
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();" style="padding:0.25rem;">
|
||||
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i>
|
||||
</button>
|
||||
<input type="date" id="new-user-종료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>신청서 (증빙)</label>
|
||||
<input type="file" id="new-user-신청서" />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button id="btn-close-user-sub" class="btn btn-outline">취소</button>
|
||||
<button id="btn-confirm-user-edit" class="btn btn-primary">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
|
||||
this.openUserEditSubModal(idx);
|
||||
});
|
||||
});
|
||||
export function openSwUserModal(asset: SoftwareAsset) {
|
||||
currentSwUserAsset = asset;
|
||||
const modal = document.getElementById('sw-user-modal')!;
|
||||
|
||||
const swInfo = document.getElementById('sw-user-sw-info')!;
|
||||
swInfo.innerHTML = `
|
||||
<div style="background:var(--bg-light); padding:1rem; border-radius:6px; margin-bottom:1.5rem;">
|
||||
<div style="font-size:0.8rem; color:var(--text-muted); margin-bottom:0.25rem;">${asset.법인}</div>
|
||||
<div style="font-size:1.1rem; font-weight:700; color:var(--primary-color);">${asset.제품명}</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
tbody.querySelectorAll('.btn-del-user').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
|
||||
if (confirm('사용자 할당을 삭제하시겠습니까?')) {
|
||||
this.tempSwUsers.splice(idx, 1); this.renderUserList();
|
||||
}
|
||||
});
|
||||
});
|
||||
createIcons({ icons: { Paperclip } });
|
||||
}
|
||||
|
||||
private openUserEditSubModal(idx: number = -1) {
|
||||
const subModal = document.getElementById('sw-user-edit-modal')!;
|
||||
const form = document.getElementById('sw-user-edit-form') as HTMLFormElement;
|
||||
form.reset();
|
||||
setFieldValue('edit-user-index', idx);
|
||||
if (idx > -1) {
|
||||
const user = this.tempSwUsers[idx];
|
||||
setFieldValue('new-user-조직', user.조직);
|
||||
setFieldValue('new-user-부서', user.부서);
|
||||
setFieldValue('new-user-직위', user.직위);
|
||||
setFieldValue('new-user-이름', user.이름);
|
||||
if (user.사용기간 && user.사용기간.includes('~')) {
|
||||
const parts = user.사용기간.split('~');
|
||||
setFieldValue('new-user-시작일', parts[0].trim());
|
||||
setFieldValue('new-user-종료일', parts[1].trim());
|
||||
}
|
||||
}
|
||||
subModal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
private saveUserDataToList() {
|
||||
const idx = parseInt(getFieldValue('edit-user-index'));
|
||||
const 신청서Input = document.getElementById('new-user-신청서') as HTMLInputElement;
|
||||
const 신청서명 = 신청서Input.files && 신청서Input.files.length > 0 ? 신청서Input.files[0].name : (idx > -1 ? this.tempSwUsers[idx].신청서명 : '');
|
||||
|
||||
const userData: any = {
|
||||
조직: getFieldValue('new-user-조직'),
|
||||
부서: getFieldValue('new-user-부서'),
|
||||
직위: getFieldValue('new-user-직위'),
|
||||
이름: getFieldValue('new-user-이름'),
|
||||
사용기간: `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`,
|
||||
신청서명
|
||||
};
|
||||
if (idx === -1) this.tempSwUsers.push(userData);
|
||||
else this.tempSwUsers[idx] = userData;
|
||||
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
|
||||
this.renderUserList();
|
||||
}
|
||||
// 기존 사용자 데이터 복사 (원본 보호를 위해 temp 사용)
|
||||
const existingMapping = state.masterData.swUsers.find(u => u.sw_id === asset.id);
|
||||
tempSwUsers = existingMapping ? (existingMapping.userData || []).map((u: any) => ({
|
||||
조직: u[0], 부서: u[1], 직위: u[2], 이름: u[3], 사용기간: u[4], 신청서명: u[5]
|
||||
})) : [];
|
||||
|
||||
renderUserList();
|
||||
modal.classList.remove('hidden');
|
||||
createIcons({ icons: { Edit2, X, Paperclip } });
|
||||
}
|
||||
|
||||
export const swUserModal = new SwUserModal();
|
||||
function renderUserList() {
|
||||
const tbody = document.getElementById('sw-user-table-body')!;
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (tempSwUsers.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center; padding:2rem; color:var(--text-muted);">할당된 사용자가 없습니다.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tempSwUsers.forEach((user, idx) => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.innerHTML = `
|
||||
<td>${user.조직 || ''}</td>
|
||||
<td>${user.부서 || ''}</td>
|
||||
<td>${user.직위 || ''}</td>
|
||||
<td>${user.이름 || ''}</td>
|
||||
<td>${user.사용기간 || ''}</td>
|
||||
<td style="text-align:center;">${user.신청서명 ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
|
||||
<td>
|
||||
<div style="display:flex; gap:0.5rem;">
|
||||
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
|
||||
<button class="btn btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button>
|
||||
</div>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
|
||||
// 이벤트 연결
|
||||
tbody.querySelectorAll('.btn-edit-user').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
|
||||
openUserEditSubModal(idx);
|
||||
});
|
||||
});
|
||||
|
||||
tbody.querySelectorAll('.btn-del-user').forEach(btn => {
|
||||
btn.addEventListener('click', (e) => {
|
||||
const idx = parseInt((e.currentTarget as HTMLElement).getAttribute('data-idx')!);
|
||||
if (confirm('사용자 할당을 삭제하시겠습니까?')) {
|
||||
tempSwUsers.splice(idx, 1);
|
||||
renderUserList();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
createIcons({ icons: { Paperclip } });
|
||||
}
|
||||
|
||||
function openUserEditSubModal(idx: number = -1) {
|
||||
const subModal = document.getElementById('sw-user-edit-modal')!;
|
||||
const form = document.getElementById('sw-user-edit-form') as HTMLFormElement;
|
||||
form.reset();
|
||||
|
||||
setFieldValue('edit-user-index', idx);
|
||||
|
||||
if (idx > -1) {
|
||||
const user = tempSwUsers[idx];
|
||||
setFieldValue('new-user-조직', user.조직);
|
||||
setFieldValue('new-user-부서', user.부서);
|
||||
setFieldValue('new-user-직위', user.직위);
|
||||
setFieldValue('new-user-이름', user.이름);
|
||||
|
||||
// 사용기간 파싱 (yyyy-mm-dd ~ yyyy-mm-dd)
|
||||
if (user.사용기간 && user.사용기간.includes('~')) {
|
||||
const parts = user.사용기간.split('~');
|
||||
setFieldValue('new-user-시작일', parts[0].trim());
|
||||
setFieldValue('new-user-종료일', parts[1].trim());
|
||||
} else {
|
||||
setFieldValue('new-user-시작일', '');
|
||||
setFieldValue('new-user-종료일', '');
|
||||
}
|
||||
}
|
||||
|
||||
subModal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
export function initSwUserModal(onSave: () => void, closeModals: () => void) {
|
||||
swUserModal.init(onSave, closeModals);
|
||||
if (!document.getElementById('sw-user-modal')) {
|
||||
document.body.insertAdjacentHTML('beforeend', SW_USER_MODAL_HTML);
|
||||
}
|
||||
|
||||
const mainSaveBtn = document.getElementById('btn-save-sw-user')!;
|
||||
const addUserBtn = document.getElementById('btn-open-add-user')!;
|
||||
const confirmUserBtn = document.getElementById('btn-confirm-user-edit')!;
|
||||
|
||||
['new-user-시작일', 'new-user-종료일'].forEach(id => {
|
||||
applyDateMask(document.getElementById(id) as HTMLInputElement);
|
||||
});
|
||||
|
||||
createIcons({ icons: { Calendar } });
|
||||
|
||||
addUserBtn.addEventListener('click', () => openUserEditSubModal());
|
||||
|
||||
confirmUserBtn.addEventListener('click', () => {
|
||||
saveUserDataToList();
|
||||
});
|
||||
|
||||
mainSaveBtn.addEventListener('click', () => {
|
||||
if (!currentSwUserAsset) return;
|
||||
|
||||
// 전역 상태 업데이트
|
||||
const existingIdx = state.masterData.swUsers.findIndex(u => u.sw_id === currentSwUserAsset!.id);
|
||||
const newMapping = {
|
||||
sw_id: currentSwUserAsset!.id,
|
||||
userData: tempSwUsers.map(u => [u.조직, u.부서, u.직위, u.이름, u.사용기간, u.신청서명])
|
||||
};
|
||||
|
||||
if (existingIdx > -1) state.masterData.swUsers[existingIdx] = newMapping as any;
|
||||
else state.masterData.swUsers.push(newMapping as any);
|
||||
|
||||
onSave();
|
||||
document.getElementById('sw-user-modal')?.classList.add('hidden');
|
||||
});
|
||||
|
||||
document.getElementById('btn-close-sw-user-modal')?.addEventListener('click', () => {
|
||||
document.getElementById('sw-user-modal')?.classList.add('hidden');
|
||||
});
|
||||
document.getElementById('btn-cancel-sw-user')?.addEventListener('click', () => {
|
||||
document.getElementById('sw-user-modal')?.classList.add('hidden');
|
||||
});
|
||||
document.getElementById('btn-close-user-edit')?.addEventListener('click', () => {
|
||||
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
|
||||
});
|
||||
document.getElementById('btn-close-user-sub')?.addEventListener('click', () => {
|
||||
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
|
||||
});
|
||||
}
|
||||
|
||||
export function openSwUserModal(asset: any) {
|
||||
swUserModal.open(asset);
|
||||
function saveUserDataToList() {
|
||||
const idx = parseInt(getFieldValue('edit-user-index'));
|
||||
const 신청서Input = document.getElementById('new-user-신청서') as HTMLInputElement;
|
||||
const 신청서명 = 신청서Input.files && 신청서Input.files.length > 0 ? 신청서Input.files[0].name : (idx > -1 ? tempSwUsers[idx].신청서명 : '');
|
||||
|
||||
const userData: any = {
|
||||
조직: getFieldValue('new-user-조직'),
|
||||
부서: getFieldValue('new-user-부서'),
|
||||
직위: getFieldValue('new-user-직위'),
|
||||
이름: getFieldValue('new-user-이름'),
|
||||
사용기간: `${getFieldValue('new-user-시작일')} ~ ${getFieldValue('new-user-종료일')}`,
|
||||
신청서명
|
||||
};
|
||||
|
||||
if (idx === -1) tempSwUsers.push(userData);
|
||||
else tempSwUsers[idx] = userData;
|
||||
|
||||
document.getElementById('sw-user-edit-modal')?.classList.add('hidden');
|
||||
renderUserList();
|
||||
}
|
||||
|
||||
@@ -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', '사이니지룸'],
|
||||
'기술개발센터': ['서버실', '1층', '기타'],
|
||||
'유니온빌딩': ['4층', '5층', '6층'],
|
||||
'뉴코아빌딩': ['4층', '6층', '7층'],
|
||||
'IDC': ['서관202', '서관203', '서관204', '서관205', '동관53', '동관54']
|
||||
@@ -38,35 +38,8 @@ export const LOCATION_DATA: Record<string, string[]> = {
|
||||
|
||||
// 유형별 자산번호 접두사(Prefix) 매핑
|
||||
export const TYPE_PREFIX_MAP: Record<string, string> = {
|
||||
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO',
|
||||
'서버': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC', 'NAS': 'NAS', 'DAS': 'DAS', '스토리지': 'STO',
|
||||
'HDD': 'HDD', 'SSD': 'SSD', '노트북': 'NBK', '태블릿': 'TAB',
|
||||
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
|
||||
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'INT'
|
||||
};
|
||||
|
||||
// 배치도 이미지 매핑 데이터
|
||||
export const IMAGE_LOCATIONS: Record<string, Record<string, string[]>> = {
|
||||
'IDC': {
|
||||
'서관202': ['img/location_photo/IDC/서관202.png'],
|
||||
'서관203': ['img/location_photo/IDC/서관203.png'],
|
||||
'서관204': ['img/location_photo/IDC/서관204.png'],
|
||||
'서관205': ['img/location_photo/IDC/서관205.png'],
|
||||
'동관53': ['img/location_photo/IDC/동관53.png'],
|
||||
'동관54': ['img/location_photo/IDC/동관54.png'],
|
||||
},
|
||||
'기술개발센터': {
|
||||
'서버실': [
|
||||
'img/location_photo/기술개발센터/서버실/서버실_1.png',
|
||||
'img/location_photo/기술개발센터/서버실/서버실_2.png'
|
||||
]
|
||||
},
|
||||
'한맥빌딩': {
|
||||
'7층': ['img/location_photo/한맥빌딩/7층_로비.png'],
|
||||
'MDF실': [
|
||||
'img/location_photo/한맥빌딩/MDF실/MDF_1.png',
|
||||
'img/location_photo/한맥빌딩/MDF실/MDF_2.png',
|
||||
'img/location_photo/한맥빌딩/MDF실/MDF_3.png',
|
||||
'img/location_photo/한맥빌딩/MDF실/MDF_4.png'
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,7 +3,7 @@ import { state } from '../core/state';
|
||||
const MENU_CONFIG: any = {
|
||||
hw: {
|
||||
label: '하드웨어',
|
||||
tabs: ['서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비']
|
||||
tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비']
|
||||
},
|
||||
sw: {
|
||||
label: '소프트웨어',
|
||||
@@ -86,7 +86,7 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
|
||||
adminTrigger.style.paddingLeft = '1.5rem';
|
||||
|
||||
adminTrigger.addEventListener('click', () => {
|
||||
window.open('/map_editor.html', '_blank');
|
||||
alert('준비중입니다.');
|
||||
});
|
||||
|
||||
adminGroup.appendChild(adminTrigger);
|
||||
|
||||
10695
src/core/dummyData.ts
Normal file
@@ -14,26 +14,18 @@ export interface FilterOptions {
|
||||
showDept?: boolean;
|
||||
showLoc?: boolean;
|
||||
showField?: boolean;
|
||||
showType?: boolean;
|
||||
extraHTML?: string;
|
||||
onFilterChange: (filters: any) => void;
|
||||
}
|
||||
|
||||
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, extraHTML = '', onFilterChange } = options;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="search-item flex-1">
|
||||
<label>${keywordLabel}</label>
|
||||
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off">
|
||||
</div>
|
||||
${showType ? `
|
||||
<div class="search-item">
|
||||
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
|
||||
<select id="filter-type">
|
||||
<option value="">전체 유형</option>
|
||||
</select>
|
||||
</div>` : ''}
|
||||
${showField ? `
|
||||
<div class="search-item">
|
||||
<label>${ASSET_SCHEMA.SW_FIELD.ui}</label>
|
||||
@@ -74,8 +66,7 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
||||
corp: (container.querySelector('#filter-corp') as HTMLSelectElement)?.value || '',
|
||||
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 || ''
|
||||
field: (container.querySelector('#filter-field') as HTMLSelectElement)?.value || ''
|
||||
};
|
||||
onFilterChange(filters);
|
||||
};
|
||||
@@ -85,10 +76,9 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
|
||||
container.querySelector('#filter-dept')?.addEventListener('change', triggerChange);
|
||||
container.querySelector('#filter-loc')?.addEventListener('change', triggerChange);
|
||||
container.querySelector('#filter-field')?.addEventListener('change', triggerChange);
|
||||
container.querySelector('#filter-type')?.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'].forEach(id => {
|
||||
const el = container.querySelector(`#${id}`);
|
||||
if (el) (el as any).value = '';
|
||||
});
|
||||
@@ -108,8 +98,7 @@ export function applyCommonFilters(list: any[], filters: any, searchKeys: (keyof
|
||||
const matchDept = !filters.dept || (item[ASSET_SCHEMA.CURRENT_DEPT.key] || item[ASSET_SCHEMA.CURRENT_DEPT.db]) === filters.dept;
|
||||
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;
|
||||
|
||||
return matchKeyword && matchCorp && matchDept && matchLoc && matchField && matchType;
|
||||
return matchKeyword && matchCorp && matchDept && matchLoc && matchField;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,9 +21,6 @@ export const ASSET_SCHEMA = {
|
||||
MANAGER_SUB: { key: 'manager_secondary', db: 'manager_secondary', ui: '담당자(부)' },
|
||||
LOCATION: { key: 'location', db: 'location', ui: '자산위치' },
|
||||
LOC_DETAIL: { key: 'location_detail', db: 'location_detail', ui: '상세위치' },
|
||||
LOCATION_PHOTO: { key: 'location_photo', db: 'location_photo', ui: '배치도이미지' },
|
||||
LOC_X: { key: 'loc_x', db: 'loc_x', ui: '위치X' },
|
||||
LOC_Y: { key: 'loc_y', db: 'loc_y', ui: '위치Y' },
|
||||
MEMO: { key: 'memo', db: 'memo', ui: '메모' },
|
||||
|
||||
// ─── 하드웨어 상세 (Hardware) ───
|
||||
|
||||
@@ -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 {
|
||||
@@ -38,15 +39,13 @@ export interface AppState {
|
||||
activeSubTab: string;
|
||||
masterData: MasterAssetData;
|
||||
activeCharts: any[];
|
||||
currentUserRole: 'admin' | 'user';
|
||||
}
|
||||
|
||||
// 초기 상태
|
||||
export const state: AppState = {
|
||||
activeCategory: 'hw',
|
||||
activeSubTab: '서버', // 대시보드 제거됨에 따라 기본값 변경
|
||||
activeSubTab: '대시보드',
|
||||
activeCharts: [],
|
||||
currentUserRole: 'user',
|
||||
masterData: {
|
||||
users: [],
|
||||
pc: [], server: [], storage: [], network: [],
|
||||
@@ -60,39 +59,27 @@ export const state: AppState = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 신규 14개 테이블 구조에 맞춘 데이터 로드
|
||||
* 신규 14개 테이블 구조에 맞춘 데이터 로드 (Dummy Data)
|
||||
*/
|
||||
export async function loadMasterDataFromDB() {
|
||||
try {
|
||||
const endpoints = [
|
||||
{ key: 'users', url: '/api/users' },
|
||||
{ key: 'pc', url: '/api/pc' },
|
||||
{ key: 'server', url: '/api/server' },
|
||||
{ key: 'storage', url: '/api/storage' },
|
||||
{ key: 'network', url: '/api/network' },
|
||||
{ key: 'survey', url: '/api/survey' },
|
||||
{ key: 'pcParts', url: '/api/pc-parts' },
|
||||
{ key: 'equipment', url: '/api/equipment' },
|
||||
{ key: 'officeSupplies', url: '/api/office-supplies' },
|
||||
{ key: 'swInternal', url: '/api/sw/internal' },
|
||||
{ key: 'swExternal', url: '/api/sw/external' },
|
||||
{ key: 'cloud', url: '/api/cloud' },
|
||||
{ key: 'domain', url: '/api/domain' },
|
||||
{ key: 'cost', url: '/api/cost' },
|
||||
{ key: 'vip', url: '/api/vip' },
|
||||
{ key: 'swUsers', url: '/api/asset/software/assignment' },
|
||||
{ key: 'logs', url: '/api/asset/history' }
|
||||
];
|
||||
|
||||
const results = await Promise.all(endpoints.map(e => fetch(API_BASE_URL + e.url)));
|
||||
|
||||
for (let i = 0; i < endpoints.length; i++) {
|
||||
if (results[i].ok) {
|
||||
const data = await results[i].json();
|
||||
const key = endpoints[i].key;
|
||||
(state.masterData as any)[key] = Array.isArray(data) ? data : [];
|
||||
}
|
||||
}
|
||||
state.masterData.pc = dummyPCs || [];
|
||||
state.masterData.server = dummyServers || [];
|
||||
state.masterData.storage = dummyStorages || [];
|
||||
state.masterData.network = dummyEquips || []; // dummy fallback
|
||||
state.masterData.survey = [];
|
||||
state.masterData.pcParts = [];
|
||||
state.masterData.equipment = dummyEquips || [];
|
||||
state.masterData.officeSupplies = [];
|
||||
state.masterData.swInternal = dummyPermSw || [];
|
||||
state.masterData.swExternal = dummySubSw || [];
|
||||
state.masterData.cloud = dummyCloud || [];
|
||||
state.masterData.domain = dummyDomain || [];
|
||||
state.masterData.cost = [];
|
||||
state.masterData.vip = [];
|
||||
state.masterData.swUsers = dummySwUsers || [];
|
||||
state.masterData.logs = dummyLogs || [];
|
||||
state.masterData.users = [];
|
||||
|
||||
// Mapping for backward compatibility
|
||||
state.masterData.equip = state.masterData.equipment;
|
||||
@@ -117,10 +104,10 @@ export async function loadMasterDataFromDB() {
|
||||
...state.masterData.cloud
|
||||
];
|
||||
|
||||
console.log('✅ All data (including users) loaded and unified');
|
||||
console.log('✅ All dummy data loaded and unified');
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('⚠️ 서버 연결 실패:', err);
|
||||
console.warn('⚠️ Dummy 로드 실패:', err);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -130,45 +117,18 @@ export function updateState(newState: Partial<AppState>) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 자산 저장 (Generic API)
|
||||
* 자산 저장 (Dummy API)
|
||||
*/
|
||||
export async function saveAsset(category: string, asset: any) {
|
||||
try {
|
||||
const endpointMap: Record<string, string> = {
|
||||
'users': '/api/users/batch',
|
||||
'pc': '/api/pc/batch',
|
||||
'server': '/api/server/batch',
|
||||
'storage': '/api/storage/batch',
|
||||
'network': '/api/network/batch',
|
||||
'survey': '/api/survey/batch',
|
||||
'pcParts': '/api/pc-parts/batch',
|
||||
'equipment': '/api/equipment/batch',
|
||||
'officeSupplies': '/api/office-supplies/batch',
|
||||
'swInternal': '/api/sw/internal/batch',
|
||||
'swExternal': '/api/sw/external/batch',
|
||||
'cloud': '/api/cloud/batch',
|
||||
'domain': '/api/domain/batch',
|
||||
'cost': '/api/cost/batch',
|
||||
'vip': '/api/vip/batch'
|
||||
};
|
||||
|
||||
const url = `${API_BASE_URL}${endpointMap[category]}`;
|
||||
const currentList = [...(state.masterData as any)[category]];
|
||||
const idx = currentList.findIndex(a => a.id === asset.id);
|
||||
|
||||
if (idx > -1) currentList[idx] = asset;
|
||||
else currentList.push(asset);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(currentList)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
(state.masterData as any)[category] = currentList;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('자산 저장 실패:', err);
|
||||
}
|
||||
@@ -176,42 +136,14 @@ export async function saveAsset(category: string, asset: any) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 자산 삭제 (Generic API - Batch 방식 활용)
|
||||
* 자산 삭제 (Dummy API)
|
||||
*/
|
||||
export async function deleteAsset(category: string, assetId: string) {
|
||||
try {
|
||||
const endpointMap: Record<string, string> = {
|
||||
'users': '/api/users/batch',
|
||||
'pc': '/api/pc/batch',
|
||||
'server': '/api/server/batch',
|
||||
'storage': '/api/storage/batch',
|
||||
'network': '/api/network/batch',
|
||||
'survey': '/api/survey/batch',
|
||||
'pcParts': '/api/pc-parts/batch',
|
||||
'equipment': '/api/equipment/batch',
|
||||
'officeSupplies': '/api/office-supplies/batch',
|
||||
'swInternal': '/api/sw/internal/batch',
|
||||
'swExternal': '/api/sw/external/batch',
|
||||
'cloud': '/api/cloud/batch',
|
||||
'domain': '/api/domain/batch',
|
||||
'cost': '/api/cost/batch',
|
||||
'vip': '/api/vip/batch'
|
||||
};
|
||||
|
||||
const url = `${API_BASE_URL}${endpointMap[category]}`;
|
||||
const currentList = [...(state.masterData as any)[category]];
|
||||
const filteredList = currentList.filter(a => a.id !== assetId);
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(filteredList)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
await loadMasterDataFromDB(); // 전역 상태 갱신
|
||||
return true;
|
||||
}
|
||||
(state.masterData as any)[category] = filteredList;
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('자산 삭제 실패:', err);
|
||||
}
|
||||
|
||||
91
src/main.ts
@@ -14,16 +14,7 @@ import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Lapto
|
||||
// --- 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 저장 완료`);
|
||||
console.log(`✅ ${label} DB 저장 완료 (Dummy Mode: ${data?.length || 0} items)`);
|
||||
} catch (err) {
|
||||
console.error(`❌ ${label} DB 저장 오류:`, err);
|
||||
alert(`${label} 저장 중 오류가 발생했습니다: ${(err as any).message}`);
|
||||
@@ -83,14 +74,15 @@ function initApp() {
|
||||
|
||||
initHwModal(() => saveAllDataToDB(), closeAllModals);
|
||||
initSwModal(() => saveAllDataToDB(), closeAllModals);
|
||||
|
||||
initSwUserModal(() => {
|
||||
saveSwUsersToDB().then(() => {
|
||||
loadMasterDataFromDB().then(() => refreshView());
|
||||
});
|
||||
}, closeAllModals);
|
||||
initDomainModal(() => saveAllDataToDB(), closeAllModals);
|
||||
|
||||
|
||||
initDashboardDetailModal();
|
||||
initDomainModal();
|
||||
initGuide();
|
||||
|
||||
loadMasterDataFromDB().then((success) => {
|
||||
@@ -136,77 +128,4 @@ function initApp() {
|
||||
window.addEventListener('refresh-view', () => refreshView());
|
||||
}
|
||||
|
||||
/**
|
||||
* 헤더 역할 전환 토글 로직
|
||||
*/
|
||||
function initRoleSwitcher() {
|
||||
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
|
||||
const userLabel = document.querySelector('.role-label.user');
|
||||
const adminLabel = document.querySelector('.role-label.admin');
|
||||
|
||||
if (!checkbox || !userLabel || !adminLabel) return;
|
||||
|
||||
checkbox.addEventListener('change', () => {
|
||||
if (checkbox.checked) {
|
||||
alert('관리자 모드는 현재 준비 중입니다. 나중에 관리자 전용 페이지와 연결될 예정입니다.');
|
||||
checkbox.checked = false; // UI 강제 되돌리기
|
||||
return;
|
||||
}
|
||||
|
||||
// 실무자 모드 전환 (현재는 Admin 진입이 차단되므로 사실상 fallback 로직)
|
||||
state.currentUserRole = 'user';
|
||||
adminLabel.classList.remove('active');
|
||||
userLabel.classList.add('active');
|
||||
document.body.classList.remove('admin-mode');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 로그인 처리 로직
|
||||
*/
|
||||
function handleLogin() {
|
||||
const loginContainer = document.getElementById('login-container');
|
||||
const appLayout = document.getElementById('app-layout');
|
||||
const roleCards = document.querySelectorAll('.role-card');
|
||||
|
||||
if (!loginContainer || !appLayout || roleCards.length === 0) return;
|
||||
|
||||
roleCards.forEach(card => {
|
||||
card.addEventListener('click', () => {
|
||||
const role = card.getAttribute('data-role');
|
||||
|
||||
if (role === 'admin') {
|
||||
alert('관리자 모드는 현재 준비 중입니다. 실무자 모드를 이용해 주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
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';
|
||||
|
||||
// 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(); // 즉시 초기화면으로 복귀
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', handleLogin);
|
||||
document.addEventListener('DOMContentLoaded', initApp);
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import './styles/common.css';
|
||||
import './styles/map-editor.css';
|
||||
import { MapEditor } from './views/MapEditor';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const editor = new MapEditor();
|
||||
editor.init();
|
||||
});
|
||||
@@ -41,10 +41,9 @@
|
||||
--color-yellow-medium: #FFE599;
|
||||
--color-orange-medium: #FFD699;
|
||||
--color-dahong-medium: #FFB199;
|
||||
--color-dahong: #FF3D00;
|
||||
--color-dahong-light: #FFECE6;
|
||||
--color-dahong-medium: #FFB199;
|
||||
--color-dahong-dark: #cc3100;
|
||||
--color-brown-medium: #D9C6BF;
|
||||
--color-iron-medium: #CCCCCC;
|
||||
--color-steel-medium: #C3CFD5;
|
||||
|
||||
/* --- Primary Brand Levels --- */
|
||||
--primary-lv-0: #E9EEED;
|
||||
@@ -58,16 +57,11 @@
|
||||
--primary-lv-8: #193833;
|
||||
--primary-lv-9: #162A27;
|
||||
|
||||
/* --- Semantic Colors --- */
|
||||
/* --- Legacy Aliases (Maintained for compatibility) --- */
|
||||
--primary-color: var(--primary-lv-6);
|
||||
--primary-hover: var(--primary-lv-5);
|
||||
--primary-light: var(--primary-lv-0);
|
||||
|
||||
--edit-mode-color: var(--color-dahong);
|
||||
--edit-mode-light: var(--color-dahong-light);
|
||||
--edit-mode-focus: var(--color-dahong-medium);
|
||||
--edit-mode-dark: var(--color-dahong-dark);
|
||||
|
||||
--text-main: #111827;
|
||||
--text-muted: #6B7280;
|
||||
--border-color: #E5E7EB;
|
||||
@@ -76,16 +70,13 @@
|
||||
--sidebar-bg: #ffffff;
|
||||
--white: #FFFFFF;
|
||||
--danger: var(--color-red);
|
||||
--info: var(--color-blue);
|
||||
--success: var(--color-green);
|
||||
--warning: var(--color-orange);
|
||||
|
||||
--dash-primary: #6cc020;
|
||||
--dash-light: #f2f9ec;
|
||||
--dash-danger: #cf222e;
|
||||
|
||||
--header-height: 52px;
|
||||
}
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
@@ -100,7 +91,7 @@ body {
|
||||
color: var(--text-main);
|
||||
background-color: var(--bg-color);
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
font-size: 19px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -229,85 +220,6 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Role Switcher Toggle --- */
|
||||
.role-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-right: 0.5rem;
|
||||
padding: 0 0.75rem;
|
||||
border-right: 1px solid var(--border-color);
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
.role-label {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.role-label.active {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.role-label.admin.active {
|
||||
color: var(--color-orange);
|
||||
}
|
||||
|
||||
/* Toggle Switch Base */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 34px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: #ccc;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
left: 3px;
|
||||
bottom: 3px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
}
|
||||
|
||||
input:checked + .slider {
|
||||
background-color: var(--color-orange);
|
||||
}
|
||||
|
||||
input:focus + .slider {
|
||||
box-shadow: 0 0 1px var(--color-orange);
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.slider.round {
|
||||
border-radius: 34px;
|
||||
}
|
||||
|
||||
.slider.round:before {
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
/* --- Global Actions & Buttons --- */
|
||||
.header-actions {
|
||||
display: flex;
|
||||
@@ -375,7 +287,7 @@ input:checked + .slider:before {
|
||||
|
||||
/* --- Footer --- */
|
||||
.main-footer {
|
||||
height: 40px;
|
||||
height: 28px;
|
||||
background-color: var(--white);
|
||||
border-top: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
@@ -391,7 +303,7 @@ input:checked + .slider:before {
|
||||
font-weight: 300;
|
||||
line-height: 1.25rem;
|
||||
letter-spacing: -0.0175rem;
|
||||
color: var(--text-muted);
|
||||
color: #777777;
|
||||
user-select: none;
|
||||
pointer-events: all;
|
||||
-webkit-user-drag: none;
|
||||
@@ -412,7 +324,7 @@ input:checked + .slider:before {
|
||||
.badge {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -429,7 +341,7 @@ input:checked + .slider:before {
|
||||
|
||||
.text-tag {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
font-size: 16px;
|
||||
padding: 1px 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
|
||||
@@ -1,39 +1,36 @@
|
||||
/* --- 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;
|
||||
font-size: 1.55rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.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);
|
||||
/* Premium Glassmorphism Card Style */
|
||||
.dashboard-card, .stat-card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.07);
|
||||
border-radius: 12px;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: transform 0.2s ease, box-shadow 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: translateY(-5px);
|
||||
box-shadow: 0 12px 40px rgba(31, 38, 135, 0.12);
|
||||
}
|
||||
|
||||
.dashboard-layout-2col {
|
||||
@@ -42,14 +39,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-card canvas {
|
||||
@@ -57,3 +54,142 @@
|
||||
width: 100% !important;
|
||||
max-height: 280px;
|
||||
}
|
||||
|
||||
/* Premium KPI Value Styling */
|
||||
.stat-value {
|
||||
font-size: 2.2rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.stat-value-danger {
|
||||
background: linear-gradient(135deg, #E11D48 0%, #F59E0B 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 1.15rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
.table-premium {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 15px rgba(0,0,0,0.05);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-premium table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table-premium th {
|
||||
background: #F8FAFC;
|
||||
color: #475569;
|
||||
font-weight: 700;
|
||||
padding: 1rem;
|
||||
text-transform: uppercase;
|
||||
font-size: 0.75rem;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.table-premium td {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #E2E8F0;
|
||||
color: #1E293B;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.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);
|
||||
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;
|
||||
}
|
||||
|
||||
.slider-indicator {
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.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: 200%; /* For 2 pages */
|
||||
}
|
||||
|
||||
.dashboard-slide {
|
||||
width: 50%; /* 100% / 2 pages */
|
||||
flex-shrink: 0;
|
||||
padding: 0 2px; /* Slight padding to avoid cutting off box-shadows */
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
/* Login Screen Styles */
|
||||
|
||||
.login-layout {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100vh;
|
||||
background-color: var(--bg-color);
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
background-color: var(--white);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 12px;
|
||||
padding: 3rem;
|
||||
box-shadow: 0 4px 30px rgba(0, 0, 0, 0.08);
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.login-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2.5rem;
|
||||
}
|
||||
|
||||
.login-logo {
|
||||
height: 52px;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.login-header h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 800;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.login-header p {
|
||||
font-size: 0.9375rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.login-selection {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2rem 1.5rem;
|
||||
border: 2px solid var(--bg-light);
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
background-color: var(--bg-light);
|
||||
}
|
||||
|
||||
.role-card:hover {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--white);
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 10px 20px rgba(30, 81, 73, 0.08);
|
||||
}
|
||||
|
||||
.role-icon {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
background-color: var(--white);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 1.25rem;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid var(--border-color);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.role-card:hover .role-icon {
|
||||
background-color: var(--primary-color);
|
||||
color: var(--white);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.role-card h3 {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-main);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.role-card p {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.login-footer {
|
||||
margin-top: 3rem;
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
/* ITAM Map Coordinate Editor Styles */
|
||||
|
||||
.file-sidebar {
|
||||
width: 260px;
|
||||
background: var(--white);
|
||||
border-right: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.folder-item {
|
||||
padding: 10px 15px;
|
||||
background: var(--bg-light);
|
||||
font-weight: bold;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.file-item {
|
||||
padding: 8px 25px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
border-bottom: 1px solid var(--bg-color);
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.file-item:hover { background: var(--bg-light); }
|
||||
.file-item.active { background: var(--primary-color); color: var(--white); font-weight: bold; }
|
||||
|
||||
/* Center: Editor Area */
|
||||
.editor-container {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
overflow: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #e0e0e0; /* 전용 배경색 유지 */
|
||||
}
|
||||
|
||||
.img-wrapper {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
box-shadow: 0 0 30px rgba(0,0,0,0.3);
|
||||
background: var(--white);
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.img-wrapper img {
|
||||
display: block;
|
||||
max-width: calc(100vw - 650px);
|
||||
max-height: 85vh;
|
||||
width: auto;
|
||||
height: auto;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
/* Right Sidebar: Control Panel */
|
||||
.sidebar {
|
||||
width: 350px;
|
||||
background: var(--white);
|
||||
border-left: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
box-shadow: -5px 0 15px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.sidebar h2 { margin-top: 0; color: var(--primary-color); font-size: 1.2rem; }
|
||||
.sidebar p { font-size: 0.85rem; color: var(--text-muted); line-height: 1.4; margin-bottom: 20px; }
|
||||
|
||||
.current-path { font-size: 11px; color: var(--text-muted); margin-bottom: 10px; word-break: break-all; font-family: monospace; }
|
||||
|
||||
.box-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 15px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
background: var(--bg-light);
|
||||
}
|
||||
|
||||
.box-item {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
padding: 6px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.box-item:hover { background: var(--white); }
|
||||
.btn-del { cursor: pointer; color: var(--danger); border: none; background: none; font-size: 16px; padding: 0 5px; }
|
||||
|
||||
.actions { display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
/* Drawing Elements */
|
||||
.draw-box {
|
||||
position: absolute;
|
||||
border: 2px solid var(--edit-mode-color);
|
||||
background: rgba(255, 61, 0, 0.2);
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.placed-box {
|
||||
position: absolute;
|
||||
border: 1.5px solid var(--primary-color);
|
||||
background: rgba(30, 81, 73, 0.15);
|
||||
cursor: pointer;
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.placed-box:hover {
|
||||
background: rgba(30, 81, 73, 0.4);
|
||||
border-color: #000;
|
||||
}
|
||||
|
||||
.placed-box.selected {
|
||||
border: 2.5px solid var(--edit-mode-color);
|
||||
z-index: 60;
|
||||
box-shadow: 0 0 10px rgba(255,61,0,0.5);
|
||||
}
|
||||
|
||||
.box-label {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
color: var(--primary-color);
|
||||
pointer-events: none;
|
||||
white-space: nowrap;
|
||||
background: rgba(255,255,255,0.7);
|
||||
padding: 0 2px;
|
||||
border-radius: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.draw-box .box-label {
|
||||
color: var(--edit-mode-color);
|
||||
background: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
#save-status {
|
||||
margin-top: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--success);
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
height: 14px;
|
||||
}
|
||||
@@ -41,13 +41,13 @@
|
||||
}
|
||||
|
||||
.modal-header h2 {
|
||||
font-size: 1.125rem;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.modal-header .btn-icon {
|
||||
color: var(--white) !important;
|
||||
color: #FFFFFF !important;
|
||||
cursor: pointer;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
@@ -94,7 +94,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; /* 패딩 조정 */
|
||||
@@ -129,7 +129,7 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.grid-form.is-view-mode button:not(.btn-loc-action) {
|
||||
.grid-form.is-view-mode button {
|
||||
pointer-events: none !important;
|
||||
background: none !important;
|
||||
border: none !important;
|
||||
@@ -143,7 +143,7 @@
|
||||
.grid-form.is-edit-mode input,
|
||||
.grid-form.is-edit-mode select,
|
||||
.grid-form.is-edit-mode textarea {
|
||||
color: var(--edit-mode-color); /* 수정 시 글자색 변경 */
|
||||
color: #FF3D00; /* 수정 시 글자색 변경 */
|
||||
border: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
@@ -160,8 +160,8 @@
|
||||
.grid-form.is-edit-mode input:focus,
|
||||
.grid-form.is-edit-mode select:focus,
|
||||
.grid-form.is-edit-mode textarea:focus {
|
||||
border-color: var(--edit-mode-color);
|
||||
box-shadow: 0 0 0 2px var(--edit-mode-focus);
|
||||
border-color: #FF3D00;
|
||||
box-shadow: 0 0 0 2px rgba(255, 61, 0, 0.1);
|
||||
}
|
||||
|
||||
.form-section-title:first-child {
|
||||
@@ -169,7 +169,7 @@
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -181,7 +181,7 @@
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-family: inherit;
|
||||
font-size: 0.875rem;
|
||||
font-size: 1.15rem;
|
||||
outline: none;
|
||||
transition: all 0.2s;
|
||||
background-color: var(--white);
|
||||
@@ -238,7 +238,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;
|
||||
@@ -295,7 +295,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);
|
||||
@@ -303,7 +303,7 @@
|
||||
|
||||
.preview-table td {
|
||||
padding: 0.75rem 1rem;
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
color: var(--text-main);
|
||||
}
|
||||
@@ -338,7 +338,7 @@
|
||||
}
|
||||
|
||||
.history-header h3 {
|
||||
font-size: 0.9375rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -406,21 +406,21 @@
|
||||
}
|
||||
|
||||
.history-date {
|
||||
font-size: 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.history-user {
|
||||
font-size: 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
font-weight: 600;
|
||||
color: var(--primary-color);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.history-details {
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-main);
|
||||
line-height: 1.4;
|
||||
white-space: pre-wrap;
|
||||
@@ -431,7 +431,7 @@
|
||||
padding: 2rem 0;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
/* Dashboard Detail Modal Table Fixed Header */
|
||||
@@ -464,7 +464,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;
|
||||
@@ -474,7 +474,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;
|
||||
}
|
||||
@@ -492,7 +492,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;
|
||||
}
|
||||
@@ -508,186 +508,3 @@
|
||||
color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
/* Layout Map & Image Picker Styles */
|
||||
.layout-map-container {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
cursor: crosshair;
|
||||
background-color: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.layout-map-container.readonly {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.layout-map-container.readonly .map-seat-obj {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.digital-overlay-layer {
|
||||
position: absolute;
|
||||
top: 0; left: 0; width: 100%; height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: 8;
|
||||
}
|
||||
|
||||
.digital-map-svg {
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
|
||||
.map-seat-obj {
|
||||
fill: rgba(30, 81, 73, 0.02);
|
||||
stroke: rgba(30, 81, 73, 0.15); /* 평상시에도 아주 연하게 보이게 수정 */
|
||||
stroke-width: 0.2;
|
||||
cursor: pointer;
|
||||
pointer-events: all;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.map-seat-obj:hover {
|
||||
fill: rgba(30, 81, 73, 0.3);
|
||||
stroke: rgba(30, 81, 73, 0.6);
|
||||
stroke-width: 0.5;
|
||||
}
|
||||
|
||||
.layout-map-img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 75vh;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
|
||||
.layout-marker {
|
||||
position: absolute;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background-color: rgba(30, 81, 73, 0.7);
|
||||
border: 2px solid #FFFFFF;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.pulse-marker {
|
||||
background-color: rgba(255, 61, 0, 0.8) !important;
|
||||
border-color: #FFFFFF !important;
|
||||
animation: marker-pulse 1.2s infinite;
|
||||
}
|
||||
|
||||
@keyframes marker-pulse {
|
||||
0% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0.6); }
|
||||
70% { transform: translate(-50%, -50%) scale(1.6); box-shadow: 0 0 0 10px rgba(255, 61, 0, 0); }
|
||||
100% { transform: translate(-50%, -50%) scale(1); box-shadow: 0 0 0 0 rgba(255, 61, 0, 0); }
|
||||
}
|
||||
|
||||
.image-picker-overlay {
|
||||
position: fixed;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background: rgba(0,0,0,0.85);
|
||||
z-index: 2500;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.image-picker-header {
|
||||
width: 100%;
|
||||
max-width: 900px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.image-picker-header h3 {
|
||||
color: white;
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.image-picker-content {
|
||||
background: white;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
max-width: 95vw;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.picker-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 40px;
|
||||
height: 60px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
cursor: pointer;
|
||||
z-index: 100;
|
||||
user-select: none;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.picker-nav:hover { background: rgba(0,0,0,0.8); }
|
||||
.picker-nav.disabled { opacity: 0.2; cursor: not-allowed; }
|
||||
.picker-nav.prev { left: 10px; border-radius: 0 4px 4px 0; }
|
||||
.picker-nav.next { right: 10px; border-radius: 4px 0 0 4px; }
|
||||
|
||||
.image-picker-footer {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-loc-action {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
font-size: 10px !important;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
height: 24px;
|
||||
min-width: 52px;
|
||||
white-space: nowrap;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-loc-view {
|
||||
background-color: var(--primary-color);
|
||||
color: white !important;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-loc-view:hover {
|
||||
background-color: #163d37;
|
||||
}
|
||||
|
||||
.location-detail-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.location-detail-container select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 16px;
|
||||
font-size: 21px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
display: flex;
|
||||
@@ -30,7 +30,7 @@
|
||||
}
|
||||
|
||||
.page-description {
|
||||
font-size: 12px;
|
||||
font-size: 17px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
line-height: 1.4;
|
||||
@@ -72,7 +72,7 @@
|
||||
}
|
||||
|
||||
.search-item label {
|
||||
font-size: 11px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
@@ -83,7 +83,7 @@
|
||||
padding: 0 1rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-size: 19px;
|
||||
outline: none;
|
||||
background-color: var(--white);
|
||||
}
|
||||
@@ -128,7 +128,7 @@ table {
|
||||
|
||||
th, td {
|
||||
padding: 0.8rem 1.2rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid #F3F4F6;
|
||||
text-align: left; /* 기본은 좌측 정렬 */
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -140,8 +140,8 @@ thead {
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: var(--bg-light) !important;
|
||||
font-size: 13px;
|
||||
background-color: #FAFAFA !important;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
position: sticky;
|
||||
@@ -152,13 +152,13 @@ th {
|
||||
}
|
||||
|
||||
td {
|
||||
font-size: 13px;
|
||||
font-size: 18px;
|
||||
color: var(--text-main);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background-color: var(--bg-color);
|
||||
background-color: #F9FAFB;
|
||||
}
|
||||
|
||||
/* 정렬 클래스 강제 적용 */
|
||||
|
||||
@@ -8,19 +8,17 @@ export function renderCloudList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '클라우드',
|
||||
dataSource: () => state.masterData.cloud || [],
|
||||
searchKeys: ['PRODUCT_NAME', 'ASSET_PURPOSE', 'PURCHASE_VENDOR', 'ASSET_TYPE'],
|
||||
searchKeys: ['PRODUCT_NAME', 'ASSET_PURPOSE', 'PURCHASE_VENDOR'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.PURCHASE_VENDOR.ui})`,
|
||||
showCorp: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (asset) => openSwModal(asset, 'view'),
|
||||
columns: [
|
||||
{ header: ASSET_SCHEMA.PRODUCT_NAME.ui, sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.key] || '' },
|
||||
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '' },
|
||||
{ header: ASSET_SCHEMA.PURCHASE_VENDOR.ui, sortKey: ASSET_SCHEMA.PURCHASE_VENDOR.key, render: a => a[ASSET_SCHEMA.PURCHASE_VENDOR.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.PURCHASE_AMOUNT.ui,
|
||||
sortKey: ASSET_SCHEMA.PURCHASE_AMOUNT.key,
|
||||
|
||||
@@ -7,16 +7,15 @@ export function renderCostList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '비용관리',
|
||||
dataSource: () => sortAssets(state.masterData.cloud?.filter((a: any) => a.category === '비용관리') || []),
|
||||
searchKeys: ['PRODUCT_NAME', 'MANAGER_MAIN', 'EMAIL_ACCOUNT', 'ASSET_TYPE'],
|
||||
searchKeys: ['PRODUCT_NAME', 'MANAGER_MAIN', 'EMAIL_ACCOUNT'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui})`,
|
||||
showCorp: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: () => alert('상세 정보 준비 중입니다.'),
|
||||
columns: [
|
||||
{ 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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' },
|
||||
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, render: a => formatInline(a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '-') },
|
||||
{ header: '현 사용자', sortKey: ASSET_SCHEMA.MANAGER_MAIN.key, align: 'center', render: a => a[ASSET_SCHEMA.MANAGER_MAIN.key] || '-' },
|
||||
{
|
||||
|
||||
@@ -12,20 +12,24 @@ export function renderDomainList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '도메인',
|
||||
dataSource: () => state.masterData.domain || [],
|
||||
searchKeys: ['DOMAIN_ADDR', 'ASSET_PURPOSE', 'PRODUCT_NAME', 'ASSET_TYPE'],
|
||||
searchKeys: ['DOMAIN_ADDR', 'ASSET_PURPOSE', 'PRODUCT_NAME'],
|
||||
persistentSortState,
|
||||
emptyMessage: '등록된 도메인 정보가 없습니다.',
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.DOMAIN_ADDR.ui}/${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
|
||||
showCorp: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (item) => openDomainModal(item),
|
||||
columns: [
|
||||
{ header: ASSET_SCHEMA.DOMAIN_ADDR.ui, sortKey: ASSET_SCHEMA.DOMAIN_ADDR.key, align: 'left', render: a => a[ASSET_SCHEMA.DOMAIN_ADDR.key] || '' },
|
||||
{ header: ASSET_SCHEMA.ASSET_PURPOSE.ui, sortKey: ASSET_SCHEMA.ASSET_PURPOSE.key, align: 'left', render: a => a[ASSET_SCHEMA.ASSET_PURPOSE.key] || '' },
|
||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||
{
|
||||
header: ASSET_SCHEMA.ASSET_TYPE.ui,
|
||||
sortKey: ASSET_SCHEMA.ASSET_TYPE.key,
|
||||
align: 'center',
|
||||
render: a => `<span class="badge badge-${a[ASSET_SCHEMA.ASSET_TYPE.key] === '관리중' ? 'primary' : 'muted'}">${a[ASSET_SCHEMA.ASSET_TYPE.key] || '-'}</span>`
|
||||
},
|
||||
{ header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.key] || '' },
|
||||
{ header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
|
||||
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
|
||||
|
||||
@@ -8,12 +8,11 @@ export function renderEquipmentList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '업무지원장비',
|
||||
dataSource: () => sortAssets(state.masterData.equipment || []),
|
||||
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
|
||||
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
|
||||
showLoc: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
@@ -24,7 +23,7 @@ export function renderEquipmentList(container: HTMLElement) {
|
||||
render: a => `<span class="badge badge-${a[ASSET_SCHEMA.HW_STATUS.key] === '대여중' ? 'primary' : 'success'}">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' },
|
||||
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
|
||||
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a.명칭 || '-') },
|
||||
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
|
||||
|
||||
@@ -8,12 +8,11 @@ export function renderFacilityList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '사무가구',
|
||||
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '시설자산') || []),
|
||||
searchKeys: ['MODEL_NAME', 'ASSET_MFR', 'ASSET_TYPE'],
|
||||
searchKeys: ['MODEL_NAME', 'ASSET_MFR'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
|
||||
showLoc: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
@@ -23,7 +22,7 @@ export function renderFacilityList(container: HTMLElement) {
|
||||
align: 'center',
|
||||
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
|
||||
},
|
||||
{ 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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' },
|
||||
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
|
||||
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
|
||||
{
|
||||
|
||||
@@ -7,17 +7,15 @@ export function renderGiftList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '선물',
|
||||
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '선물') || []),
|
||||
searchKeys: ['PRODUCT_NAME', 'MODEL_NAME', 'ASSET_TYPE'],
|
||||
searchKeys: ['PRODUCT_NAME', 'MODEL_NAME'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
|
||||
showCorp: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: () => alert('상세 정보 준비 중입니다.'),
|
||||
columns: [
|
||||
{ header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.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: '구매연월', sortKey: ASSET_SCHEMA.PURCHASE_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_DATE.key] || '' },
|
||||
{ header: ASSET_SCHEMA.EXPIRED_DATE.ui, sortKey: ASSET_SCHEMA.EXPIRED_DATE.key, align: 'center', render: a => a[ASSET_SCHEMA.EXPIRED_DATE.key] || '' },
|
||||
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
|
||||
|
||||
@@ -23,7 +23,6 @@ export interface ListViewConfig {
|
||||
showDept?: boolean;
|
||||
showLoc?: boolean;
|
||||
showField?: boolean;
|
||||
showType?: boolean;
|
||||
};
|
||||
columns: ColumnDef[];
|
||||
onRowClick?: (asset: any) => void;
|
||||
@@ -38,7 +37,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
let sortState: SortState = config.persistentSortState || { key: '', direction: 'asc' };
|
||||
|
||||
// Initialize currentFilters with all possible keys to avoid undefined issues
|
||||
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '' };
|
||||
let currentFilters: any = { keyword: '', corp: '', dept: '', loc: '', field: '' };
|
||||
|
||||
const filterBar = document.createElement('div');
|
||||
filterBar.className = 'search-bar';
|
||||
@@ -152,7 +151,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
|
||||
if (config.filterOptions.showLoc) populateSelect('#filter-loc', ASSET_SCHEMA.LOCATION.key);
|
||||
if (config.filterOptions.showDept) populateSelect('#filter-dept', ASSET_SCHEMA.CURRENT_DEPT.key);
|
||||
if (config.filterOptions.showCorp) populateSelect('#filter-corp', ASSET_SCHEMA.PURCHASE_CORP.key);
|
||||
if (config.filterOptions.showType) populateSelect('#filter-type', ASSET_SCHEMA.ASSET_TYPE.key);
|
||||
|
||||
// 6. 초기 렌더링
|
||||
updateTable();
|
||||
|
||||
@@ -8,18 +8,16 @@ export function renderMobileList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: 'PC', // Legacy support
|
||||
dataSource: () => sortAssets(state.masterData.mobile || []),
|
||||
searchKeys: ['MODEL_NAME', 'ASSET_TYPE'],
|
||||
searchKeys: ['MODEL_NAME'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
|
||||
showCorp: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
{ header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '운영중' },
|
||||
{ header: ASSET_SCHEMA.PURCHASE_CORP.ui, sortKey: ASSET_SCHEMA.PURCHASE_CORP.key, align: 'center', render: a => a[ASSET_SCHEMA.PURCHASE_CORP.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.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => a[ASSET_SCHEMA.MODEL_NAME.key] || '' },
|
||||
{
|
||||
header: ASSET_SCHEMA.LOCATION.ui,
|
||||
|
||||
@@ -8,12 +8,11 @@ export function renderNetworkList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '네트워크',
|
||||
dataSource: () => sortAssets(state.masterData.network || []),
|
||||
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
|
||||
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
|
||||
showLoc: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
@@ -24,7 +23,7 @@ export function renderNetworkList(container: HTMLElement) {
|
||||
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '운영중'}</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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' },
|
||||
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
|
||||
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
|
||||
{ header: ASSET_SCHEMA.ASSET_COUNT.ui, sortKey: ASSET_SCHEMA.ASSET_COUNT.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_COUNT.key] || '1' },
|
||||
|
||||
@@ -8,40 +8,32 @@ export function renderPcList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: 'PC',
|
||||
dataSource: () => sortAssets((state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC')),
|
||||
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'],
|
||||
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
|
||||
showLoc: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.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 => [a[ASSET_SCHEMA.SSD1.key], a[ASSET_SCHEMA.SSD2.key]].filter(Boolean).join(' / ') || '-'
|
||||
},
|
||||
{
|
||||
header: 'HDD',
|
||||
align: 'center',
|
||||
width: '12%',
|
||||
render: a => [a[ASSET_SCHEMA.HDD1.key], a[ASSET_SCHEMA.HDD2.key], a[ASSET_SCHEMA.HDD3.key], a[ASSET_SCHEMA.HDD4.key]].filter(Boolean).join(' / ') || '-'
|
||||
},
|
||||
{ header: 'SSD1', sortKey: ASSET_SCHEMA.SSD1.key, align: 'center', render: a => a[ASSET_SCHEMA.SSD1.key] || '-' },
|
||||
{ header: 'SSD2', sortKey: ASSET_SCHEMA.SSD2.key, align: 'center', render: a => a[ASSET_SCHEMA.SSD2.key] || '-' },
|
||||
{ header: 'HDD1', sortKey: ASSET_SCHEMA.HDD1.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD1.key] || '-' },
|
||||
{ header: 'HDD2', sortKey: ASSET_SCHEMA.HDD2.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD2.key] || '-' },
|
||||
{ header: 'HDD3', sortKey: ASSET_SCHEMA.HDD3.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD3.key] || '-' },
|
||||
{ header: 'HDD4', sortKey: ASSET_SCHEMA.HDD4.key, align: 'center', render: a => a[ASSET_SCHEMA.HDD4.key] || '-' },
|
||||
{
|
||||
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: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', width: '30%', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
|
||||
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
@@ -12,8 +12,7 @@ export function renderPcPartList(container: HTMLElement) {
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
|
||||
showLoc: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
@@ -23,7 +22,7 @@ export function renderPcPartList(container: HTMLElement) {
|
||||
align: 'center',
|
||||
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
|
||||
},
|
||||
{ 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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' },
|
||||
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
|
||||
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
|
||||
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
|
||||
|
||||
@@ -12,12 +12,11 @@ export function renderServerList(container: HTMLElement) {
|
||||
const serverPcList = (state.masterData.pc || []).filter((a: any) => a.asset_type === '서버PC');
|
||||
return sortAssets([...serverList, ...serverPcList]);
|
||||
},
|
||||
searchKeys: ['CURRENT_DEPT', 'MODEL_NAME', 'ASSET_PURPOSE', 'ASSET_TYPE'],
|
||||
searchKeys: ['CURRENT_DEPT', 'MODEL_NAME', 'ASSET_PURPOSE'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.CURRENT_DEPT.ui}/${ASSET_SCHEMA.MODEL_NAME.ui})`,
|
||||
showLoc: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
|
||||
@@ -8,12 +8,11 @@ export function renderSpaceInfoList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '공간정보장비',
|
||||
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '공간정보장비') || []),
|
||||
searchKeys: ['MODEL_NAME', 'PRODUCT_NAME', 'CURRENT_USER', 'ASSET_TYPE'],
|
||||
searchKeys: ['MODEL_NAME', 'PRODUCT_NAME', 'CURRENT_USER'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
|
||||
showLoc: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
@@ -25,7 +24,7 @@ export function renderSpaceInfoList(container: HTMLElement) {
|
||||
},
|
||||
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.ASSET_NAME.ui, sortKey: ASSET_SCHEMA.ASSET_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.PRODUCT_NAME.key] || a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.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.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '' },
|
||||
{
|
||||
header: ASSET_SCHEMA.LOCATION.ui,
|
||||
sortKey: ASSET_SCHEMA.LOCATION.key,
|
||||
|
||||
@@ -8,18 +8,17 @@ export function renderStorageList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: '스토리지',
|
||||
dataSource: () => sortAssets(state.masterData.storage || []),
|
||||
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'SERIAL_NUM', 'ASSET_TYPE'],
|
||||
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'SERIAL_NUM'],
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
|
||||
showLoc: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (asset) => openHwModal(asset, 'view'),
|
||||
columns: [
|
||||
{ header: ASSET_SCHEMA.HW_STATUS.ui, sortKey: ASSET_SCHEMA.HW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.HW_STATUS.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
|
||||
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || a[ASSET_SCHEMA.ASSET_NAME.key] || '-') },
|
||||
{ header: ASSET_SCHEMA.SERIAL_NUM.ui, sortKey: ASSET_SCHEMA.SERIAL_NUM.key, align: 'center', render: a => a[ASSET_SCHEMA.SERIAL_NUM.key] || '-' },
|
||||
|
||||
@@ -10,26 +10,24 @@ export function renderSwList(container: HTMLElement) {
|
||||
createListView(container, {
|
||||
title: isInternal ? '내부' : '외부',
|
||||
dataSource: () => sortAssets(isInternal ? state.masterData.swInternal : state.masterData.swExternal),
|
||||
searchKeys: ['PRODUCT_NAME', 'CURRENT_USER', 'CURRENT_DEPT', 'ASSET_TYPE'],
|
||||
searchKeys: ['PRODUCT_NAME', 'CURRENT_USER', 'CURRENT_DEPT'],
|
||||
emptyMessage: '검색 결과가 없습니다.',
|
||||
filterOptions: {
|
||||
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui}/${ASSET_SCHEMA.CURRENT_DEPT.ui})`,
|
||||
showField: true,
|
||||
showCorp: true,
|
||||
showDept: true,
|
||||
showType: true
|
||||
showDept: true
|
||||
},
|
||||
onRowClick: (asset) => openSwModal(asset, 'view'),
|
||||
columns: isInternal ? [
|
||||
{ header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' },
|
||||
{ header: ASSET_SCHEMA.DEV_OBJ.ui, sortKey: ASSET_SCHEMA.DEV_OBJ.key, align: 'center', render: a => a[ASSET_SCHEMA.DEV_OBJ.key] || '' },
|
||||
{ header: ASSET_SCHEMA.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.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.SW_TYPE.ui, sortKey: ASSET_SCHEMA.SW_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_TYPE.key] || '내부' },
|
||||
{ header: ASSET_SCHEMA.MEMO.ui, sortKey: ASSET_SCHEMA.MEMO.key, className: 'col-memo', render: a => formatInline(a[ASSET_SCHEMA.MEMO.key] || '-') }
|
||||
] : [
|
||||
{ header: '자산명', sortKey: ASSET_SCHEMA.PRODUCT_NAME.key, render: a => a[ASSET_SCHEMA.PRODUCT_NAME.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: '유형', sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '외부' },
|
||||
{ header: ASSET_SCHEMA.SW_STATUS.ui, sortKey: ASSET_SCHEMA.SW_STATUS.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_STATUS.key] || '사용중' },
|
||||
{ header: ASSET_SCHEMA.SW_FIELD.ui, sortKey: ASSET_SCHEMA.SW_FIELD.key, align: 'center', render: a => a[ASSET_SCHEMA.SW_FIELD.key] || '' },
|
||||
{ header: ASSET_SCHEMA.CURRENT_DEPT.ui, sortKey: ASSET_SCHEMA.CURRENT_DEPT.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_DEPT.key] || '' },
|
||||
|
||||
@@ -1,222 +0,0 @@
|
||||
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
|
||||
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
|
||||
|
||||
export class MapEditor {
|
||||
private container: HTMLElement;
|
||||
private wrapper: HTMLElement;
|
||||
private img: HTMLImageElement;
|
||||
private boxListEl: HTMLElement;
|
||||
private pathLabel: HTMLElement;
|
||||
private statusEl: HTMLElement;
|
||||
private saveBtn: HTMLButtonElement;
|
||||
private fileSidebar: HTMLElement;
|
||||
|
||||
private allMapConfig: Record<string, any[]> = {};
|
||||
private boxes: any[] = [];
|
||||
private isDrawing: boolean = false;
|
||||
private startX: number = 0;
|
||||
private startY: number = 0;
|
||||
private currentBox: HTMLElement | null = null;
|
||||
private currentPath: string = '';
|
||||
|
||||
constructor() {
|
||||
this.container = document.getElementById('container')!;
|
||||
this.wrapper = document.getElementById('wrapper')!;
|
||||
this.img = document.getElementById('target-img') as HTMLImageElement;
|
||||
this.boxListEl = document.getElementById('box-list')!;
|
||||
this.pathLabel = document.getElementById('current-path')!;
|
||||
this.statusEl = document.getElementById('save-status')!;
|
||||
this.saveBtn = document.getElementById('btn-save-server') as HTMLButtonElement;
|
||||
this.fileSidebar = document.getElementById('file-sidebar')!;
|
||||
}
|
||||
|
||||
public async init() {
|
||||
this.renderFileSidebar();
|
||||
await this.loadConfig();
|
||||
this.bindEvents();
|
||||
this.selectFirstFile();
|
||||
createIcons({ icons: { X, Save, Trash2, ChevronLeft, ChevronRight } });
|
||||
}
|
||||
|
||||
private renderFileSidebar() {
|
||||
let html = '';
|
||||
Object.entries(IMAGE_LOCATIONS).forEach(([bldg, details]) => {
|
||||
html += `<div class="folder-item">${bldg}</div>`;
|
||||
Object.entries(details).forEach(([detail, paths]) => {
|
||||
paths.forEach(path => {
|
||||
const fileName = path.split('/').pop() || path;
|
||||
html += `<div class="file-item" data-path="${path}">${fileName}</div>`;
|
||||
});
|
||||
});
|
||||
});
|
||||
this.fileSidebar.innerHTML = html;
|
||||
|
||||
this.fileSidebar.querySelectorAll('.file-item').forEach(item => {
|
||||
item.addEventListener('click', () => {
|
||||
this.fileSidebar.querySelectorAll('.file-item').forEach(i => i.classList.remove('active'));
|
||||
item.classList.add('active');
|
||||
this.renderCurrentFile();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private selectFirstFile() {
|
||||
const firstItem = this.fileSidebar.querySelector('.file-item') as HTMLElement;
|
||||
if (firstItem) {
|
||||
firstItem.classList.add('active');
|
||||
this.renderCurrentFile();
|
||||
}
|
||||
}
|
||||
|
||||
private async loadConfig() {
|
||||
try {
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
|
||||
this.allMapConfig = await res.json();
|
||||
} catch (err) {
|
||||
console.error('Failed to load config:', err);
|
||||
}
|
||||
}
|
||||
|
||||
private renderCurrentFile() {
|
||||
const activeItem = this.fileSidebar.querySelector('.file-item.active') as HTMLElement;
|
||||
if (!activeItem) return;
|
||||
|
||||
this.currentPath = activeItem.dataset.path || '';
|
||||
this.boxes = this.allMapConfig[this.currentPath] || [];
|
||||
this.pathLabel.textContent = this.currentPath;
|
||||
this.img.src = this.currentPath;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private bindEvents() {
|
||||
this.wrapper.addEventListener('mousedown', (e) => {
|
||||
if (e.button !== 0) return;
|
||||
this.isDrawing = true;
|
||||
const rect = this.wrapper.getBoundingClientRect();
|
||||
this.startX = e.clientX - rect.left;
|
||||
this.startY = e.clientY - rect.top;
|
||||
|
||||
this.currentBox = document.createElement('div');
|
||||
this.currentBox.className = 'draw-box';
|
||||
this.currentBox.style.left = this.startX + 'px';
|
||||
this.currentBox.style.top = this.startY + 'px';
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'box-label';
|
||||
label.textContent = '#' + (this.boxes.length + 1);
|
||||
this.currentBox.appendChild(label);
|
||||
|
||||
this.wrapper.appendChild(this.currentBox);
|
||||
});
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!this.isDrawing || !this.currentBox) return;
|
||||
const rect = this.wrapper.getBoundingClientRect();
|
||||
const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width));
|
||||
const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height));
|
||||
|
||||
const width = currentX - this.startX;
|
||||
const height = currentY - this.startY;
|
||||
|
||||
this.currentBox.style.width = Math.abs(width) + 'px';
|
||||
this.currentBox.style.height = Math.abs(height) + 'px';
|
||||
this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px';
|
||||
this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px';
|
||||
});
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
if (!this.isDrawing || !this.currentBox) return;
|
||||
this.isDrawing = false;
|
||||
|
||||
const width = parseFloat(this.currentBox.style.width);
|
||||
const height = parseFloat(this.currentBox.style.height);
|
||||
|
||||
if (width > 3 && height > 3) {
|
||||
const rect = this.wrapper.getBoundingClientRect();
|
||||
const boxData = {
|
||||
x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2),
|
||||
y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2),
|
||||
w: (width / rect.width * 100).toFixed(2),
|
||||
h: (height / rect.height * 100).toFixed(2)
|
||||
};
|
||||
this.boxes.push(boxData);
|
||||
this.render();
|
||||
}
|
||||
|
||||
this.currentBox.remove();
|
||||
this.currentBox = null;
|
||||
});
|
||||
|
||||
(window as any).removeBox = (index: number) => {
|
||||
this.boxes.splice(index, 1);
|
||||
this.render();
|
||||
};
|
||||
|
||||
document.getElementById('btn-clear-all')?.addEventListener('click', () => {
|
||||
if(confirm('모든 박스를 삭제할까요?')) {
|
||||
this.boxes = [];
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('btn-save-server')?.addEventListener('click', () => this.saveToServer());
|
||||
}
|
||||
|
||||
private async saveToServer() {
|
||||
if (!this.currentPath) return;
|
||||
|
||||
try {
|
||||
this.saveBtn.disabled = true;
|
||||
this.saveBtn.textContent = '저장 중...';
|
||||
|
||||
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: this.currentPath, boxes: this.boxes })
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
this.allMapConfig[this.currentPath] = [...this.boxes];
|
||||
this.statusEl.textContent = '✅ 서버 저장 완료 (' + new Date().toLocaleTimeString() + ')';
|
||||
setTimeout(() => this.statusEl.textContent = '', 3000);
|
||||
} else {
|
||||
alert('저장 실패!');
|
||||
}
|
||||
} catch (err) {
|
||||
alert('서버 연결 오류!');
|
||||
} finally {
|
||||
this.saveBtn.disabled = false;
|
||||
this.saveBtn.textContent = '서버에 즉시 저장';
|
||||
}
|
||||
}
|
||||
|
||||
private render() {
|
||||
this.boxListEl.innerHTML = '';
|
||||
const oldBoxes = this.wrapper.querySelectorAll('.placed-box');
|
||||
oldBoxes.forEach(b => b.remove());
|
||||
|
||||
this.boxes.forEach((box, i) => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'placed-box';
|
||||
div.style.left = box.x + '%';
|
||||
div.style.top = box.y + '%';
|
||||
div.style.width = box.w + '%';
|
||||
div.style.height = box.h + '%';
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'box-label';
|
||||
label.textContent = '#' + (i + 1);
|
||||
div.appendChild(label);
|
||||
|
||||
this.wrapper.appendChild(div);
|
||||
|
||||
const item = document.createElement('div');
|
||||
item.className = 'box-item';
|
||||
item.innerHTML = `
|
||||
<span>#${i+1}: [${box.x}, ${box.y}]</span>
|
||||
<button class="btn-del" onclick="removeBox(${i})">×</button>
|
||||
`;
|
||||
this.boxListEl.appendChild(item);
|
||||
});
|
||||
}
|
||||
}
|
||||