1 Commits

142 changed files with 21166 additions and 9369 deletions

View 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) &gt; 편집 디자이너(80.2) &gt; 3D 디자이너(78.4) &gt; UXUI 디자이너(72.7) &gt; 3D 개발자(67.8) &gt; 프로그램 개발자(67.3) &gt; BIM모델러(62.1) &gt; 엔지니어(42.9) &gt; 웹 개발자(39.2) &gt; 기획자(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">개인 실질 점수 &lt; avgScore × 0.80</span><span class="key">"사양 부족"</span> (직무 평균 20% 이상 미달)
IF <span class="val">개인 실질 점수 &gt; 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>실질 점수 &lt; 직무 평균 × 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>실질 점수 &gt; 직무 평균 × 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 &gt; 4080 &gt; 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 반영) &nbsp;·&nbsp; 2026. 05. 28</p>
<p style="margin-top:0.25rem;">내부 검토용 문서입니다. 무단 외부 배포를 금합니다.</p>
</footer>
</div>
</body>
</html>

View File

@@ -9,14 +9,9 @@
- 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다.
- 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다.
4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다.
5. **REDGREENRefactor 개발 원칙**:
- 모든 기능 개발과 버그 수정은 **RED → GREEN → Refactor** 순서로 진행한다.
- **RED**: 요구사항을 명확히 표현하는 테스트를 먼저 작성하고, 해당 테스트가 기능 미구현 또는 결함으로 인해 실패하는지 확인한다.
- **GREEN**: 실패한 테스트를 통과시키는 데 필요한 최소한의 코드만 구현하며, 불필요한 기능 추가나 구조 변경을 하지 않는다.
- **Refactor**: 관련 테스트와 기존 테스트가 모두 통과하는 상태에서만 중복 제거, 명칭 개선, 책임 분리 등 코드 구조를 개선하며 동작은 변경하지 않는다.
- 각 단계가 끝날 때마다 관련 테스트와 기존 기능의 회귀 여부를 검증한다.
- 테스트 작성이 현실적으로 불가능한 경우에는 그 사유와 대체 검증 방법을 먼저 보고하고 승인을 받은 후 진행한다.
- 본 원칙을 적용할 때에도 기존의 **선보고 후승인****외과 수술식 수정** 규칙을 준수한다.
5. **DB 삭제 및 초기화 절대 엄금 (Strict DB Deletion Policy)**:
- 어떠한 경우에도 `DELETE`, `DROP`, `TRUNCATE` 등 데이터를 삭제하거나 테이블을 초기화하는 작업은 사전에 사용자에게 상세 사유를 보고하고 **명시적 승인**을 얻은 후에만 시행한다.
- 기존 데이터의 가치를 최우선으로 하며, 작업 전 백업 여부를 반드시 확인한다.
---
@@ -36,8 +31,29 @@
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
디자인 일관성 및 시각적 원칙에 관한 상세 내용은 아래 문서를 참조하십시오.
1. **디자인 철학 (Design Philosophy)**
* **Minimalist & Border-based**: 불필요한 박스(Card) 사용을 최소화하고, 정보의 구분은 간결한 라인(Border/Divider)을 활용하여 시각적 피로도를 낮춥니다.
* **Professional Achromatic**: 무채색(Black, White, Grey)을 기본으로 하여 정돈된 업무 환경을 제공합니다.
* **Green Accent**: 블루 대신 짙은 그린(`#1E5149`)을 포인트 컬러로 사용하여 차분한 전문성을 강조합니다.
👉 **[디자인 가이드 바로가기 (design_rule.md)](./design_rule.md)**
2. **타이포그래피 (Typography)**
* **Font Family**: `Pretendard` (전역 적용)
* **Letter Spacing**: `-0.02em` (약 -2%) 적용. 자간을 좁게 설정하여 밀도 있고 세련된 가독성을 확보합니다.
* **Weights**: 400(Regular), 500(Medium), 600(SemiBold), 700(Bold).
3. **컬러 팔레트 (Color Palette)**
* **Point Color**: `#1E5149` (Deep Green) - 강조, 활성화 상태, 주요 액션 버튼.
* **Text**: Main(`#111827` - Near Black), Muted(`#6B7280` - Grey).
* **Border/Divider**: `#E5E7EB` (Light Grey) - 정보 구분을 위한 얇은 실선.
* **Background**: `#FFFFFF` (White) / `#F9FAFB` (Off White).
4. **레이아웃 및 컴포넌트 규칙 (Layout Rules)**
* **Box-less Design**: 꼭 필요한 정보 묶음(데이터 그룹화 등)이 아니면 박스 형태의 테두리나 배경 사용을 지양합니다.
* **Line-based Division**: 섹션 간의 구분은 1px 두께의 얇은 실선(Border)을 통해 명확히 합니다.
* **Table**: 배경색이나 화려한 효과 없이 행(Row) 간의 얇은 구분선만 사용하여 데이터 본연에 집중하게 합니다.
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
* **Modal (모달 공통 규칙)**:
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
* **Interaction**: 사용자의 오입력(실수로 바깥을 클릭하여 입력 내용이 날아가는 현상)을 방지하기 위해 **모달 바깥 영역(Overlay) 클릭 시 모달이 닫히지 않도록** 설정합니다. 닫기는 오직 'ESC' 키 또는 명시적인 'X' 및 '닫기' 버튼을 통해서만 가능합니다.
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.

BIN
SampleData_PC.xlsx Normal file

Binary file not shown.

BIN
SampleData_SVR.xlsx Normal file

Binary file not shown.

30
WORK_LOG_20260615.md Normal file
View File

@@ -0,0 +1,30 @@
# 📝 작업 보고서 (2026-06-15)
## 1. 서버 및 개발 환경 설정
- **백엔드 서버 구동**: 3000번 포트(DB 서버) 정상 구동 완료.
- **프론트엔드 서버 구동**: 8080번 포트 정상 구동 완료.
- **브랜치 전환**: \`db_setting\` 브랜치로 전환 및 최신 코드 Pull 완료.
## 2. 데이터베이스 정제 및 보강 (Surgical Update)
- **사용자 정보(system_users) 업데이트**:
- 엑셀(\`system_User (20260615).xlsx\`) 기반 987건 신규 입력.
- 기존 백업 데이터(212건)와 병합하여 총 1,199건의 사용자 DB 구축.
- **PC 자산(asset_pc) 데이터 입력**:
- 엑셀(\`asset_pc (2026.06.15).xlsx\`) 기반 1,030건 입력 완료.
- **용량 정제**: 괄호 제거 및 4자리 GB 단위를 TB로 자동 변환 (예: 1863GB -> 1.86TB).
- **구매일 보강**: 연도 데이터에 월/일 추가 (\`YYYY-12-01\` 형식으로 통일).
- **자산번호 재매핑**: \`PC-YYYY12-NNNN\` 형식으로 전수 재부여 및 기존 번호와의 연속성 유지.
## 3. 부서 및 자산 유형 정상화
- **부서명 통합**: '총괄기획실', '기술개발센터', '한맥', '장헌', 'PTC', '현타' 등을 제외한 1,045건의 부서명을 **'삼안'**으로 일괄 통합.
- **자산 유형 교정 (핵심)**:
- 엑셀의 오기입과 상관없이 **사번(emp_no) 존재 여부**를 기준으로 자산 유형을 재분류.
- 사번이 있는 991건 -> **개인PC**로 정상화.
- 사번이 없는 39건 -> **공용PC**로 지정 및 사용자명 '공용'으로 정리.
## 4. 운영 규칙 업데이트
- **README.md 수정**: 'DB 삭제 및 초기화 절대 엄금 (Rule 5)' 항목 추가.
---
**보고자**: Gemini CLI
**상태**: 소스 코드 수정 없음, 데이터베이스 정제 완료.

BIN
asset_pc (2026.06.15).xlsx Normal file

Binary file not shown.

BIN
backupDB_20260602.xlsx Normal file

Binary file not shown.

59
backup_db.js Normal file
View File

@@ -0,0 +1,59 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
import * as xlsx from 'xlsx';
import fs from 'fs';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function backup() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 Starting Database Backup Process...');
const tables = [
'asset_pc', 'asset_server', 'asset_storage', 'asset_remote',
'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'
];
const wb = xlsx.utils.book_new();
for (const table of tables) {
try {
// 1. Create table backup
await connection.query(`DROP TABLE IF EXISTS ${table}_backup`);
await connection.query(`CREATE TABLE ${table}_backup AS SELECT * FROM ${table}`);
console.log(`✅ Table backup created: ${table} -> ${table}_backup`);
// 2. Fetch data for Excel
const [rows] = await connection.query(`SELECT * FROM ${table}`);
if (rows.length > 0) {
const ws = xlsx.utils.json_to_sheet(rows);
// Sheet names max length is 31 chars
const sheetName = table.substring(0, 31);
xlsx.utils.book_append_sheet(wb, ws, sheetName);
}
} catch (e) {
console.warn(`⚠️ Skipped ${table}: ${e.message}`);
}
}
// 3. Write Excel file
const fileName = 'backupDB_20260608.xlsx';
xlsx.writeFile(wb, fileName);
console.log(`✅ Excel data exported successfully to ${fileName}`);
await connection.end();
}
backup().catch(err => {
console.error('❌ Backup Failed:', err);
process.exit(1);
});

28
check_logs.js Normal file
View File

@@ -0,0 +1,28 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function checkRecentLogs() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('--- Recent History Logs ---');
const [rows] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC LIMIT 5');
console.log(JSON.stringify(rows, null, 2));
console.log('\n--- Recent Core Data (to check current_dept) ---');
const [coreRows] = await connection.query('SELECT id, asset_code, current_dept, previous_dept FROM asset_core ORDER BY updated_at DESC LIMIT 5');
console.log(JSON.stringify(coreRows, null, 2));
await connection.end();
}
checkRecentLogs().catch(console.error);

29
check_network.js Normal file
View File

@@ -0,0 +1,29 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function checkRemote() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('--- Checking asset_remote table ---');
const [columns] = await connection.query('DESCRIBE asset_remote');
const cols = columns.map(c => c.Field);
console.log('Columns in asset_remote:', cols.join(', '));
const [count] = await connection.query('SELECT COUNT(*) as count FROM asset_remote WHERE remote_tool IS NOT NULL OR remote_id IS NOT NULL');
console.log(`Rows with remote info (tool or id): ${count[0].count}`);
await connection.end();
}
checkRemote().catch(console.error);

176
db_init.js Normal file
View File

@@ -0,0 +1,176 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function initDB() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306'),
multipleStatements: true
});
console.log('🔄 DB 초기화 시작 (영문 표준 스키마 적용)...');
const tablesToDrop = [
'pc_assets', 'server_assets', 'storage_assets', 'equip_assets', 'mobile_assets',
'sw_sub_assets', 'sw_perm_assets', 'cloud_assets', 'sw_users', 'asset_logs'
];
for (const table of tablesToDrop) {
await connection.query(`DROP TABLE IF EXISTS ${table}`);
}
const createHardwareTable = (tableName, comment) => `
CREATE TABLE ${tableName} (
id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100),
asset_code VARCHAR(100),
purchase_date VARCHAR(50),
type VARCHAR(50),
detail_purpose VARCHAR(50),
purpose VARCHAR(255),
details TEXT,
current_org VARCHAR(255),
prev_org VARCHAR(255),
location VARCHAR(255),
manager_main VARCHAR(100),
manager_sub VARCHAR(100),
ip_address VARCHAR(100),
remote_tool VARCHAR(100),
server_id VARCHAR(100),
server_pw VARCHAR(100),
model_name VARCHAR(255),
mainboard VARCHAR(255) COMMENT '메인보드',
os VARCHAR(100),
cpu VARCHAR(255),
ram VARCHAR(100),
gpu VARCHAR(100),
storage1 VARCHAR(255),
storage2 VARCHAR(255),
storage3 VARCHAR(255),
monitoring VARCHAR(100),
price VARCHAR(100),
remarks TEXT,
storage_location VARCHAR(255),
status VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`;
await connection.query(createHardwareTable('pc_assets', 'PC'));
await connection.query(createHardwareTable('server_assets', 'Server'));
await connection.query(createHardwareTable('storage_assets', 'Storage'));
await connection.query(createHardwareTable('equip_assets', 'Equipment'));
await connection.query(createHardwareTable('mobile_assets', 'Mobile'));
await connection.query(`
CREATE TABLE sw_sub_assets (
id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인',
category VARCHAR(100) COMMENT '분야',
dept VARCHAR(100) COMMENT '부서',
product_name VARCHAR(255) COMMENT '제품명',
license_type VARCHAR(100) COMMENT '라이선스 유형',
quantity INT COMMENT '수량',
price VARCHAR(100) COMMENT '금액',
purchase_date VARCHAR(50) COMMENT '구매일',
start_date VARCHAR(50) COMMENT '시작일',
expiry_date VARCHAR(50) COMMENT '만료일',
vendor VARCHAR(255) COMMENT '구매업체',
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE sw_perm_assets (
id VARCHAR(50) PRIMARY KEY,
corp VARCHAR(100) COMMENT '구매법인',
category VARCHAR(100) COMMENT '분야',
dept VARCHAR(100) COMMENT '부서',
product_name VARCHAR(255) COMMENT '제품명',
license_key VARCHAR(255) COMMENT '라이선스 키',
quantity INT COMMENT '수량',
price VARCHAR(100) COMMENT '금액',
purchase_date VARCHAR(50) COMMENT '구매일',
start_date VARCHAR(50) COMMENT '시작일',
expiry_date VARCHAR(50) COMMENT '만료일',
vendor VARCHAR(255) COMMENT '구매업체',
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE cloud_assets (
id VARCHAR(50) PRIMARY KEY,
platform_name VARCHAR(100),
corp VARCHAR(100),
dept VARCHAR(100),
product_name VARCHAR(255),
account_name VARCHAR(255),
pay_method VARCHAR(100),
pay_day VARCHAR(50),
card_num VARCHAR(100),
monthly_fee VARCHAR(100),
remarks TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE sw_users (
id INT AUTO_INCREMENT PRIMARY KEY,
sw_id VARCHAR(50),
corp VARCHAR(100),
dept VARCHAR(100),
position VARCHAR(50),
user_name VARCHAR(100),
usage_period VARCHAR(100),
doc_name VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_logs (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50),
log_date VARCHAR(50),
log_user VARCHAR(100),
details TEXT,
cost DECIMAL(15,2) DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE ops_domain_assets (
id VARCHAR(50) PRIMARY KEY,
type VARCHAR(50) COMMENT '유형',
corp VARCHAR(100) COMMENT '법인',
service_name VARCHAR(255) COMMENT '서비스명',
domain_name VARCHAR(255) COMMENT '관리도메인',
start_date VARCHAR(50) COMMENT '시작일',
expiry_date VARCHAR(50) COMMENT '만료일',
price VARCHAR(100) COMMENT '금액',
manager_main VARCHAR(100) COMMENT '담당자',
manager_sub VARCHAR(100) COMMENT '담당자(부)',
remarks TEXT COMMENT '비고',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ 모든 테이블이 영문 표준 스키마로 재생성되었습니다.');
await connection.end();
}
initDB().catch(err => {
console.error('❌ DB 초기화 실패:', err);
process.exit(1);
});

View File

@@ -1,48 +0,0 @@
# 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
본 문서는 ITAM(IT Asset Management System)의 시각적 일관성과 사용자 경험을 유지하기 위한 핵심 디자인 원칙을 정의합니다.
---
### 1. 디자인 철학 (Design Philosophy)
* **Minimalist & Stark**: Vercel 스타일의 극도로 간결하고 현대적인 디자인을 지향합니다.
* **Achromatic Base**: 블랙(#171717)과 화이트를 기본으로 하며, 정보의 구분은 얇은 헤어라인(#ebebeb)을 사용합니다.
* **Fluid & Responsive**: 고정된 픽셀 대신 화면 크기에 비례하여 UI 밀도가 변하는 유동적 스케일링 시스템을 적용합니다.
### 2. 타이포그래피 및 자간 (Typography & Letter-spacing)
* **Font Family**: `Pretendard` 단일 폰트를 사용합니다.
* **Letter-spacing**: 모든 텍스트에 `-0.02em` (-2%) 자간을 적용하여 밀도 있는 가독성을 확보합니다.
* **Typography Scale**:
* **XS**: `clamp(10px, 1.2vmin + 0.2vw, 15px)` - 보조 텍스트
* **SM**: `clamp(12px, 1.4vmin + 0.3vw, 18px)` - 필터, 일반 라벨, 테이블 헤더
* **Base**: `clamp(14px, 1.6vmin + 0.4vw, 22px)` - 본문, 테이블 데이터
* **MD**: `clamp(18px, 2.5vmin + 0.5vw, 30px)` - 섹션 소제목
* **LG**: `clamp(24px, 4vmin + 0.6vw, 48px)` - 페이지 대제목
* **XL**: `clamp(32px, 6vmin + 0.8vw, 72px)` - 핵심 통계 지표
* **Layout Units**:
* **Header Height**: `clamp(50px, 8vmin, 90px)`
* **Base Spacing**: `clamp(0.75rem, 3vmin, 3rem)`
* **Radius**: `clamp(6px, 1.5vmin, 16px)`
### 3. 컬러 팔레트 (Vercel Stark Palette)
* **Primary**: `#171717` (Stark Black) - 텍스트, 주요 버튼, 강조 요소.
* **Secondary**: `#888888` (Mute) - 보조 텍스트, 비활성 아이콘.
* **Border**: `#ebebeb` (Hairline) - 정보 구분선.
* **Background**: `#ffffff` (Canvas), `#fafafa` (Soft), `#f5f5f5` (Soft 2).
* **Accents**: Blue(`#0070f3`), Orange(`#f5a623`), Danger(`#ee0000`).
### 4. 컴포넌트 및 레이아웃 규칙 (Component Rules)
* **Header & Navigation**:
* 상단 1열 통합 바 형태를 유지하며, GNB와 LNB를 동일 라인에 배치하여 공간을 효율적으로 사용합니다.
* **Unified Filter Bar**:
* 검색창과 필터는 상단 타이틀 바로 아래(기존 액션 버튼 라인)까지 올려서 배치합니다.
* **Action Group**: '자산 추가', '부품 마스터' 등의 주요 액션 버튼은 검색창과 같은 라인의 최우측에 정렬합니다.
* **Dashboard**:
* **Single-Screen View**: 1920*1080(또는 1920*919) 해상도에서 스크롤 없이 한 화면에 핵심 정보가 모두 보이도록 최적화합니다.
* **Fixed Charts**: 차트 내부 숫자나 요소에 애니메이션(`animation: false`) 및 플로팅 레이블을 배제하여 정적인 안정성을 확보합니다.
* **Footer**:
* 화면 최하단에 위치하며, 텍스트는 **우측 정렬(Right-aligned)**합니다.
* 상단에 1px 헤어라인 구분선을 가집니다.
* **Security & UX**:
* **Text Selection**: 사용자의 실수에 의한 UI 드래그 방지를 위해 입력창(`input`, `textarea`)을 제외한 전체 영역의 텍스트 선택을 차단합니다.
* **View Toggle**: '서버' 탭 등 특정 탭에서만 '목록보기' 체크박스를 통해 뷰를 전환하며, 그 외 화면은 리스트 중심의 UI를 제공합니다.

44
drop_legacy.js Normal file
View File

@@ -0,0 +1,44 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function dropLegacyTables() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🧹 Starting cleanup of obsolete legacy backup tables...');
const tablesToDrop = [
'asset_pc', 'asset_pc_backup',
'asset_server', 'asset_server_backup',
'asset_storage', 'asset_storage_backup',
'asset_remote_backup', // IMPORTANT: DO NOT drop asset_remote!
'asset_equipment', 'asset_equipment_backup',
'asset_office_supplies', 'asset_office_supplies_backup',
'asset_survey', 'asset_survey_backup',
'asset_vip', 'asset_vip_backup',
'asset_pc_parts'
];
for (const table of tablesToDrop) {
try {
await connection.query(`DROP TABLE IF EXISTS ${table}`);
console.log(`✅ Dropped table: ${table}`);
} catch (err) {
console.warn(`⚠️ Failed to drop table ${table}: ${err.message}`);
}
}
console.log('🎉 Cleanup complete. Database is now lean and mean.');
await connection.end();
}
dropLegacyTables().catch(console.error);

BIN
image 92.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

View File

Before

Width:  |  Height:  |  Size: 10 MiB

After

Width:  |  Height:  |  Size: 10 MiB

View File

Before

Width:  |  Height:  |  Size: 6.3 MiB

After

Width:  |  Height:  |  Size: 6.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 MiB

View File

Before

Width:  |  Height:  |  Size: 4.7 MiB

After

Width:  |  Height:  |  Size: 4.7 MiB

View File

Before

Width:  |  Height:  |  Size: 2.9 MiB

After

Width:  |  Height:  |  Size: 2.9 MiB

View File

Before

Width:  |  Height:  |  Size: 3.9 MiB

After

Width:  |  Height:  |  Size: 3.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 MiB

After

Width:  |  Height:  |  Size: 11 MiB

View File

Before

Width:  |  Height:  |  Size: 6.1 MiB

After

Width:  |  Height:  |  Size: 6.1 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 MiB

After

Width:  |  Height:  |  Size: 9.5 MiB

View File

Before

Width:  |  Height:  |  Size: 9.8 MiB

After

Width:  |  Height:  |  Size: 9.8 MiB

View File

Before

Width:  |  Height:  |  Size: 8.1 MiB

After

Width:  |  Height:  |  Size: 8.1 MiB

View File

Before

Width:  |  Height:  |  Size: 5.8 MiB

After

Width:  |  Height:  |  Size: 5.8 MiB

View File

@@ -5,9 +5,15 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>한맥가족 자산관리시스템</title>
<title>ITAM 자산관리 ERP</title>
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<link rel="stylesheet" href="/src/styles/common.css" />
<link rel="stylesheet" href="/src/styles/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" />
<link rel="stylesheet" href="/src/styles/table.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
</head>
@@ -19,7 +25,7 @@
<div class="header-container" id="nav-container">
<div class="brand">
<img src="/image 92.png" alt="Logo" class="main-logo" />
<h1>한맥자산관리시스템</h1>
<h1>자산관리시스템<span class="sub-title">(Digital Asset Control Hub System)</span></h1>
</div>
<!-- Navigation (GNB + LNB in same row) -->
@@ -51,7 +57,8 @@
<!-- Footer -->
<footer class="main-footer">
<p>&copy; 2026 BARON Consultant Co,Ltd. All rights reserved.</p>
<div id="secret-cloud-trigger" style="width: 20px; height: 20px; cursor: pointer; opacity: 0.1; background: #000; border-radius: 4px; position: absolute; left: 1rem;"></div>
<p>Powered by BARON Consultant Co,Ltd</p>
</footer>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -6,7 +6,7 @@
<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 class="editor-body">
<body style="margin: 0; display: flex; height: 100vh; overflow: hidden; font-family: sans-serif;">
<!-- Left: File Selector -->
<div class="file-sidebar" id="file-sidebar">
@@ -22,7 +22,7 @@
<!-- Right: Control Panel -->
<div class="sidebar">
<h2>Map Editor <small class="editor-version">v3.0</small></h2>
<h2>Map Editor <small style="font-size: 0.6em; color: #888;">v3.0</small></h2>
<div class="current-path" id="current-path">파일을 선택하세요</div>
<p>
드래그하여 구역을 정의하세요. 저장 버튼을 누르면 즉시 시스템에 반영됩니다.
@@ -31,8 +31,8 @@
<div class="box-list" id="box-list"></div>
<div class="actions">
<button id="btn-clear-all" class="btn btn-outline">전체 삭제</button>
<button id="btn-save-server" class="btn btn-primary">서버에 즉시 저장</button>
<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>

197
migrate_schema.js Normal file
View File

@@ -0,0 +1,197 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function migrateSchema() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 Phase 1: Creating Normalized Tables & Migrating Data...');
try {
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
// --- 1. Drop existing new tables if they exist ---
await connection.query('DROP TABLE IF EXISTS asset_core, asset_hardware, asset_location, asset_remote');
// --- 2. Create New Schema ---
await connection.query(`
CREATE TABLE asset_core (
id VARCHAR(50) PRIMARY KEY,
asset_code VARCHAR(100) UNIQUE NOT NULL,
category VARCHAR(100),
asset_type VARCHAR(100),
asset_purpose VARCHAR(255),
service_type VARCHAR(50),
purchase_corp VARCHAR(100),
purchase_date VARCHAR(50),
purchase_amount VARCHAR(100),
purchase_vendor VARCHAR(255),
approval_document VARCHAR(255),
memo TEXT,
manager_primary VARCHAR(100),
manager_secondary VARCHAR(100),
current_dept VARCHAR(255),
previous_dept VARCHAR(255),
user_current VARCHAR(100),
previous_user VARCHAR(100),
emp_no VARCHAR(20),
user_position VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_hardware (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
hw_status VARCHAR(50),
model_name VARCHAR(255),
mainboard VARCHAR(255),
os VARCHAR(100),
cpu VARCHAR(255),
ram VARCHAR(100),
gpu VARCHAR(100),
storage1 VARCHAR(255),
storage2 VARCHAR(255),
storage3 VARCHAR(255),
monitoring VARCHAR(100),
price VARCHAR(100),
volume VARCHAR(100),
monitor_inch VARCHAR(50),
serial_num VARCHAR(100),
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_location (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
location VARCHAR(255),
location_detail VARCHAR(255),
location_photo VARCHAR(255),
loc_x VARCHAR(20),
loc_y VARCHAR(20),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_remote (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
ip_address VARCHAR(100),
mac_address VARCHAR(100),
remote_tool VARCHAR(100),
remote_id VARCHAR(100),
remote_pw VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
console.log('✅ Normalized tables created.');
// --- 3. Migrate Data from Legacy Tables ---
const legacyTables = ['asset_pc', 'asset_server', 'asset_storage', 'asset_remote', 'asset_equipment', 'asset_office_supplies', 'asset_survey', 'asset_vip'];
let totalMigrated = 0;
for (const table of legacyTables) {
try {
const [rows] = await connection.query(`SELECT * FROM ${table}`);
for (const row of rows) {
// 3.1 Insert into asset_core
await connection.query(`
INSERT IGNORE INTO asset_core (
id, asset_code, category, asset_type, asset_purpose, service_type, purchase_corp, purchase_date,
purchase_amount, purchase_vendor, approval_document, memo, manager_primary, manager_secondary,
current_dept, previous_dept, user_current, previous_user, emp_no, user_position, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.asset_code, row.category, row.asset_type, row.asset_purpose, row.service_type,
row.purchase_corp, row.purchase_date, row.purchase_amount, row.purchase_vendor, row.approval_document,
row.memo, row.manager_primary, row.manager_secondary, row.current_dept, row.previous_dept,
row.user_current, row.previous_user, row.emp_no, row.user_position, row.created_at
]);
// 3.2 Insert into asset_hardware (if hardware fields exist)
if (row.model_name || row.cpu || row.ram || row.hw_status) {
await connection.query(`
INSERT INTO asset_hardware (
asset_id, hw_status, model_name, mainboard, os, cpu, ram, gpu, storage1, storage2, storage3, monitoring, price, volume, monitor_inch, serial_num
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.hw_status, row.model_name, row.mainboard, row.os, row.cpu, row.ram, row.gpu,
row.ssd_1 || row.hdd_1, row.ssd_2 || row.hdd_2, row.hdd_3, row.monitoring, row.price,
row.volume, row.monitor_inch, row.serial_num
]);
}
// 3.3 Insert into asset_location (if location fields exist)
if (row.location || row.location_detail) {
await connection.query(`
INSERT INTO asset_location (
asset_id, location, location_detail, location_photo, loc_x, loc_y
) VALUES (?, ?, ?, ?, ?, ?)
`, [
row.id, row.location, row.location_detail, row.location_photo, row.loc_x, row.loc_y
]);
}
// 3.4 Insert into asset_remote (if network fields exist)
// Handle primary network interface
if (row.ip_address || row.mac_address || row.remote_tool) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw
) VALUES (?, ?, ?, ?, ?, ?)
`, [
row.id, row.ip_address, row.mac_address, row.remote_tool, row.remote_id, row.remote_pw
]);
}
// Handle secondary network interface (e.g., from server table) if it exists
if (row.ip_address_2 || row.remote_tool_2) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, remote_tool, remote_id, remote_pw
) VALUES (?, ?, ?, ?, ?)
`, [
row.id, row.ip_address_2, row.remote_tool_2, row.remote_id_2, row.remote_pw_2
]);
}
totalMigrated++;
}
console.log(`- Migrated ${rows.length} records from ${table}`);
} catch (err) {
console.warn(`- Skipping legacy table ${table}: ${err.message}`);
}
}
console.log(`✅ Phase 1 Data Migration Completed. Total Assets Migrated: ${totalMigrated}`);
} catch (err) {
console.error('❌ Migration Failed:', err);
} finally {
await connection.end();
}
}
migrateSchema();

212
migrate_v2_final.js Normal file
View File

@@ -0,0 +1,212 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function migrateV2() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 Phase 2: Final Migration to Normalized V2 Schema...');
try {
await connection.query('SET FOREIGN_KEY_CHECKS = 0');
// 1. Create/Enhance Core Tables
console.log('1. Creating/Enhancing Tables...');
await connection.query('DROP TABLE IF EXISTS asset_core, asset_hardware, asset_location, asset_remote');
await connection.query(`
CREATE TABLE asset_core (
id VARCHAR(50) PRIMARY KEY,
asset_code VARCHAR(100) UNIQUE NOT NULL,
category VARCHAR(100),
asset_type VARCHAR(100),
current_role VARCHAR(50) DEFAULT 'Normal' COMMENT 'Normal, Server, Personal, etc.',
asset_purpose VARCHAR(255),
service_type VARCHAR(50),
purchase_corp VARCHAR(100),
purchase_date VARCHAR(50),
purchase_amount VARCHAR(100),
purchase_vendor VARCHAR(255),
approval_document VARCHAR(255),
memo TEXT,
manager_primary VARCHAR(100),
manager_secondary VARCHAR(100),
current_dept VARCHAR(255),
previous_dept VARCHAR(255),
user_current VARCHAR(100),
previous_user VARCHAR(100),
emp_no VARCHAR(20),
user_position VARCHAR(50),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_hardware (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
hw_status VARCHAR(50),
model_name VARCHAR(255),
mainboard VARCHAR(255),
os VARCHAR(100),
cpu VARCHAR(255),
ram VARCHAR(100),
gpu VARCHAR(100),
storage1 VARCHAR(255),
storage2 VARCHAR(255),
storage3 VARCHAR(255),
storage4 VARCHAR(255),
monitoring VARCHAR(100),
price VARCHAR(100),
volume VARCHAR(100),
monitor_inch VARCHAR(50),
serial_num VARCHAR(100),
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_location (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
location VARCHAR(255),
location_detail VARCHAR(255),
location_photo VARCHAR(255),
loc_x VARCHAR(20),
loc_y VARCHAR(20),
is_active TINYINT(1) DEFAULT 1,
deactivated_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
await connection.query(`
CREATE TABLE asset_remote (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
ip_address VARCHAR(100),
mac_address VARCHAR(100),
remote_tool VARCHAR(100),
remote_id VARCHAR(100),
remote_pw VARCHAR(100),
is_active TINYINT(1) DEFAULT 1,
deactivated_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ V2 Schema tables created.');
// 2. Migration Logic
const legacyTables = [
{ name: 'asset_pc', defaultRole: 'Personal' },
{ name: 'asset_server', defaultRole: 'Server' },
{ name: 'asset_storage', defaultRole: 'Normal' },
{ name: 'asset_equipment', defaultRole: 'Normal' },
{ name: 'asset_office_supplies', defaultRole: 'Normal' },
{ name: 'asset_survey', defaultRole: 'Normal' },
{ name: 'asset_vip', defaultRole: 'Normal' },
{ name: 'asset_pc_parts', defaultRole: 'Normal' }
];
let totalMigrated = 0;
for (const tableInfo of legacyTables) {
const table = tableInfo.name;
try {
const [rows] = await connection.query(`SELECT * FROM ${table}`);
console.log(`- Migrating ${rows.length} records from ${table}...`);
for (const row of rows) {
// 2.1 Insert into asset_core
const role = (table === 'asset_pc' && row.asset_type === '서버PC') ? 'Server' : tableInfo.defaultRole;
await connection.query(`
INSERT IGNORE INTO asset_core (
id, asset_code, category, asset_type, current_role, asset_purpose, service_type, purchase_corp, purchase_date,
purchase_amount, purchase_vendor, approval_document, memo, manager_primary, manager_secondary,
current_dept, previous_dept, user_current, previous_user, emp_no, user_position, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.asset_code, row.category, row.asset_type, role, row.asset_purpose, row.service_type,
row.purchase_corp, row.purchase_date, row.purchase_amount, row.purchase_vendor, row.approval_document,
row.memo, row.manager_primary, row.manager_secondary, row.current_dept, row.previous_dept,
row.user_current || row.current_user, row.previous_user, row.emp_no, row.user_position, row.created_at
]);
// 2.2 Insert into asset_hardware
await connection.query(`
INSERT INTO asset_hardware (
asset_id, hw_status, model_name, mainboard, os, cpu, ram, gpu, storage1, storage2, storage3, storage4, monitoring, price, volume, monitor_inch, serial_num
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, [
row.id, row.hw_status, row.model_name, row.mainboard, row.os, row.cpu, row.ram, row.gpu,
row.ssd_1 || row.storage1, row.ssd_2 || row.storage2, row.hdd_1 || row.storage3, row.hdd_2, row.monitoring, row.price,
row.volume, row.monitor_inch, row.serial_num
]);
// 2.3 Insert into asset_location
if (row.location || row.location_detail) {
await connection.query(`
INSERT INTO asset_location (
asset_id, location, location_detail, location_photo, loc_x, loc_y, is_active
) VALUES (?, ?, ?, ?, ?, ?, 1)
`, [
row.id, row.location, row.location_detail, row.location_photo, row.loc_x, row.loc_y
]);
}
// 2.4 Insert into asset_remote
// Primary Network
if (row.ip_address || row.mac_address || row.remote_tool) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, mac_address, remote_tool, remote_id, remote_pw, is_active
) VALUES (?, ?, ?, ?, ?, ?, 1)
`, [
row.id, row.ip_address, row.mac_address, row.remote_tool, row.remote_id, row.remote_pw
]);
}
// Secondary Network (for servers)
if (row.ip_address_2 || row.remote_tool_2) {
await connection.query(`
INSERT INTO asset_remote (
asset_id, ip_address, remote_tool, remote_id, remote_pw, is_active
) VALUES (?, ?, ?, ?, ?, 1)
`, [
row.id, row.ip_address_2, row.remote_tool_2, row.remote_id_2, row.remote_pw_2
]);
}
totalMigrated++;
}
} catch (err) {
console.warn(`- Skipping table ${table}: ${err.message}`);
}
}
await connection.query('SET FOREIGN_KEY_CHECKS = 1');
console.log(`✅ Phase 2 Data Migration Completed. Total Assets Migrated: ${totalMigrated}`);
} catch (err) {
console.error('❌ Migration Failed:', err);
} finally {
await connection.end();
}
}
migrateV2();

73
migrate_v4_network.js Normal file
View File

@@ -0,0 +1,73 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
async function migrate() {
const conn = await pool.getConnection();
try {
console.log('1. Creating asset_remote_v4 table...');
await conn.query(`
CREATE TABLE IF NOT EXISTS asset_remote_v4 (
id INT AUTO_INCREMENT PRIMARY KEY,
asset_id VARCHAR(50) NOT NULL,
net_type VARCHAR(20) NOT NULL, /* 'IP' or 'REMOTE' */
net_name VARCHAR(100), /* e.g., '기본망', 'AnyDesk' */
net_value1 VARCHAR(100), /* IP or ID */
net_value2 VARCHAR(100), /* MAC or PW */
is_active TINYINT(1) DEFAULT 1,
deactivated_at DATETIME NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (asset_id) REFERENCES asset_core(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('2. Migrating data from asset_remote...');
const [oldRows] = await conn.query('SELECT * FROM asset_remote WHERE is_active = 1');
let ipCount = 0;
let remoteCount = 0;
for (const row of oldRows) {
// Migrating IP/MAC
if (row.ip_address || row.mac_address) {
await conn.query(
'INSERT INTO asset_remote_v4 (asset_id, net_type, net_name, net_value1, net_value2, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[row.asset_id, 'IP', '기본망', row.ip_address, row.mac_address, row.created_at]
);
ipCount++;
}
// Migrating Remote
if (row.remote_tool || row.remote_id || row.remote_pw) {
await conn.query(
'INSERT INTO asset_remote_v4 (asset_id, net_type, net_name, net_value1, net_value2, created_at) VALUES (?, ?, ?, ?, ?, ?)',
[row.asset_id, 'REMOTE', row.remote_tool, row.remote_id, row.remote_pw, row.created_at]
);
remoteCount++;
}
}
console.log(`Migrated ${ipCount} IP records and ${remoteCount} Remote records.`);
console.log('3. Renaming tables...');
await conn.query('DROP TABLE IF EXISTS asset_remote_legacy');
await conn.query('RENAME TABLE asset_remote TO asset_remote_legacy, asset_remote_v4 TO asset_remote;');
console.log('✅ Migration V4 (Remote) Complete.');
} catch (e) {
console.error('Migration failed:', e);
} finally {
conn.release();
pool.end();
}
}
migrate();

View File

@@ -0,0 +1,28 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
async function migrate() {
const conn = await pool.getConnection();
try {
console.log('1. Renaming asset_network to asset_remote...');
await conn.query('RENAME TABLE asset_network TO asset_remote');
console.log('✅ Table renamed successfully.');
} catch (e) {
console.error('Migration failed:', e);
} finally {
conn.release();
pool.end();
}
}
migrate();

195
migrate_v6_parts_master.js Normal file
View File

@@ -0,0 +1,195 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config({ override: true });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
// 기존의 감점 계산 로직을 그대로 이용해 등급과 감점점수를 도출하는 헬퍼 함수
function parseCpu(cpu) {
if (!cpu) return { tier: '기타', deduction: 30 };
const cpuUpper = cpu.toUpperCase().trim();
if (cpuUpper === '-' || cpuUpper === '') return { tier: '기타', deduction: 30 };
let tier = '기타';
let deduction = 30;
if (cpuUpper.includes('I9') || cpuUpper.includes('RYZEN 9') || cpuUpper.includes('RYZEN9')) {
tier = 'i9 / Ryzen 9';
deduction = 0;
} else if (cpuUpper.includes('I7') || cpuUpper.includes('RYZEN 7') || cpuUpper.includes('RYZEN7')) {
tier = 'i7 / Ryzen 7';
deduction = 5;
} else if (cpuUpper.includes('I5') || cpuUpper.includes('RYZEN 5') || cpuUpper.includes('RYZEN5')) {
tier = 'i5 / Ryzen 5';
deduction = 15;
} else if (cpuUpper.includes('I3') || cpuUpper.includes('RYZEN 3') || cpuUpper.includes('RYZEN3')) {
tier = 'i3 / Ryzen 3';
deduction = 25;
}
// CPU 세대 감점 계산 (최대 -15점)
let genDeduction = 0;
const intelMatch = cpuUpper.match(/I\d-?(\d+)/);
let gen = 0;
if (intelMatch && intelMatch[1]) {
const numStr = intelMatch[1];
if (numStr.length === 5) gen = parseInt(numStr.substring(0, 2), 10);
else if (numStr.length === 4) gen = parseInt(numStr.substring(0, 1), 10);
}
const amdMatch = cpuUpper.match(/RYZEN\s?\d\s?-?(\d+)/);
let amdGen = 0;
if (amdMatch && amdMatch[1] && !intelMatch) {
const numStr = amdMatch[1];
if (numStr.length === 4) amdGen = parseInt(numStr.substring(0, 1), 10);
}
if (intelMatch) {
if (gen >= 12) genDeduction = 0;
else if (gen >= 10) genDeduction = 5;
else if (gen >= 8) genDeduction = 10;
else genDeduction = 15;
} else if (amdMatch) {
if (amdGen >= 5) genDeduction = 0;
else if (amdGen >= 3) genDeduction = 5;
else genDeduction = 10;
} else {
genDeduction = 15;
}
// 최종 등급 감점 + 세대 감점 합산
return { tier, deduction: deduction + genDeduction };
}
function parseGpu(gpu) {
if (!gpu) return { tier: 'C', deduction: 25 };
const gpuUpper = gpu.toUpperCase().trim();
if (gpuUpper === '-' || gpuUpper === '') return { tier: 'C', deduction: 25 };
if (
gpuUpper.includes('RTX 4090') || gpuUpper.includes('RTX 4080') || gpuUpper.includes('RTX 4070') ||
gpuUpper.includes('RTX A5000') || gpuUpper.includes('RTX A6000') || gpuUpper.includes('RTX A4000')
) {
return { tier: 'S', deduction: 0 };
} else if (
gpuUpper.includes('RTX 3070') || gpuUpper.includes('RTX 3060') || gpuUpper.includes('RTX 2060') ||
gpuUpper.includes('RTX A2000') || gpuUpper.includes('RTX A3000') || gpuUpper.includes('QUADRO')
) {
return { tier: 'A', deduction: 5 };
} else if (
gpuUpper.includes('GTX 1660') || gpuUpper.includes('GTX 1080') || gpuUpper.includes('GTX 1070') ||
gpuUpper.includes('GTX 1060') || gpuUpper.includes('RX 6700') || gpuUpper.includes('RX 6600')
) {
return { tier: 'B', deduction: 15 };
} else {
return { tier: 'C', deduction: 25 };
}
}
function parseRam(ram) {
if (!ram) return { tier: '부족', deduction: 25 };
const ramUpper = ram.toUpperCase().trim();
if (ramUpper === '-' || ramUpper === '') return { tier: '부족', deduction: 25 };
const ramMatch = ramUpper.match(/(\d+)\s*GB/);
if (ramMatch && ramMatch[1]) {
const ramVal = parseInt(ramMatch[1], 10);
if (ramVal >= 32) return { tier: '최적', deduction: 0 };
else if (ramVal >= 16) return { tier: '보통', deduction: 10 };
else if (ramVal >= 8) return { tier: '주의', deduction: 20 };
}
return { tier: '부족', deduction: 25 };
}
async function runMigration() {
console.log('🔄 DB 커넥션 연결 중...');
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
try {
console.log('⚙️ 1. hardware_components_master 테이블 생성...');
await connection.query('DROP TABLE IF EXISTS hardware_components_master');
await connection.query(`
CREATE TABLE hardware_components_master (
id INT AUTO_INCREMENT PRIMARY KEY,
category VARCHAR(50) NOT NULL COMMENT 'CPU, GPU, RAM 등',
component_name VARCHAR(255) NOT NULL UNIQUE COMMENT '부품 표준 명칭',
score_tier VARCHAR(50) COMMENT '성능 등급',
deduction INT DEFAULT 0 COMMENT '감점 점수',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ 테이블 생성 완료.');
console.log('🔍 2. 기존 asset_spec 테이블에서 부품명 조회...');
const [specRows] = await connection.query('SELECT DISTINCT cpu, ram, gpu FROM asset_spec');
const uniqueCpus = new Set();
const uniqueGpus = new Set();
const uniqueRams = new Set();
specRows.forEach(row => {
if (row.cpu && row.cpu.trim() !== '-' && row.cpu.trim() !== '') uniqueCpus.add(row.cpu.trim());
if (row.gpu && row.gpu.trim() !== '-' && row.gpu.trim() !== '') uniqueGpus.add(row.gpu.trim());
if (row.ram && row.ram.trim() !== '-' && row.ram.trim() !== '') uniqueRams.add(row.ram.trim());
});
// 만약 데이터가 너무 비어있을 경우를 대비하여 기본 대표 부품 몇 개 추가
if (uniqueCpus.size === 0) {
['Intel Core i9-13900K', 'Intel Core i7-14700K', 'Intel Core i5-12400', 'AMD Ryzen 7 7800X3D', 'Intel Core i3-10100'].forEach(c => uniqueCpus.add(c));
}
if (uniqueGpus.size === 0) {
['NVIDIA GeForce RTX 4090', 'NVIDIA GeForce RTX 4070', 'NVIDIA GeForce RTX 3060', 'Intel Iris Xe Graphics', 'NVIDIA GeForce GTX 1660 Super'].forEach(g => uniqueGpus.add(g));
}
if (uniqueRams.size === 0) {
['8GB', '16GB', '32GB', '64GB'].forEach(r => uniqueRams.add(r));
}
console.log(` - 추출된 CPU 개수: ${uniqueCpus.size}`);
console.log(` - 추출된 GPU 개수: ${uniqueGpus.size}`);
console.log(` - 추출된 RAM 개수: ${uniqueRams.size}`);
console.log('💾 3. 마스터 테이블에 부품 데이터 및 감점 정보 삽입...');
// CPU 삽입
for (const cpu of uniqueCpus) {
const { tier, deduction } = parseCpu(cpu);
await connection.query(
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
['CPU', cpu, tier, deduction]
);
}
// GPU 삽입
for (const gpu of uniqueGpus) {
const { tier, deduction } = parseGpu(gpu);
await connection.query(
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
['GPU', gpu, tier, deduction]
);
}
// RAM 삽입
for (const ram of uniqueRams) {
const { tier, deduction } = parseRam(ram);
await connection.query(
'INSERT IGNORE INTO hardware_components_master (category, component_name, score_tier, deduction) VALUES (?, ?, ?, ?)',
['RAM', ram, tier, deduction]
);
}
console.log('✅ 마이그레이션이 성공적으로 완료되었습니다!');
} catch (error) {
console.error('❌ 마이그레이션 오류 발생:', error);
} finally {
await connection.end();
}
}
runMigration();

36
probe_db.js Normal file
View File

@@ -0,0 +1,36 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function probeDB() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('--- Database Probe Start ---');
const [tables] = await connection.query('SHOW TABLES');
const tableNames = tables.map(t => Object.values(t)[0]);
console.log('Existing Tables:', tableNames);
for (const table of tableNames) {
const [columns] = await connection.query(`DESCRIBE ${table}`);
console.log(`\n[Table: ${table}]`);
columns.forEach(c => {
console.log(` - ${c.Field} (${c.Type}) ${c.Comment ? '// ' + c.Comment : ''}`);
});
}
await connection.end();
console.log('\n--- Database Probe End ---');
}
probeDB().catch(console.error);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 388 KiB

View File

@@ -1,354 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Center Chair Map (View Only)</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
overflow: hidden;
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
z-index: 2;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
display: inline-block;
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
canvas {
width: 100vw;
height: 100vh;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
</div>
<canvas id="canvas"></canvas>
</main>
</div>
</div>
<script src="./center_chair_people_payload.js?v=20260403a"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const scaleChip = document.getElementById("scale-chip");
// --- Added for Point Picking & Marker ---
const params = new URLSearchParams(window.location.search);
let markerX = params.get('markerX') ? parseFloat(params.get('markerX')) : null;
let markerY = params.get('markerY') ? parseFloat(params.get('markerY')) : null;
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return { ...chair, minX, minY, maxX, maxY, path };
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let dragging = false;
let dragStart = null;
let rafPending = false;
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (Math.max(x1, x2) < viewMinX || Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY || Math.min(y1, y2) > viewMaxY) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.lineWidth = 1.35 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(21, 149, 142, 0.8)";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
ctx.stroke(chair.path);
}
// --- Draw Marker ---
if (markerX !== null && markerY !== null) {
ctx.beginPath();
ctx.arc(markerX, markerY, 50 / camera.scale, 0, Math.PI * 2);
ctx.fillStyle = "rgba(220, 38, 38, 0.8)";
ctx.fill();
ctx.strokeStyle = "#fff";
ctx.lineWidth = 10 / camera.scale;
ctx.stroke();
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const worldPos = screenToWorld(mx, my);
markerX = worldPos.x;
markerY = worldPos.y;
requestDraw();
// Notify parent window
window.parent.postMessage({
type: 'PICK_LOCATION',
x: markerX.toFixed(2),
y: markerY.toFixed(2)
}, '*');
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
});
window.addEventListener("pointermove", (event) => {
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
requestDraw();
}
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
window.addEventListener("resize", resize);
resize();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -1,931 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload.js?v=20260403a"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

View File

@@ -1,932 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map 6f</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload_6f.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

View File

@@ -1,932 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map 7f</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload_7f.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 213 KiB

24
scratch/analyze_codes.cjs Normal file
View File

@@ -0,0 +1,24 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function analyzeCodes() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
// 새 자산들의 연도 분포 확인
const [years] = await connection.query('SELECT DISTINCT purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%"');
console.log('New assets years:', years.map(y => y.purchase_date));
// 기존 자산 코드 패턴 확인
const [existing] = await connection.query('SELECT asset_code FROM asset_core WHERE asset_code LIKE "PC-%" LIMIT 5');
console.log('Existing code sample:', existing);
await connection.end();
}
analyzeCodes().catch(console.error);

View 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);

View File

@@ -0,0 +1,11 @@
const XLSX = require('xlsx');
const workbook = XLSX.readFile('backupDB_20260602.xlsx');
console.log('Sheet Names:', workbook.SheetNames);
if (workbook.SheetNames.includes('system_users')) {
const sheet = workbook.Sheets['system_users'];
const data = XLSX.utils.sheet_to_json(sheet);
console.log('system_users found! Count:', data.length);
console.log('Sample:', data.slice(0, 2));
} else {
console.log('system_users sheet not found in backupDB_20260602.xlsx');
}

24
scratch/check_codes.cjs Normal file
View File

@@ -0,0 +1,24 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function checkCodes() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
console.log('--- Asset Codes Sample ---');
const [rows] = await connection.query('SELECT id, asset_code, purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%" LIMIT 10');
console.log(rows);
console.log('\n--- Other Asset Codes Sample ---');
const [rows2] = await connection.query('SELECT id, asset_code, purchase_date FROM asset_core WHERE id NOT LIKE "PC_20260615_%" AND asset_code IS NOT NULL LIMIT 5');
console.log(rows2);
await connection.end();
}
checkCodes().catch(console.error);

View File

@@ -0,0 +1,40 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function checkPublicPCs() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
console.log('🔍 공용 PC(Public PC)로 추정되는 자산 조회 중...');
// 사번이 없거나, 사용자명에 '공용'이 포함된 데이터 조회
const [rows] = await connection.query(`
SELECT id, asset_code, user_current, emp_no, current_dept, asset_type
FROM asset_core
WHERE (emp_no IS NULL OR emp_no = '' OR user_current LIKE '%공용%')
AND id LIKE 'PC_20260615_%'
`);
console.log(`📊 발견된 공용 PC 후보: ${rows.length}`);
if (rows.length > 0) {
console.table(rows.slice(0, 20)); // 상위 20개 샘플 출력
// 요약 통계
const summary = {
only_no_emp: rows.filter(r => (!r.emp_no) && !r.user_current.includes('공용')).length,
only_public_name: rows.filter(r => r.emp_no && r.user_current.includes('공용')).length,
both: rows.filter(r => (!r.emp_no) && r.user_current.includes('공용')).length
};
console.log('\n📈 요약 통계:', summary);
}
await connection.end();
}
checkPublicPCs().catch(console.error);

View File

@@ -0,0 +1,77 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function updateAndCompare() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
console.log('🚀 [Step 1 & 2] "undefined" 사번 및 빈 사용자명 정리 중...');
const [updateResult] = await connection.query(`
UPDATE asset_core
SET user_current = '공용', emp_no = NULL
WHERE id LIKE "PC_20260615_%" AND (emp_no = 'undefined' OR emp_no IS NULL OR emp_no = '')
`);
console.log(`✅ 업데이트 완료: ${updateResult.affectedRows}`);
console.log('\n🔍 [Step 3] 엑셀 데이터와 DB asset_type 비교 분석 중...');
const XLSX = require('xlsx');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const excelData = XLSX.utils.sheet_to_json(sheet);
// DB 데이터 로드
const [dbRows] = await connection.query('SELECT id, asset_type, user_current, emp_no FROM asset_core WHERE id LIKE "PC_20260615_%"');
const dbMap = new Map();
dbRows.forEach(r => dbMap.set(r.id, r));
const mismatches = [];
const publicButExcelPersonal = [];
for (let i = 0; i < excelData.length; i++) {
const excelRow = excelData[i];
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const dbRow = dbMap.get(assetId);
if (!dbRow) continue;
const excelType = excelRow.asset_type || '개인PC';
// 1. 단순 타입 불일치 체크
if (dbRow.asset_type !== excelType) {
mismatches.push({
id: assetId,
excel_type: excelType,
db_type: dbRow.asset_type,
user: dbRow.user_current
});
}
// 2. 엑셀은 '개인PC'인데 데이터는 공용(사번없음)인 경우 탐색
if (excelType === '개인PC' && (!dbRow.emp_no || dbRow.user_current === '공용')) {
publicButExcelPersonal.push({
id: assetId,
excel_user: excelRow.user_current,
excel_dept: excelRow.current_dept,
db_user: dbRow.user_current
});
}
}
console.log(`\n📊 분석 결과:`);
console.log(`- 엑셀과 DB의 asset_type 불일치: ${mismatches.length}`);
console.log(`- 엑셀은 '개인PC'이나 사번이 없어 '공용'으로 잡힌 항목: ${publicButExcelPersonal.length}`);
if (publicButExcelPersonal.length > 0) {
console.log('\n⚠ 엑셀은 개인PC이나 데이터가 미비한 항목 (상위 10개):');
console.table(publicButExcelPersonal.slice(0, 10));
}
await connection.end();
}
updateAndCompare().catch(console.error);

25
scratch/debug_public.cjs Normal file
View File

@@ -0,0 +1,25 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function debugPublic() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
const [rows] = await connection.query(`
SELECT user_current, emp_no, COUNT(*) as count
FROM asset_core
WHERE id LIKE "PC_20260615_%"
GROUP BY user_current, emp_no
HAVING emp_no IS NULL OR emp_no = '' OR user_current LIKE '%공용%' OR user_current = ''
`);
console.table(rows);
await connection.end();
}
debugPublic().catch(console.error);

69
scratch/deep_audit.cjs Normal file
View File

@@ -0,0 +1,69 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
require('dotenv').config();
async function deepAudit() {
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const excelData = XLSX.utils.sheet_to_json(sheet);
console.log('📊 [Excel Audit] Total Rows:', excelData.length);
// 1. 엑셀 내 asset_type 종류 확인
const excelTypes = new Set();
excelData.forEach(r => excelTypes.add(r.asset_type));
console.log('Excel Asset Types:', Array.from(excelTypes));
// 2. '공용' 키워드가 들어간 모든 행 추출
const publicKeywords = ['공용', '공통', '테스트', 'TEST'];
const potentialPublicInExcel = excelData.filter(r => {
const name = String(r.user_current || '');
const type = String(r.asset_type || '');
const memo = String(r.memo || '');
return publicKeywords.some(k => name.includes(k) || type.includes(k) || memo.includes(k)) || !r.emp_no;
});
console.log(`\n🔍 [Potential Public/Issue Rows in Excel]: ${potentialPublicInExcel.length}`);
console.table(potentialPublicInExcel.slice(0, 30).map(r => ({
emp_no: r.emp_no,
user: r.user_current,
dept: r.current_dept,
type: r.asset_type,
memo: r.memo
})));
// 3. DB와 대조 (특히 엑셀엔 사번이 있는데 DB엔 공용으로 된 게 있는지)
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
const [dbRows] = await connection.query('SELECT id, user_current, emp_no, asset_type FROM asset_core WHERE id LIKE "PC_20260615_%"');
// 엑셀은 개인PC인데 DB는 공용인 경우 (또는 그 반대)
const issues = [];
for (let i = 0; i < excelData.length; i++) {
const ex = excelData[i];
const id = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const db = dbRows.find(r => r.id === id);
if (!db) continue;
const isExcelPublic = !ex.emp_no || String(ex.user_current).includes('공용');
const isDbPublic = !db.emp_no || String(db.user_current).includes('공용');
if (isExcelPublic !== isDbPublic) {
issues.push({ id, excel_user: ex.user_current, db_user: db.user_current, excel_emp: ex.emp_no, db_emp: db.emp_no });
}
}
console.log(`\n⚠️ [Consistency Issues]: ${issues.length}`);
if (issues.length > 0) console.table(issues);
await connection.end();
}
deepAudit().catch(console.error);

View File

@@ -0,0 +1,61 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function extractFailures() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🔍 실패 데이터 추출 중...');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawData = XLSX.utils.sheet_to_json(sheet);
// 현재 DB에 존재하는 모든 asset_core ID 조회
const [existingRows] = await connection.query('SELECT id FROM asset_core');
const existingIds = new Set(existingRows.map(r => r.id));
const failures = [];
for (let i = 0; i < rawData.length; i++) {
const row = rawData[i];
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
// DB에 해당 ID가 없는 경우 = 실패(충돌 등의 이유로 입력되지 않음) 또는 스킵된 데이터
// 하지만 이전 로그에서 'Duplicate entry'로 에러가 났던 항목들을 찾는 것이 목적
// 로직상 ID 생성 규칙에 따라 해당 ID가 DB에 없으면 입력에 실패한 행임
if (!existingIds.has(assetId)) {
failures.push({
excel_row: i + 2,
generated_id: assetId,
...row
});
}
}
if (failures.length > 0) {
const newWb = XLSX.utils.book_new();
const newWs = XLSX.utils.json_to_sheet(failures);
XLSX.utils.book_append_sheet(newWb, newWs, 'Failures');
const fileName = 'asset_pc_failures_20260615.xlsx';
XLSX.writeFile(newWb, fileName);
console.log(`✅ 추출 완료: ${failures.length}건의 실패 데이터를 ${fileName}에 저장했습니다.`);
} else {
console.log('입력되지 않은 데이터가 없습니다.');
}
await connection.end();
}
extractFailures().catch(console.error);

29
scratch/find_public.cjs Normal file
View File

@@ -0,0 +1,29 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function findPotentialPublic() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
console.log('--- Searching for rows with no emp_no or "공용" in user_current ---');
// 사번이 'undefined', 'null', 빈값, 또는 사용자명에 '공용'이 들어간 데이터
const [rows] = await connection.query(`
SELECT id, user_current, emp_no
FROM asset_core
WHERE id LIKE "PC_20260615_%"
AND (emp_no IS NULL OR emp_no = '' OR emp_no = 'undefined' OR user_current LIKE '%공용%')
`);
console.log('Count:', rows.length);
if (rows.length > 0) console.table(rows);
await connection.end();
}
findPotentialPublic().catch(console.error);

View File

@@ -0,0 +1,47 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function fixAssetTypes() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
console.log('🚀 [데이터 정상화] 사번 기준 자산 유형 재설정 시작...');
// 1. 사번이 있는 모든 신규 자산을 '개인PC'로 강제 전환
const [personalResult] = await connection.query(`
UPDATE asset_core
SET asset_type = '개인PC'
WHERE id LIKE "PC_20260615_%"
AND emp_no IS NOT NULL
AND emp_no != ''
`);
console.log(`✅ 개인PC 정상화 완료: ${personalResult.affectedRows}건 (사번 존재 항목)`);
// 2. 사번이 없는 모든 신규 자산을 '공용PC'로 강제 전환
const [publicResult] = await connection.query(`
UPDATE asset_core
SET asset_type = '공용PC', user_current = '공용'
WHERE id LIKE "PC_20260615_%"
AND (emp_no IS NULL OR emp_no = '')
`);
console.log(`✅ 공용PC 정상화 완료: ${publicResult.affectedRows}건 (사번 부재 항목)`);
// 3. 최종 결과 확인
const [rows] = await connection.query(`
SELECT asset_type, COUNT(*) as count
FROM asset_core
WHERE id LIKE "PC_20260615_%"
GROUP BY asset_type
`);
console.log('\n📊 최종 자산 유형 분포:');
console.table(rows);
await connection.end();
}
fixAssetTypes().catch(console.error);

View File

@@ -1,118 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
// 하드웨어 출시 연도 데이터베이스 (CPU/GPU)
const RELEASE_DATES = {
// Intel CPU Generations (Mainstream desktop release month/year)
'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10',
'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10',
'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11',
'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03',
'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05',
'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10',
'i7-8': '2017-10', 'i5-8': '2017-10',
'i7-7': '2017-01', 'i5-7': '2017-01',
'i7-6': '2015-08', 'i5-6': '2015-08',
'i7-4': '2013-06', 'i5-4': '2013-06',
'i7-3': '2012-04', 'i5-3': '2012-04',
'i7-2': '2011-01', 'i5-2': '2011-01',
// NVIDIA GPU Series
'RTX 4090': '2022-10', 'RTX 4080': '2022-11', 'RTX 4070': '2023-04', 'RTX 4060': '2023-06',
'RTX 3090': '2020-09', 'RTX 3080': '2020-09', 'RTX 3070': '2020-10', 'RTX 3060': '2021-02',
'RTX 2080': '2018-09', 'RTX 2070': '2018-10', 'RTX 2060': '2019-01',
'GTX 1660': '2019-03', 'GTX 1650': '2019-04',
'GTX 1080': '2016-05', 'GTX 1070': '2016-06', 'GTX 1060': '2016-07', 'GTX 1050': '2016-10',
'GTX 980': '2014-09', 'GTX 970': '2014-09', 'GTX 960': '2015-01'
};
function inferDateFromSpecs(cpu, gpu) {
const cpuStr = (cpu || '').toUpperCase();
const gpuStr = (gpu || '').toUpperCase();
let inferred = null;
// 1. GPU 기준 (최신 그래픽카드가 꽂혀있으면 그 시기 이후 구매일 확률이 높음)
for (const [key, date] of Object.entries(RELEASE_DATES)) {
if (gpuStr.includes(key)) {
inferred = date;
break;
}
}
// 2. CPU 기준 (GPU에서 못 찾았거나, CPU가 더 최신일 경우)
if (!inferred) {
for (const [key, date] of Object.entries(RELEASE_DATES)) {
// i7-13700 등을 찾기 위해 정규식 또는 포함 여부 확인
if (cpuStr.includes(key)) {
inferred = date;
break;
}
}
}
return inferred ? `${inferred}-01` : null;
}
async function run() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.query(`
SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu
FROM asset_core c
LEFT JOIN asset_spec s ON c.id = s.asset_id
`);
const updates = [];
const unchanged = [];
for (const row of rows) {
const currentVal = (row.purchase_date || '').trim();
// 구매일자가 없거나 부정확한 경우만 처리
if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('2024-01-01')) {
const specDate = inferDateFromSpecs(row.cpu, row.gpu);
if (specDate) {
updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
} else {
unchanged.push({ code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
}
}
}
console.log(`🚀 스펙 분석 결과: ${updates.length}건의 자산 구매일자를 보정합니다.`);
for (const item of updates) {
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`);
}
if (unchanged.length > 0) {
console.log('\n⚠ 스펙 정보를 찾을 수 없어 보정하지 못한 자산:');
unchanged.forEach(u => {
if (u.code) console.log(`[Skip] ${u.code.padEnd(15)} | CPU: ${u.cpu || '-'} | GPU: ${u.gpu || '-'}`);
});
}
console.log(`\n✅ 완료: ${updates.length}건 보정됨.`);
} catch (err) {
console.error('Error:', err);
} finally {
connection.release();
pool.end();
}
}
run();

View File

@@ -1,128 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
// 하드웨어 출시 연도/월 데이터베이스
const RELEASE_DATES = {
// Intel CPU
'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10',
'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10',
'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11',
'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03',
'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05',
'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10',
'i7-8': '2017-10', 'i5-8': '2017-10',
'i7-7': '2017-01', 'i5-7': '2017-01',
'i7-6': '2015-08', 'i5-6': '2015-08',
'i7-5': '2014-06', 'i5-5': '2015-06', // Broadwell
'i7-4': '2013-06', 'i5-4': '2013-06',
'i7-3': '2012-04', 'i5-3': '2012-04',
'i7-2': '2011-01', 'i5-2': '2011-01',
// NVIDIA GPU
'RTX 40': '2022-10',
'RTX 30': '2020-09',
'RTX 20': '2018-09',
'GTX 16': '2019-02',
'GTX 10': '2016-05',
'GTX 9': '2014-09',
'GTX 750': '2014-02',
'GTX 7': '2013-05',
'GTX 6': '2012-03'
};
// 출시 연도만 있는 경우 (지시에 따라 후속년도 12월 적용을 위함)
const YEAR_ONLY = {
'I5-4': 2013,
'I5-6': 2015,
'I7-7': 2017,
'GTX 750': 2014
};
function inferDateFromSpecs(cpu, gpu) {
const cpuStr = (cpu || '').toUpperCase();
const gpuStr = (gpu || '').toUpperCase();
let latestYear = 0;
let latestMonth = 0;
// 모든 매핑 데이터를 순회하며 가장 최신 날짜를 찾음
for (const [key, dateStr] of Object.entries(RELEASE_DATES)) {
if (cpuStr.includes(key) || gpuStr.includes(key)) {
const [y, m] = dateStr.split('-').map(Number);
if (y > latestYear || (y === latestYear && m > latestMonth)) {
latestYear = y;
latestMonth = m;
}
}
}
// 매칭된 정보가 있는 경우
if (latestYear > 0) {
// 월 정보가 명확히 매핑된 경우 (RELEASE_DATES 사용)
// 하지만 지시사항에 따라 "월을 못찾으면 12월" & "후속년도" 규칙 적용 여부 판단
// RELEASE_DATES는 월이 이미 있으므로 그대로 사용하되,
// 만약 YEAR_ONLY에만 걸리는 경우를 위해 로직 보강
return `${latestYear}-${String(latestMonth).padStart(2, '0')}-01`;
}
// 연도만 매칭되는 경우 (지시사항: 후속년도 12월)
for (const [key, year] of Object.entries(YEAR_ONLY)) {
if (cpuStr.includes(key) || gpuStr.includes(key)) {
return `${year + 1}-12-01`;
}
}
return null;
}
async function run() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.query(`
SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu
FROM asset_core c
LEFT JOIN asset_spec s ON c.id = s.asset_id
`);
const updates = [];
for (const row of rows) {
const currentVal = (row.purchase_date || '').trim();
// 구매일자가 없거나 '-', 'undefined'인 경우 + 혹은 아직 보정이 필요한 자산
if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('0000') || currentVal === '2024-01-01') {
const specDate = inferDateFromSpecs(row.cpu, row.gpu);
if (specDate) {
updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
}
}
}
console.log(`🚀 지시사항 반영: ${updates.length}건의 자산을 보정합니다. (후속년도/12월 규칙 적용)`);
for (const item of updates) {
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`);
}
console.log(`\n✅ 완료: ${updates.length}건 보정됨.`);
} catch (err) {
console.error('Error:', err);
} finally {
connection.release();
pool.end();
}
}
run();

View File

@@ -1,88 +0,0 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
async function run() {
const connection = await pool.getConnection();
try {
// 먼저 잘못 들어간 0000-00-01 등 복구
console.log('잘못된 형식(0000-00-01 등)을 초기화합니다...');
await connection.query("UPDATE asset_core SET purchase_date = '-' WHERE purchase_date LIKE '0000%' OR purchase_date = '2020-01-01'");
const [rows] = await connection.query('SELECT id, asset_code, purchase_date, category FROM asset_core');
const updates = [];
const missing = [];
for (const row of rows) {
const code = (row.asset_code || '').trim();
const currentVal = (row.purchase_date || '').trim();
// 구매일자가 없거나 '-', 'undefined' 인 경우 대상
if (!currentVal || currentVal === '-' || currentVal === 'undefined') {
let inferredDate = null;
// 1. PREFIX-YYYYMM-NNNN 형식 (예: PC-202406-0001)
const match6 = code.match(/[A-Z]+-(\d{4})(0[1-9]|1[0-2])-\d+/);
if (match6) {
inferredDate = `${match6[1]}-${match6[2]}-01`;
} else {
// 2. PREFIX-YYYYNN 형식 (예: PC-202423) -> 연도만 있고 뒤에 순번 2자리
const matchYearSeq = code.match(/[A-Z]+-(20\d{2})(\d{2})$/);
if (matchYearSeq) {
inferredDate = `${matchYearSeq[1]}-01-01`; // 월을 모르므로 1월로 통일
} else {
// 3. PREFIX-YYNNN 형식 (예: PC-24001)
const matchShort = code.match(/[A-Z]+-(1\d|2\d)(\d{3})/);
if (matchShort) {
inferredDate = `20${matchShort[1]}-01-01`;
}
}
}
// 0000 등의 잘못된 매칭 방지
if (inferredDate && !inferredDate.startsWith('0000')) {
updates.push({ id: row.id, date: inferredDate, code: code });
} else {
missing.push({ id: row.id, code: code, category: row.category });
}
}
}
console.log(`${updates.length}건의 자산을 업데이트합니다.`);
for (const item of updates) {
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
console.log(`[Update] ${item.code} -> ${item.date}`);
}
console.log('\n--- 구매일자를 추정할 수 없는 자산 목록 ---');
if (missing.length === 0) {
console.log('없음');
} else {
// 중복 제거 및 정렬하여 보고
const uniqueMissing = missing.filter(m => m.code !== '');
uniqueMissing.forEach(m => {
console.log(`[Missing] 코드: ${m.code.padEnd(20)} | 카테고리: ${m.category}`);
});
}
console.log(`\n완료: ${updates.length}건 업데이트됨, ${missing.length}건 미결정.`);
} catch (err) {
console.error('Error:', err);
} finally {
connection.release();
pool.end();
}
}
run();

View File

@@ -0,0 +1,122 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function importAssets() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 [Step 1] 데이터 로드 및 사전 준비...');
// 1. 엑셀 파일 로드
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawData = XLSX.utils.sheet_to_json(sheet);
// 2. system_users 데이터 맵 생성 (사번 기준 빠른 조회를 위함)
const [userRows] = await connection.query('SELECT emp_no, user_name, dept_name, position, status FROM system_users');
const userMap = new Map();
userRows.forEach(u => userMap.set(String(u.emp_no), u));
// 3. 기존 자산 중복 체크용 맵 생성 (emp_no + asset_type + category)
const [existingAssets] = await connection.query('SELECT emp_no, asset_type, category FROM asset_core');
const existingSet = new Set();
existingAssets.forEach(a => {
existingSet.add(`${a.emp_no}|${a.asset_type}|${a.category}`);
});
console.log(`📊 처리 대상 데이터: ${rawData.length}`);
let skipCount = 0;
let insertCount = 0;
for (let i = 0; i < rawData.length; i++) {
const row = rawData[i];
const empNo = String(row.emp_no);
const assetType = row.asset_type || '개인PC';
const category = row.category || 'PC';
// 중복 체크
if (existingSet.has(`${empNo}|${assetType}|${category}`)) {
skipCount++;
continue;
}
// [Step 2] 데이터 정제
// 1. 사용자 정보 매칭
const matchedUser = userMap.get(empNo);
const userName = matchedUser ? matchedUser.user_name : row.user_current;
const deptName = matchedUser ? matchedUser.dept_name : row.current_dept;
const position = matchedUser ? matchedUser.position : '';
// 2. 날짜 최적화 (purchase_date_1, purchase_date_2 중 최신값)
const d1 = parseInt(row.purchase_date_1) || 0;
const d2 = parseInt(row.purchase_date_2) || 0;
const latestDate = Math.max(d1, d2);
const purchaseDate = latestDate > 0 ? String(latestDate) : '';
// 3. 고유 ID 생성
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
try {
// [Step 3] DB 입력
// A. asset_core 입력
await connection.query(
`INSERT INTO asset_core (id, asset_code, category, asset_type, current_role, asset_purpose, service_type,
purchase_corp, purchase_date, memo, manager_primary, current_dept, user_current, emp_no, user_position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[assetId, assetId, category, assetType, row.current_role, row.asset_purpose, row.service_type,
'', purchaseDate, row.memo || '', '', deptName, userName, empNo, position, now, now]
);
// B. asset_spec 입력
await connection.query(
`INSERT INTO asset_spec (asset_id, model_name, mainboard, cpu, ram, gpu) VALUES (?, ?, ?, ?, ?, ?)`,
[assetId, '', row.mainboard || '', row.cpu || '', row.ram || '', row.gpu || '']
);
// C. asset_volume 입력 (SSD1, SSD2, HDD1~4)
const volumes = [
{ type: 'SSD', cap: row.SDD1, slot: 1 },
{ type: 'SSD', cap: row.SDD2, slot: 2 },
{ type: 'HDD', cap: row.HDD1, slot: 3 },
{ type: 'HDD', cap: row.HDD2, slot: 4 },
{ type: 'HDD', cap: row.HDD3, slot: 5 },
{ type: 'HDD', cap: row.HDD4, slot: 6 }
];
for (const vol of volumes) {
if (vol.cap && vol.cap !== '0' && vol.cap !== 0) {
await connection.query(
`INSERT INTO asset_volume (asset_id, disk_type, capacity, slot_no) VALUES (?, ?, ?, ?)`,
[assetId, vol.type, String(vol.cap), vol.slot]
);
}
}
insertCount++;
existingSet.add(`${empNo}|${assetType}|${category}`); // 실시간 중복 방지 추가
} catch (err) {
console.error(`❌ [${empNo}] 처리 중 오류:`, err.message);
}
}
console.log(`\n✨ 작업 완료!`);
console.log(`- 신규 입력: ${insertCount}`);
console.log(`- 중복 스킵: ${skipCount}`);
await connection.end();
}
importAssets().catch(console.error);

View File

@@ -0,0 +1,164 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
// 용량 정제 함수
function parseCapacity(val) {
if (!val || val === '0' || val === 0) return null;
let str = String(val).toUpperCase();
// 1. 괄호와 그 안의 내용 제거
str = str.replace(/\(.*\)/g, '').trim();
// 2. 숫자와 단위 분리
const numMatch = str.match(/[\d.]+/);
if (!numMatch) return null;
let num = parseFloat(numMatch[0]);
let unit = 'GB'; // 기본 단위
if (str.includes('TB')) {
unit = 'TB';
} else if (str.includes('GB')) {
// 4자리수 GB인 경우 TB로 전환 (지시사항 1번)
if (num >= 1000) {
num = num / 1000;
unit = 'TB';
} else {
unit = 'GB';
}
} else {
// 단위가 명시되지 않은 경우 숫자의 크기로 판단
if (num >= 1000) {
num = num / 1000;
unit = 'TB';
}
}
return {
capacity: parseFloat(num.toFixed(2)),
unit: unit
};
}
async function importAssets() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 [Step 1] 데이터 로드 및 사전 준비 (정제 로직 강화)...');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawData = XLSX.utils.sheet_to_json(sheet);
// system_users 데이터 맵
const [userRows] = await connection.query('SELECT emp_no, user_name, dept_name, position, status FROM system_users');
const userMap = new Map();
userRows.forEach(u => userMap.set(String(u.emp_no), u));
// 기존 자산 중복 체크용 (emp_no + asset_type + category + user_current)
const [existingAssets] = await connection.query('SELECT emp_no, asset_type, category, user_current FROM asset_core');
const existingSet = new Set();
existingAssets.forEach(a => {
existingSet.add(`${a.emp_no || ''}|${a.asset_type}|${a.category}|${a.user_current}`);
});
console.log(`📊 처리 대상 데이터: ${rawData.length}`);
let skipCount = 0;
let insertCount = 0;
let errorCount = 0;
for (let i = 0; i < rawData.length; i++) {
const row = rawData[i];
const empNo = row.emp_no ? String(row.emp_no) : ''; // 사번 없는 행 처리 (지시사항 3번)
const assetType = row.asset_type || '개인PC';
const category = row.category || 'PC';
const userCurrent = row.user_current || '';
// 중복 체크
const dupKey = `${empNo}|${assetType}|${category}|${userCurrent}`;
if (existingSet.has(dupKey)) {
skipCount++;
continue;
}
// [Step 2] 데이터 정제
const matchedUser = empNo ? userMap.get(empNo) : null;
const userName = matchedUser ? matchedUser.user_name : userCurrent;
const deptName = matchedUser ? matchedUser.dept_name : (row.current_dept || '');
const position = matchedUser ? matchedUser.position : '';
const d1 = parseInt(row.purchase_date_1) || 0;
const d2 = parseInt(row.purchase_date_2) || 0;
const purchaseDate = Math.max(d1, d2) > 0 ? String(Math.max(d1, d2)) : '';
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
try {
// [Step 3] DB 입력
// A. asset_core
await connection.query(
`INSERT INTO asset_core (id, asset_code, category, asset_type, current_role, asset_purpose, service_type,
purchase_date, memo, current_dept, user_current, emp_no, user_position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[assetId, assetId, category, assetType, row.current_role || '', row.asset_purpose || '', row.service_type || '',
purchaseDate, row.memo || '', deptName, userName, empNo, position, now, now]
);
// B. asset_spec
await connection.query(
`INSERT INTO asset_spec (asset_id, mainboard, cpu, ram, gpu) VALUES (?, ?, ?, ?, ?)`,
[assetId, row.mainboard || '', row.cpu || '', row.ram || '', row.gpu || '']
);
// C. asset_volume
const volCols = [
{ key: 'SDD1', type: 'SSD', slot: 1 },
{ key: 'SDD2', type: 'SSD', slot: 2 },
{ key: 'HDD1', type: 'HDD', slot: 3 },
{ key: 'HDD2', type: 'HDD', slot: 4 },
{ key: 'HDD3', type: 'HDD', slot: 5 },
{ key: 'HDD4', type: 'HDD', slot: 6 }
];
for (const col of volCols) {
const rawVol = row[col.key];
const parsed = parseCapacity(rawVol);
if (parsed) {
await connection.query(
`INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)`,
[assetId, col.type, parsed.capacity, parsed.unit, col.slot]
);
}
}
insertCount++;
existingSet.add(dupKey);
} catch (err) {
errorCount++;
console.error(`❌ [Row ${i + 2}] ${empNo || 'Public'}: ${err.message}`);
}
}
console.log(`\n✨ 작업 완료!`);
console.log(`- 신규 입력: ${insertCount}`);
console.log(`- 중복 스킵: ${skipCount}`);
console.log(`- 오류 실패: ${errorCount}`);
await connection.end();
}
importAssets().catch(console.error);

View File

@@ -0,0 +1,61 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function importUsers() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 Excel 데이터 로드 중...');
const workbook = XLSX.readFile('system_User (20260615).xlsx');
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet);
console.log(`📊 총 ${data.length}개의 데이터를 찾았습니다.`);
// 기존 데이터 삭제 여부 (사용자 요구사항에 따라 결정 가능하지만, 보통 초기화 후 재입입)
// 여기서는 중복 방지를 위해 기존 데이터를 삭제하고 새로 넣는 방식을 취하겠습니다.
console.log('🧹 기존 system_users 데이터 삭제 중...');
await connection.query('DELETE FROM system_users');
console.log('📥 데이터 삽입 중...');
let successCount = 0;
for (let i = 0; i < data.length; i++) {
const row = data[i];
const { emp_no, user_name, dept_name, position, status } = row;
// ID 생성 (USR_ + 인덱스 001 형식)
const id = `USR_${String(i + 1).padStart(3, '0')}`;
const createdAt = new Date().toISOString().replace('T', ' ').substring(0, 19);
try {
await connection.query(
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, String(emp_no), user_name, dept_name, position, status, createdAt]
);
successCount++;
} catch (err) {
console.error(`❌ 삽입 실패 (Row ${i + 2}):`, err.message);
}
}
console.log(`✅ 완료: ${successCount}개의 사용자가 성공적으로 등록되었습니다.`);
await connection.end();
}
importUsers().catch(err => {
console.error('❌ 작업 중 오류 발생:', err);
process.exit(1);
});

30
scratch/parse_excel.js Normal file
View 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);
}

View 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);
}

View File

@@ -0,0 +1,7 @@
const XLSX = require('xlsx');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
console.log('Headers:', JSON.stringify(data[0], null, 2));
console.log('Sample Row 1:', JSON.stringify(data[1], null, 2));

6
scratch/peek_excel.cjs Normal file
View File

@@ -0,0 +1,6 @@
const XLSX = require('xlsx');
const workbook = XLSX.readFile('system_User (20260615).xlsx');
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
console.log(JSON.stringify(data.slice(0, 5), null, 2));

18
scratch/raw_check.cjs Normal file
View File

@@ -0,0 +1,18 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function rawCheck() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
const [rows] = await connection.query('SELECT user_current, emp_no FROM asset_core WHERE id LIKE "PC_20260615_%" LIMIT 10');
console.log(rows);
await connection.end();
}
rawCheck().catch(console.error);

View File

@@ -0,0 +1,85 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function rebuildAssetCodes() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
console.log('🚀 [Step 1] 신규 자산 구매일 업데이트 (YYYY-12-01)...');
// 1. 오늘 입력한 자산들 조회
const [rows] = await connection.query(
'SELECT id, purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%"'
);
console.log(`대상 자산: ${rows.length}`);
// 2. 구매일자 업데이트 (연도만 있는 경우 -12-01 추가)
for (const row of rows) {
if (row.purchase_date && row.purchase_date.length === 4) {
const newDate = `${row.purchase_date}-12-01`;
await connection.query(
'UPDATE asset_core SET purchase_date = ? WHERE id = ?',
[newDate, row.id]
);
}
}
console.log('✅ 구매일 업데이트 완료.');
console.log('\n🚀 [Step 2] 자산번호(asset_code) 재매핑 시작...');
// 3. 연도별로 그룹화하여 자산번호 부여
// 연도 목록 추출
const [yearRows] = await connection.query(
'SELECT DISTINCT LEFT(purchase_date, 4) as year FROM asset_core WHERE id LIKE "PC_20260615_%" ORDER BY year'
);
for (const yRow of yearRows) {
const year = yRow.year;
const yearMonth = `${year}12`;
const pattern = `PC-${yearMonth}-%`;
console.log(`--- [${year}년] 처리 중 ---`);
// 해당 연도/월의 기존 최대 순번 조회
const [maxRows] = await connection.query(
'SELECT asset_code FROM asset_core WHERE asset_code LIKE ? AND id NOT LIKE "PC_20260615_%"',
[pattern]
);
let maxSeq = 0;
maxRows.forEach(r => {
const parts = r.asset_code.split('-');
const seq = parseInt(parts[2]);
if (seq > maxSeq) maxSeq = seq;
});
console.log(`기존 최대 순번: ${maxSeq}`);
// 해당 연도 자산들 순차적으로 번호 부여
const [assetsOfYear] = await connection.query(
'SELECT id FROM asset_core WHERE id LIKE "PC_20260615_%" AND purchase_date LIKE ? ORDER BY id',
[`${year}-12%`]
);
let currentSeq = maxSeq + 1;
for (const asset of assetsOfYear) {
const newCode = `PC-${yearMonth}-${String(currentSeq).padStart(4, '0')}`;
await connection.query(
'UPDATE asset_core SET asset_code = ? WHERE id = ?',
[newCode, asset.id]
);
currentSeq++;
}
console.log(`신규 부여 완료: ${assetsOfYear.length}건 (순번 ${maxSeq + 1} ~ ${currentSeq - 1})`);
}
console.log('\n✨ 모든 작업이 완료되었습니다.');
await connection.end();
}
rebuildAssetCodes().catch(console.error);

View File

@@ -0,0 +1,85 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
require('dotenv').config();
async function reexamineData() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
console.log('🧐 [전수 조사] 엑셀 vs DB 데이터 비교 분석...');
// 1. 엑셀 데이터 로드
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const excelRows = XLSX.utils.sheet_to_json(sheet);
// 2. DB 데이터 로드
const [dbRows] = await connection.query(`
SELECT id, asset_code, asset_type, user_current, emp_no, current_dept
FROM asset_core
WHERE id LIKE "PC_20260615_%"
`);
const dbMap = new Map();
dbRows.forEach(r => dbMap.set(r.id, r));
const report = {
total: excelRows.length,
publicInExcelWithEmpNo: [], // 엑셀은 공용PC인데 사번이 있는 경우
personalInExcelNoEmpNo: [], // 엑셀은 개인PC인데 사번이 없는 경우
typeMismatch: [], // 엑셀과 DB의 asset_type이 다른 경우
userMismatch: [] // 사용자명이 크게 다른 경우
};
for (let i = 0; i < excelRows.length; i++) {
const ex = excelRows[i];
const id = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const db = dbMap.get(id);
if (!db) continue;
const exType = ex.asset_type || '개인PC';
const exEmpNo = ex.emp_no ? String(ex.emp_no) : null;
const exUser = ex.user_current || '';
// A. 공용PC인데 사번이 있는 경우 (가장 큰 혼란 포인트)
if (exType === '공용PC' && exEmpNo) {
report.publicInExcelWithEmpNo.push({ id, exUser, exEmpNo, exDept: ex.current_dept });
}
// B. 개인PC인데 사번이 없는 경우
if (exType === '개인PC' && !exEmpNo) {
report.personalInExcelNoEmpNo.push({ id, exUser, exDept: ex.current_dept });
}
// C. DB와의 타입 불일치 (현재 DB 상태 체크)
if (db.asset_type !== exType) {
report.typeMismatch.push({ id, exType, dbType: db.asset_type, user: db.user_current });
}
}
console.log('\n================================================');
console.log(`📊 전수 조사 요약 (총 ${report.total}건)`);
console.log(`1. 엑셀은 '공용PC'이나 '사번'이 있는 항목: ${report.publicInExcelWithEmpNo.length}`);
console.log(`2. 엑셀은 '개인PC'이나 '사번'이 없는 항목: ${report.personalInExcelNoEmpNo.length}`);
console.log(`3. 현재 DB와 엑셀의 '자산유형' 불일치: ${report.typeMismatch.length}`);
console.log('================================================\n');
if (report.publicInExcelWithEmpNo.length > 0) {
console.log('⚠️ [그룹 1] 공용PC인데 실사용자/관리자가 지정된 사례 (샘플 15건):');
console.table(report.publicInExcelWithEmpNo.slice(0, 15));
}
if (report.personalInExcelNoEmpNo.length > 0) {
console.log('\n⚠ [그룹 2] 개인PC인데 사번 정보가 누락된 사례 (샘플 15건):');
console.table(report.personalInExcelNoEmpNo.slice(0, 15));
}
await connection.end();
}
reexamineData().catch(console.error);

View File

@@ -0,0 +1,92 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function restoreAndMerge() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🔄 데이터 복구 및 병합 시작...');
// 1. 백업 파일에서 기존 데이터(212건) 로드
const workbookBackup = XLSX.readFile('backupDB_20260602.xlsx');
const oldUsers = XLSX.utils.sheet_to_json(workbookBackup.Sheets['system_users']);
// 2. 신규 파일에서 데이터(987건) 로드
const workbookNew = XLSX.readFile('system_User (20260615).xlsx');
const newUsers = XLSX.utils.sheet_to_json(workbookNew.Sheets[workbookNew.SheetNames[0]]);
console.log(`기본 백업 데이터: ${oldUsers.length}`);
console.log(`신규 추가 데이터: ${newUsers.length}`);
// 테이블 비우기 (실수를 바로잡기 위해 다시 시작)
await connection.query('DELETE FROM system_users');
const insertedEmpNos = new Set();
let restoreCount = 0;
let addCount = 0;
// 3. 기존 데이터 복구 (ID 보존 시도)
for (const user of oldUsers) {
const { id, emp_no, user_name, dept_name, position, status, created_at } = user;
// 엑셀 날짜 처리 (숫자로 되어 있을 경우)
let finalCreatedAt = created_at;
if (typeof created_at === 'number') {
const date = new Date((created_at - 25569) * 86400 * 1000);
finalCreatedAt = date.toISOString().replace('T', ' ').substring(0, 19);
}
try {
await connection.query(
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, String(emp_no), user_name, dept_name, position, status, finalCreatedAt]
);
insertedEmpNos.add(String(emp_no));
restoreCount++;
} catch (err) {
console.error(`❌ 복구 실패 (emp_no: ${emp_no}):`, err.message);
}
}
// 4. 신규 데이터 추가 (중복 제외)
for (let i = 0; i < newUsers.length; i++) {
const user = newUsers[i];
const { emp_no, user_name, dept_name, position, status } = user;
const strEmpNo = String(emp_no);
if (insertedEmpNos.has(strEmpNo)) {
continue; // 이미 복구된 데이터는 스킵
}
// 신규 데이터용 ID 생성 (기존 ID와 겹치지 않게 'NEW_' 접두어 또는 시퀀스 사용)
// 여기서는 단순히 시퀀스로 처리 (최대 ID 확인 후 +1 하는 방식이 좋으나 여기선 간단히)
const id = `USR_N_${String(i + 1).padStart(4, '0')}`;
const createdAt = new Date().toISOString().replace('T', ' ').substring(0, 19);
try {
await connection.query(
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, strEmpNo, user_name, dept_name, position, status, createdAt]
);
addCount++;
} catch (err) {
console.error(`❌ 추가 실패 (emp_no: ${emp_no}):`, err.message);
}
}
console.log(`✅ 복구 완료: 기존 ${restoreCount}건 복구, 신규 ${addCount}건 추가 (총 ${restoreCount + addCount}건)`);
await connection.end();
}
restoreAndMerge().catch(console.error);

View File

@@ -0,0 +1,32 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function updateDepartments() {
const connection = await mysql.createConnection({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
console.log("🚀 부서명 '삼안' 통합 업데이트 시작...");
const [result] = await connection.query(`
UPDATE asset_core
SET current_dept = '삼안'
WHERE current_dept NOT IN ('총괄기획실', '기술개발센터', '현타', '장헌', '한맥', 'PTC', '', '삼안')
AND current_dept IS NOT NULL
`);
console.log(`✅ 업데이트 완료: ${result.affectedRows}건의 부서명이 '삼안'으로 변경되었습니다.`);
// 최종 확인용 카운트
const [rows] = await connection.query('SELECT current_dept, COUNT(*) as count FROM asset_core GROUP BY current_dept');
console.log('\n📊 최종 부서 분포:');
console.table(rows);
await connection.end();
}
updateDepartments().catch(console.error);

447
scratch/update_dummy_pcs.js Normal file
View 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);
}

View 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);
}

117
server.js
View File

@@ -28,32 +28,6 @@ const pool = mysql.createPool({
queueLimit: 0
});
// Database startup check (ensure job_spec_standards table exists)
(async () => {
let connection;
try {
connection = await pool.getConnection();
await connection.query(`
CREATE TABLE IF NOT EXISTS job_spec_standards (
id INT AUTO_INCREMENT PRIMARY KEY,
job_name VARCHAR(100) UNIQUE NOT NULL,
cpu_standard VARCHAR(255),
ram_standard VARCHAR(100),
gpu_standard VARCHAR(100),
min_score INT DEFAULT 0,
remarks TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ job_spec_standards table verification completed.');
} catch (err) {
console.error('❌ Failed to verify/create job_spec_standards table:', err);
} finally {
if (connection) connection.release();
}
})();
// Error Handler
const handleError = (res, err, label) => {
console.error(`❌ [${label}] Error:`, err);
@@ -177,7 +151,6 @@ app.get('/api/assets/master', async (req, res) => {
const [users] = await connection.query('SELECT * FROM system_users');
const [logs] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC');
const [partsMaster] = await connection.query('SELECT * FROM hardware_components_master ORDER BY category, component_name');
const [jobSpecs] = await connection.query('SELECT * FROM job_spec_standards ORDER BY job_name');
masterData.swInternal = swInternal;
masterData.swExternal = swExternal;
@@ -185,7 +158,6 @@ app.get('/api/assets/master', async (req, res) => {
masterData.users = users;
masterData.logs = logs;
masterData.partsMaster = partsMaster;
masterData.jobSpecs = jobSpecs;
res.json(masterData);
} catch (err) {
@@ -574,56 +546,6 @@ app.delete('/api/hardware-components/:id', async (req, res) => {
}
});
// 6.7.1. Get Job Spec Standards
app.get('/api/job-specs', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM job_spec_standards ORDER BY job_name');
res.json(rows);
} catch (err) {
handleError(res, err, 'GET JOB SPECS');
}
});
// 6.7.2. Save Job Spec Standard (Add or Update)
app.post('/api/job-specs/save', async (req, res) => {
const { id, job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks } = req.body;
let connection;
try {
connection = await pool.getConnection();
if (id) {
await connection.query(
'UPDATE job_spec_standards SET job_name = ?, cpu_standard = ?, ram_standard = ?, gpu_standard = ?, min_score = ?, remarks = ? WHERE id = ?',
[job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks, id]
);
} else {
await connection.query(
'INSERT INTO job_spec_standards (job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks) VALUES (?, ?, ?, ?, ?, ?)',
[job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks]
);
}
res.json({ success: true });
} catch (err) {
handleError(res, err, 'SAVE JOB SPEC');
} finally {
if (connection) connection.release();
}
});
// 6.7.3. Delete Job Spec Standard
app.delete('/api/job-specs/:id', async (req, res) => {
const { id } = req.params;
let connection;
try {
connection = await pool.getConnection();
await connection.query('DELETE FROM job_spec_standards WHERE id = ?', [id]);
res.json({ success: true });
} catch (err) {
handleError(res, err, 'DELETE JOB SPEC');
} finally {
if (connection) connection.release();
}
});
// 6.8. Get System Users List
app.get('/api/system-users', async (req, res) => {
try {
@@ -675,41 +597,16 @@ app.delete('/api/system-users/:id', async (req, res) => {
}
});
app.post('/api/maps/save', async (req, res) => {
let connection;
app.post('/api/maps/save', (req, res) => {
try {
const { path, boxes } = req.body;
if (!path) return res.status(400).json({ error: 'Path is required' });
// 1. Get old config to track movements
let oldConfig = {};
if (fs.existsSync('map_config.json')) {
oldConfig = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
}
const oldBoxes = oldConfig[path] || [];
// 2. Save new config to file
oldConfig[path] = boxes;
fs.writeFileSync('map_config.json', JSON.stringify(oldConfig, null, 2));
// 3. Sync Database Assets (asset_location table)
connection = await pool.getConnection();
for (const box of boxes) {
if (box.asset_id) {
console.log(`Syncing asset ${box.asset_id} to new position: [${box.x}, ${box.y}]`);
await connection.query(
'UPDATE asset_location SET loc_x = ?, loc_y = ? WHERE asset_id = ? AND is_active = 1',
[box.x, box.y, box.asset_id]
);
}
}
res.json({ success: true, message: 'Map and Database synced successfully' });
} catch (err) {
handleError(res, err, 'SAVE MAPS SYNC');
} finally {
if (connection) connection.release();
}
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));
res.json({ success: true });
} catch (err) { handleError(res, err, 'SAVE MAPS'); }
});
// 7. File Upload API (Base64)

View File

@@ -1,6 +1,5 @@
import { createIcons, BookOpen, X, ChevronDown, ChevronRight, RefreshCw } from 'lucide';
import { state } from '../core/state';
import './guide.css';
// ─── 자산별 가이드 콘텐츠 정의 (SW_Table 브랜치 전체 복구) ───
interface GuideTabConfig {

View File

@@ -1,6 +1,5 @@
import { createIcons, X } from 'lucide';
import { setEditLock } from './ModalUtils';
import './modal.css';
/**
* 모든 모달의 공통 기능을 관리하는 베이스 추상 클래스입니다.
@@ -10,7 +9,6 @@ export abstract class BaseModal {
protected title: string;
protected currentAsset: any | null = null;
protected isEditMode: boolean = false;
protected currentMode: 'view' | 'edit' | 'add' = 'view';
protected modalEl: HTMLElement | null = null;
protected formEl: HTMLFormElement | null = null;
@@ -55,23 +53,16 @@ export abstract class BaseModal {
*/
public open(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
this.currentAsset = asset;
this.currentMode = mode;
this.isEditMode = (mode === 'add' || mode === 'edit');
// 폼 초기화 추가
if (this.formEl) this.formEl.reset();
// fillFormData를 먼저 호출하여 동적 요소들을 생성한 후 잠금 처리
this.fillFormData(asset);
this.setEditLockMode(mode);
this.fillFormData(asset);
if (this.modalEl) {
this.modalEl.classList.remove('hidden');
const content = this.modalEl.querySelector('.modal-content');
if (content) {
if (mode === 'view') content.classList.add('is-view-mode');
else content.classList.remove('is-view-mode');
}
}
this.onAfterOpen(asset, mode);
@@ -120,16 +111,9 @@ export function closeModals() {
}
export function initBaseModal() {
// ESC 키로 모든 모달 닫기 (위치보기 팝업이 있으면 그것부터 닫음)
// ESC 키로 모든 모달 닫기
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
const picker = document.querySelector('.image-picker-overlay');
if (picker) {
picker.remove();
} else {
closeModals();
}
}
if (e.key === 'Escape') closeModals();
});
return { closeAllModals: closeModals };

View File

@@ -4,14 +4,14 @@ import { createIcons, X } from 'lucide';
const DASHBOARD_DETAIL_MODAL_HTML = `
<div id="dashboard-detail-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-content wide" style="max-width: 1000px;">
<div class="modal-header">
<h2 id="dashboard-detail-modal-title" class="modal-title">상세 목록</h2>
<h2 id="dashboard-detail-modal-title">상세 목록</h2>
<button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div>
<div class="modal-body">
<div class="table-container">
<table>
<table style="width:100%;">
<thead></thead>
<tbody id="dashboard-detail-tbody"></tbody>
</table>

View File

@@ -2,7 +2,7 @@ import { state, saveAsset, deleteAsset } from '../../core/state';
import { BaseModal } from './BaseModal';
import { CORP_LIST } from './SharedData';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, History, Plus } from 'lucide';
import { createIcons, X, Save, Database, CalendarClock, Edit2, History, Plus } from 'lucide';
import { formatExcelDate } from '../../core/excelHandler';
import { UI_TEXT } from '../../core/schema';
@@ -16,18 +16,15 @@ class DomainAssetModal extends BaseModal {
<div id="domain-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<div class="header-left">
<h2 id="domain-modal-title" class="modal-title">${this.title}</h2>
<div id="domain-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기">&times;</button>
<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>
@@ -61,7 +58,7 @@ class DomainAssetModal extends BaseModal {
</div>
<div class="form-group">
<label>비용 (연간/월간)</label>
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" />
<input type="text" id="domain-price" name="price" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
</div>
<div class="form-section-title">담당자 및 비고</div>
@@ -81,9 +78,9 @@ class DomainAssetModal extends BaseModal {
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history"></i> 변경 이력</h3>
<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"></i>
이력 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button>
</div>
<div id="domain-history-list" class="history-timeline"></div>
@@ -144,7 +141,7 @@ class DomainAssetModal extends BaseModal {
}
});
createIcons({ icons: { History, Plus, Save, X } });
createIcons({ icons: { History, Plus, Save, CalendarClock, Database } });
}
protected fillFormData(asset: any): void {
@@ -161,52 +158,31 @@ class DomainAssetModal extends BaseModal {
setFieldValue('domain-remarks', asset.remarks || '');
this.renderHistory(asset.id);
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('domain-modal-title');
if (titleEl) titleEl.textContent = (mode === 'add') ? '신규 도메인 등록' : '도메인 정보 상세';
const deleteBtn = document.getElementById('btn-delete-domain-asset');
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('domain-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const type = getFieldValue('domain-type') || asset.type || '';
const serviceName = getFieldValue('domain-service-name') || asset.service_name || '';
const domainName = getFieldValue('domain-name') || asset.domain_name || '';
container.innerHTML = `
<span class="asset-code-title">${serviceName}</span>
<span class="service-type-badge">${type}</span>
<span class="asset-type-label">${domainName}</span>
`;
}
private renderHistory(assetId: string) {
const container = document.getElementById('domain-history-list');
if (!container) return;
const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId);
if (logs.length === 0) {
container.innerHTML = '<div style="color:var(--mute); padding:1rem; text-align:center;">이력이 없습니다.</div>';
} else {
container.innerHTML = logs.map(l => `<div class="history-item"><div class="history-date">${l.log_date || ''}</div><div class="history-user">${l.log_user || '시스템'}</div><div class="history-details">${l.details}</div></div>`).join('');
}
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 initDomainModal(onSave: () => void, closeModals: () => void) { domainModal.init(onSave, closeModals); }
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { domainModal.open(asset, mode); }
export function initDomainModal(onSave: () => void, closeModals: () => void) {
domainModal.init(onSave, closeModals);
}
export function openDomainModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
domainModal.open(asset, mode);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,295 +0,0 @@
import { state, saveJobSpec, deleteJobSpec } from '../../core/state';
import { BaseModal } from './BaseModal';
import { setFieldValue } from './ModalUtils';
import { UI_TEXT } from '../../core/schema';
import { calculatePcScoreDeductive } from '../../core/utils';
class JobSpecModal extends BaseModal {
constructor() {
super('job-spec', '직무별 기준 사양');
}
protected renderFrameHTML(): string {
return `
<div id="job-spec-asset-modal" class="modal-overlay hidden">
<div class="modal-content narrow">
<div class="modal-header">
<div class="header-left">
<h2 id="job-spec-modal-title" class="modal-title">\${this.title}</h2>
<div id="job-spec-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-job-spec-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<form id="job-spec-asset-form" class="grid-form vertical-form">
<input type="hidden" id="job-spec-id" name="id" />
<div class="form-group">
<label>직무명</label>
<input type="text" id="job-spec-job-name" name="job_name" placeholder="예: BIM 모델러, 개발자, 엔지니어" required />
</div>
<div class="form-group relative">
<label>권장 CPU 사양</label>
<input type="text" id="job-spec-cpu-standard" name="cpu_standard" placeholder="CPU 검색..." required autocomplete="off" />
<div id="job-spec-cpu-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group relative">
<label>권장 RAM 사양</label>
<input type="text" id="job-spec-ram-standard" name="ram_standard" placeholder="RAM 검색..." required autocomplete="off" />
<div id="job-spec-ram-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group relative">
<label>권장 GPU 사양</label>
<input type="text" id="job-spec-gpu-standard" name="gpu_standard" placeholder="GPU 검색..." required autocomplete="off" />
<div id="job-spec-gpu-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group">
<label>성능 기준 점수 (이상, 자동 계산됨)</label>
<input type="number" id="job-spec-min-score" name="min_score" placeholder="자동 계산 대기..." required readonly />
</div>
<div class="form-group">
<label>비고 (메모)</label>
<textarea id="job-spec-remarks" name="remarks" placeholder="기타 필요 사양 및 안내 사항" rows="3"></textarea>
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-job-spec-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-job-spec-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-job-spec-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-job-spec-asset" class="btn btn-primary">수정</button>
</div>
</div>
</div>
</div>
<style>
.autocomplete-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 150px;
overflow-y: auto;
background-color: white;
border: 1px solid var(--border-color, #E2E8F0);
border-top: none;
border-radius: 0 0 4px 4px;
z-index: 1000;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.autocomplete-item {
padding: 8px 12px;
font-size: 13px;
color: #334155;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.autocomplete-item:hover {
background-color: #F1F5F9;
color: #1E5149;
font-weight: 600;
}
</style>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
const revertBtn = document.getElementById('btn-revert-job-spec-edit')!;
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const jobName = (document.getElementById('job-spec-job-name') as HTMLInputElement).value.trim();
const cpuStd = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement).value.trim();
const ramStd = (document.getElementById('job-spec-ram-standard') as HTMLInputElement).value.trim();
const gpuStd = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement).value.trim();
const minScoreStr = (document.getElementById('job-spec-min-score') as HTMLInputElement).value;
const remarks = (document.getElementById('job-spec-remarks') as HTMLTextAreaElement).value.trim();
if (!jobName) {
alert('직무명을 입력해 주세요.');
return;
}
const updated = {
id: this.currentAsset.id || null,
job_name: jobName,
cpu_standard: cpuStd,
ram_standard: ramStd,
gpu_standard: gpuStd,
min_score: minScoreStr !== '' ? parseInt(minScoreStr, 10) : 0,
remarks: remarks
};
if (await saveJobSpec(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 직무별 기준 사양을 삭제하시겠습니까?')) return;
if (await deleteJobSpec(this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
// 자동완성 바인딩
this.bindAutocomplete('job-spec-cpu-standard', 'job-spec-cpu-autocomplete', 'CPU');
this.bindAutocomplete('job-spec-ram-standard', 'job-spec-ram-autocomplete', 'RAM');
this.bindAutocomplete('job-spec-gpu-standard', 'job-spec-gpu-autocomplete', 'GPU');
// 실시간 점수 계산 이벤트 바인딩
const inputs = ['job-spec-cpu-standard', 'job-spec-ram-standard', 'job-spec-gpu-standard'];
inputs.forEach(id => {
const el = document.getElementById(id);
el?.addEventListener('input', () => this.updateMinScore());
el?.addEventListener('change', () => this.updateMinScore());
});
}
private bindAutocomplete(inputId: string, autocompleteId: string, category: string) {
const input = document.getElementById(inputId) as HTMLInputElement;
const list = document.getElementById(autocompleteId) as HTMLDivElement;
if (!input || !list) return;
const showList = (filterText: string = '') => {
if (!this.isEditMode) return;
const items = (state.masterData.partsMaster || []).filter((c: any) => c.category === category);
const filtered = filterText
? items.filter((c: any) => c.component_name.toLowerCase().includes(filterText.toLowerCase()))
: items;
if (filtered.length === 0) {
list.innerHTML = '<div class="autocomplete-item" style="color: #94a3b8; cursor: default;">검색 결과 없음</div>';
} else {
list.innerHTML = filtered.map((c: any) => `<div class="autocomplete-item" data-val="${c.component_name}">${c.component_name}</div>`).join('');
}
list.classList.remove('hidden');
};
input.addEventListener('focus', () => {
showList(input.value);
});
input.addEventListener('input', () => {
showList(input.value);
});
list.addEventListener('mousedown', (e) => {
const item = (e.target as HTMLElement).closest('.autocomplete-item');
if (item && item.getAttribute('data-val')) {
input.value = item.getAttribute('data-val') || '';
list.classList.add('hidden');
this.updateMinScore();
}
});
document.addEventListener('mousedown', (e) => {
if (e.target !== input && !list.contains(e.target as Node)) {
list.classList.add('hidden');
}
});
}
private updateMinScore(): void {
const cpu = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement)?.value || '';
const ram = (document.getElementById('job-spec-ram-standard') as HTMLInputElement)?.value || '';
const gpu = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement)?.value || '';
const score = calculatePcScoreDeductive(cpu, ram, gpu, '');
const minScoreEl = document.getElementById('job-spec-min-score') as HTMLInputElement;
if (minScoreEl) {
minScoreEl.value = score.toString();
}
}
protected fillFormData(asset: any): void {
setFieldValue('job-spec-id', asset.id || '');
setFieldValue('job-spec-job-name', asset.job_name || '');
setFieldValue('job-spec-cpu-standard', asset.cpu_standard || '');
setFieldValue('job-spec-ram-standard', asset.ram_standard || '');
setFieldValue('job-spec-gpu-standard', asset.gpu_standard || '');
setFieldValue('job-spec-min-score', asset.min_score !== undefined ? asset.min_score.toString() : '100');
setFieldValue('job-spec-remarks', asset.remarks || '');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('job-spec-modal-title');
if (titleEl) {
if (mode === 'add') {
titleEl.textContent = '신규 직무별 기준 사양 등록';
} else {
titleEl.textContent = '직무별 기준 사양 상세 편집';
}
}
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add' || mode === 'edit') {
saveBtn.textContent = (mode === 'add') ? '등록' : '저장';
saveBtn.style.display = 'block';
} else {
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('job-spec-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const jobName = asset.job_name || '';
const minScore = asset.min_score || 0;
container.innerHTML = `
<span class="asset-code-title">${jobName}</span>
<span class="service-type-badge">${minScore}점 기준</span>
`;
}
}
export const jobSpecModal = new JobSpecModal();
export function initJobSpecModal(onSave: () => void, closeModals: () => void) {
jobSpecModal.init(onSave, closeModals);
}
export function openJobSpecModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
jobSpecModal.open(asset, mode);
}

View File

@@ -110,45 +110,29 @@ export function setEditLock(
const generateBtn = options.generateBtnId ? document.getElementById(options.generateBtnId) : null;
const addLogBtn = options.addLogBtnId ? document.getElementById(options.addLogBtnId) : null;
if (!form) return;
if (!form || !saveBtn || !revertBtn) return;
const isEdit = (mode === 'add' || mode === 'edit');
if (isEdit) {
if (mode === 'add' || mode === 'edit') {
// 편집 모드 활성화
form.classList.remove('is-view-mode');
form.classList.add('is-edit-mode');
if (saveBtn) saveBtn.textContent = (mode === 'add' ? '등록' : '저장');
if (revertBtn) revertBtn.classList.toggle('hidden', mode === 'add');
saveBtn.textContent = '저장';
revertBtn.classList.toggle('hidden', mode === 'add'); // 신규 추가 시에는 취소 버튼 숨김
// 모든 필드 활성화
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
// 자산번호 및 ID 필드는 편집 모드에서도 잠금 유지
if (el.name !== 'asset_code' && !el.id.includes('asset-id') && !el.id.includes('id-hidden')) {
el.disabled = false;
if ('readOnly' in el) (el as HTMLInputElement).readOnly = false;
}
});
if (generateBtn) generateBtn.style.display = (mode === 'add' ? 'flex' : 'none');
// 번호 생성 버튼은 '추가(add)' 시에만 노출
if (generateBtn) {
generateBtn.style.display = mode === 'add' ? 'flex' : 'none';
}
// 내역 추가 버튼 노출
if (addLogBtn) addLogBtn.style.display = 'flex';
} else {
// 조회 모드 (잠금)
form.classList.remove('is-edit-mode');
form.classList.add('is-view-mode');
if (saveBtn) saveBtn.textContent = '수정';
if (revertBtn) revertBtn.classList.add('hidden');
saveBtn.textContent = '수정';
revertBtn.classList.add('hidden');
// 모든 필드 잠금
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
const el = input as HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
el.disabled = true;
if ('readOnly' in el) (el as HTMLInputElement).readOnly = true;
});
// 조회 모드에서는 버튼들 숨김
if (generateBtn) generateBtn.style.display = 'none';
if (addLogBtn) addLogBtn.style.display = 'none';
}
@@ -185,9 +169,9 @@ export function createModalFrameHTML(
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" class="icon-sm"></i> ${options.historyTitle}</h3>
<button type="button" id="btn-add-${idPrefix}-log" class="btn btn-outline btn-sm">
내역 추가 <i data-lucide="plus" class="icon-sm"></i>
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> ${options.historyTitle}</h3>
<button type="button" id="${options.addLogBtnId}" class="btn btn-outline btn-sm">
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
</button>
</div>
<div id="${idPrefix}-history-list" class="history-timeline"></div>

View File

@@ -4,10 +4,10 @@ import { API_BASE_URL } from '../../core/utils';
export class PCFlowModal {
private static instance: PCFlowModal | null = null;
private modalEl: HTMLElement | null = null;
private currentFlowType: 'checkout' | 'return' | 'move' = 'checkout';
// Selected state
private selectedUser: any = null;
private selectedTargetUser: any = null;
@@ -30,7 +30,7 @@ export class PCFlowModal {
this.modalEl = document.getElementById('pc-flow-modal');
this.setupEventListeners(onSave);
// Set default date to today
const dateInput = document.getElementById('pc-flow-date') as HTMLInputElement;
if (dateInput) {
@@ -59,19 +59,14 @@ export class PCFlowModal {
this.selectedTargetUser = null;
this.selectedPC = null;
this.currentFlowType = 'checkout';
const radioCheckout = document.querySelector('input[name="flow-type"][value="checkout"]') as HTMLInputElement;
if (radioCheckout) {
radioCheckout.checked = true;
document.querySelectorAll('.flow-type-label').forEach(l => {
l.classList.toggle('active', l.contains(radioCheckout));
});
}
if (radioCheckout) radioCheckout.checked = true;
// Reset text fields
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
if (userSearch) userSearch.value = '';
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
if (targetUserSearch) targetUserSearch.value = '';
@@ -99,7 +94,7 @@ export class PCFlowModal {
label.classList.add('active');
radio.checked = true;
this.currentFlowType = radio.value as any;
// Reset selected PC when switching flow types
this.selectedPC = null;
this.updateUI();
@@ -109,16 +104,16 @@ export class PCFlowModal {
// 1. Source User Autocomplete Search
const userSearch = document.getElementById('pc-flow-user-search') as HTMLInputElement;
const userSuggestions = document.getElementById('pc-flow-user-suggestions')!;
userSearch?.addEventListener('input', () => {
const query = userSearch.value.trim().toLowerCase();
if (!query) {
userSuggestions.classList.add('hidden');
return;
}
const users = state.masterData.users || [];
const filtered = users.filter((u: any) =>
const filtered = users.filter((u: any) =>
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
(u.emp_no && u.emp_no.toString().includes(query))
@@ -138,7 +133,7 @@ export class PCFlowModal {
this.selectedUser = user;
userSearch.value = `${user.user_name} (${user.dept_name} / 사번:${user.emp_no || '-'})`;
userSuggestions.classList.add('hidden');
// Automatically populate details if return or move
if (this.currentFlowType === 'return' || this.currentFlowType === 'move') {
this.selectedPC = null; // Reset selection
@@ -166,16 +161,16 @@ export class PCFlowModal {
// 2. Target User Autocomplete Search (For Moves)
const targetUserSearch = document.getElementById('pc-flow-target-user-search') as HTMLInputElement;
const targetSuggestions = document.getElementById('pc-flow-target-user-suggestions')!;
targetUserSearch?.addEventListener('input', () => {
const query = targetUserSearch.value.trim().toLowerCase();
if (!query) {
targetSuggestions.classList.add('hidden');
return;
}
const users = state.masterData.users || [];
const filtered = users.filter((u: any) =>
const filtered = users.filter((u: any) =>
(u.user_name && u.user_name.toLowerCase().includes(query)) ||
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
(u.emp_no && u.emp_no.toString().includes(query))
@@ -202,7 +197,7 @@ export class PCFlowModal {
// 3. Stock PC Autocomplete Search (For Checkout)
const stockSearch = document.getElementById('pc-flow-stock-search') as HTMLInputElement;
const stockSuggestions = document.getElementById('pc-flow-stock-suggestions')!;
const showStockSuggestions = () => {
const query = stockSearch.value.trim().toLowerCase();
@@ -210,11 +205,11 @@ export class PCFlowModal {
const pcs = state.masterData.pc || [];
const filtered = pcs.filter((p: any) => {
const status = (p.hw_status || '').trim();
const matchesQuery = !query ||
const matchesQuery = !query ||
(p.asset_code && p.asset_code.toLowerCase().includes(query)) ||
(p.model_name && p.model_name.toLowerCase().includes(query)) ||
(p.cpu && p.cpu.toLowerCase().includes(query));
return (status === '대기' || status === '미할당' || status === '재고') && matchesQuery;
});
@@ -314,17 +309,21 @@ export class PCFlowModal {
private renderUserSuggestions(users: any[], container: HTMLElement, onSelect: (user: any) => void) {
container.innerHTML = '';
if (users.length === 0) {
container.innerHTML = '<div class="autocomplete-item-empty">일치하는 사원이 없습니다.</div>';
container.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size: 13px;">일치하는 사원이 없습니다.</div>';
container.classList.remove('hidden');
return;
}
users.forEach(u => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.style.padding = '8px 12px';
item.style.cursor = 'pointer';
item.style.fontSize = '13px';
item.style.borderBottom = '1px solid #F3F4F6';
item.className = 'suggestion-item';
item.innerHTML = `
<div class="suggestion-name">${u.user_name}</div>
<div class="suggestion-meta">
<div style="font-weight: 700; color: var(--text-main);">${u.user_name}</div>
<div style="font-size: 11px; color: var(--text-muted); display: flex; gap: 8px;">
<span>부서: ${u.dept_name}</span>
<span>|</span>
<span>사번: ${u.emp_no || '-'}</span>
@@ -339,17 +338,21 @@ export class PCFlowModal {
private renderPCSuggestions(pcs: any[], container: HTMLElement, onSelect: (pc: any) => void) {
container.innerHTML = '';
if (pcs.length === 0) {
container.innerHTML = '<div class="autocomplete-item-empty">불출 가능한 대기 PC 재고가 없습니다.</div>';
container.innerHTML = '<div style="padding: 10px; color: var(--text-muted); font-size: 13px;">불출 가능한 대기 PC 재고가 없습니다.</div>';
container.classList.remove('hidden');
return;
}
pcs.forEach(p => {
const item = document.createElement('div');
item.className = 'autocomplete-item';
item.style.padding = '8px 12px';
item.style.cursor = 'pointer';
item.style.fontSize = '13px';
item.style.borderBottom = '1px solid #F3F4F6';
item.className = 'suggestion-item';
item.innerHTML = `
<div class="suggestion-name">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
<div class="suggestion-meta">
<div style="font-weight: 700; color: var(--primary-color);">${p.asset_code} (${p.model_name || '모델명 없음'})</div>
<div style="font-size: 11px; color: var(--text-muted);">
사양: CPU ${p.cpu || '-'} / RAM ${p.ram || '-'} / 위치: ${p.location || '-'}
</div>
`;
@@ -424,20 +427,20 @@ export class PCFlowModal {
const userPcsList = document.getElementById('user-pcs-list')!;
if (this.selectedUser && (this.currentFlowType === 'return' || this.currentFlowType === 'move')) {
const allPcs = state.masterData.pc || [];
const userPcs = allPcs.filter((p: any) =>
const userPcs = allPcs.filter((p: any) =>
(p.emp_no && p.emp_no.toString() === this.selectedUser.emp_no?.toString()) ||
(p.user_current && p.user_current === this.selectedUser.user_name)
);
if (userPcs.length === 0) {
userPcsList.innerHTML = '<div class="empty-list-message">이 사용자가 소유한 PC 자산이 없습니다.</div>';
userPcsList.innerHTML = '<div style="font-size: 12px; color: var(--text-muted); padding: 8px 0;">이 사용자가 소유한 PC 자산이 없습니다.</div>';
} else {
userPcsList.innerHTML = userPcs.map(p => {
const isSelected = this.selectedPC && this.selectedPC.id === p.id;
return `
<div class="user-pc-item ${isSelected ? 'selected' : ''}" data-id="${p.id}">
<div class="pc-item-code">${p.asset_code}</div>
<div class="pc-item-meta">
<div class="user-pc-item ${isSelected ? 'selected' : ''}" data-id="${p.id}" style="padding: 10px; border: 1px solid ${isSelected ? 'var(--primary-color)' : 'var(--border-color)'}; border-radius: 4px; cursor: pointer; background: ${isSelected ? 'var(--primary-light)' : 'white'}; transition: all 0.2s;">
<div style="font-weight: 700; font-size: 13px; color: ${isSelected ? 'var(--primary-color)' : 'var(--text-main)'};">${p.asset_code}</div>
<div style="font-size: 11px; color: var(--text-muted); margin-top: 2px;">
${p.model_name || '모델명 없음'} | CPU: ${p.cpu || '-'} | RAM: ${p.ram || '-'}
</div>
</div>
@@ -462,132 +465,159 @@ export class PCFlowModal {
}
private renderHTML(): string {
const overlayStyle = `
position: fixed; top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0, 0, 0, 0.4); display: flex; align-items: center; justify-content: center;
z-index: 1000; transition: opacity 0.3s;
`;
const contentStyle = `
background: white; border-radius: 12px; box-shadow: 0 10px 25px rgba(0, 0, 0, 0.15);
overflow: hidden; max-height: 90vh; width: 950px; display: flex; flex-direction: column;
`;
const labelStyle = 'display: block; font-size: 13px; font-weight: 700; color: var(--text-muted); margin-bottom: 8px;';
const inputStyle = 'width: 100%; height: 38px; padding: 0 12px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
const inputWithIconStyle = 'width: 100%; height: 38px; padding: 0 12px 0 36px; border: 1px solid var(--border-color); border-radius: 4px; font-size: 13px; outline: none; box-sizing: border-box;';
return `
<div id="pc-flow-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 class="modal-title">
<div id="pc-flow-modal" class="modal-overlay hidden" style="${overlayStyle}">
<div class="modal-content" style="${contentStyle}">
<div class="modal-header" style="background: var(--primary-color); padding: 16px 24px; display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-color);">
<h2 style="margin: 0; font-size: 18px; font-weight: 800; color: white; display: flex; align-items: center; gap: 8px;">
<i data-lucide="refresh-cw"></i> PC 이동/반납 (불출/반납/이동)
</h2>
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기">&times;</button>
<button id="btn-close-pc-flow-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">&times;</button>
</div>
<div class="modal-body">
<div class="modal-body-split">
<!-- 왼쪽 영역: 입력 폼 -->
<div class="modal-form-area">
<div class="grid-form flex-col">
<!-- 1. 처리 유형 -->
<div class="form-group">
<label>1. 처리 유형 선택</label>
<div class="view-toggle w-full flex-row">
<label class="flow-type-label toggle-btn active flex-1 text-center">
<input type="radio" name="flow-type" value="checkout" checked class="hidden" />
불출 (지급)
</label>
<label class="flow-type-label toggle-btn flex-1 text-center">
<input type="radio" name="flow-type" value="return" class="hidden" />
입고 (반납)
</label>
<label class="flow-type-label toggle-btn flex-1 text-center">
<input type="radio" name="flow-type" value="move" class="hidden" />
이동 (이관)
</label>
</div>
</div>
<!-- 2. 대상 사용자 검색 -->
<div class="form-group relative">
<label id="user-search-label">2. 대상 사원 검색</label>
<div class="input-with-icon">
<input type="text" id="pc-flow-user-search" placeholder="사원명, 부서, 사번 검색..." />
<i data-lucide="search" class="icon-sm"></i>
</div>
<div id="pc-flow-user-suggestions" class="autocomplete-list hidden"></div>
</div>
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
<div id="target-user-search-container" class="form-group hidden relative">
<label>새 인수 사원 검색</label>
<div class="input-with-icon">
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." />
<i data-lucide="search" class="icon-sm"></i>
</div>
<div id="pc-flow-target-user-suggestions" class="autocomplete-list hidden"></div>
</div>
<!-- 4. 재고 PC 검색 (불출 시 노출) -->
<div id="stock-pc-search-container" class="form-group relative">
<label>3. 불출할 재고 PC 선택</label>
<div class="input-with-icon">
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." />
<i data-lucide="monitor" class="icon-sm"></i>
</div>
<div id="pc-flow-stock-suggestions" class="autocomplete-list hidden"></div>
</div>
<!-- 5. 상세 공통 입력 -->
<div class="detail-grid-2col">
<div class="form-group">
<label>처리 일자</label>
<input type="date" id="pc-flow-date" />
</div>
<div class="form-group">
<label>상세 사유</label>
<textarea id="pc-flow-details" rows="2" placeholder="미입력 시 기본 문구로 자동 입력됩니다."></textarea>
</div>
</div>
<div class="modal-body" style="padding: 24px; overflow-y: auto; display: flex; gap: 24px;">
<!-- 왼쪽 영역: 입력 폼 -->
<div style="flex: 1.2; display: flex; flex-direction: column; gap: 20px;">
<!-- 1. 처리 유형 -->
<div>
<label style="${labelStyle}">1. 처리 유형 선택</label>
<div style="display: flex; gap: 12px;">
<label class="flow-type-label active" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
<input type="radio" name="flow-type" value="checkout" checked style="display:none;" />
불출 (지급)
</label>
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
<input type="radio" name="flow-type" value="return" style="display:none;" />
입고 (반납)
</label>
<label class="flow-type-label" style="flex: 1; display: flex; align-items: center; justify-content: center; gap: 8px; padding: 12px; border: 1px solid var(--border-color); border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 600;">
<input type="radio" name="flow-type" value="move" style="display:none;" />
이동 (이관)
</label>
</div>
</div>
<!-- 오른쪽 영역: 선택 요약 & 사원 소유 자산 목록 -->
<div class="modal-history-area">
<div class="history-header">
<h3>선택 내역 요약</h3>
<!-- 2. 대상 사용자 검색 -->
<div style="position: relative;">
<label id="user-search-label" style="${labelStyle}">2. 대상 사원 검색</label>
<div style="position: relative; display: flex; align-items: center;">
<input type="text" id="pc-flow-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
</div>
<div id="pc-flow-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
</div>
<div class="dynamic-row-container">
<!-- 사원 요약 카드 -->
<div id="summary-user-card" class="summary-info-card">
<div class="detail-label-sm">대상 사원</div>
<div id="summary-user-name" class="detail-value-lg">선택된 사원 없음</div>
<div id="summary-user-dept" class="detail-label-sm">-</div>
</div>
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
<div id="target-user-search-container" class="hidden" style="position: relative;">
<label style="${labelStyle}">새 인수 사원 검색</label>
<div style="position: relative; display: flex; align-items: center;">
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." style="${inputWithIconStyle}" />
<i data-lucide="search" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
</div>
<div id="pc-flow-target-user-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
</div>
<!-- 인수 사원 요약 카드 (이동 전용) -->
<div id="summary-target-user-card" class="summary-info-card hidden bg-primary-light">
<div class="detail-label-sm">새 인수 사원</div>
<div id="summary-target-user-name" class="detail-value-lg">선택된 사원 없음</div>
<div id="summary-target-user-dept" class="detail-label-sm">-</div>
</div>
<!-- 4. 재고 PC 검색 (불출 시 노출) -->
<div id="stock-pc-search-container" style="position: relative;">
<label style="${labelStyle}">3. 불출할 재고 PC 선택</label>
<div style="position: relative; display: flex; align-items: center;">
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." style="${inputWithIconStyle}" />
<i data-lucide="monitor" style="position: absolute; left: 10px; width: 16px; height: 16px; color: var(--text-muted);"></i>
</div>
<div id="pc-flow-stock-suggestions" class="hidden" style="position: absolute; top: 100%; left: 0; right: 0; max-height: 200px; overflow-y: auto; background: white; border: 1px solid var(--border-color); border-radius: 4px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 1000; margin-top: 4px;"></div>
</div>
<!-- 대상 PC 자산 요약 카드 -->
<div id="summary-pc-card" class="summary-info-card">
<div class="detail-label-sm">대상 PC 자산</div>
<div id="summary-pc-code" class="detail-value-lg text-success">선택된 PC 없음</div>
<div id="summary-pc-model" class="detail-label-sm">-</div>
</div>
<!-- 사용자 보유 PC 목록 선택 (반납/이동 시) -->
<div id="user-pcs-container" class="form-group hidden">
<label>사원 보유 PC 선택 (클릭하여 매핑)</label>
<div id="user-pcs-list" class="user-pc-selection-list"></div>
</div>
<!-- 5. 상세 공통 입력 -->
<div style="display: flex; gap: 16px;">
<div style="flex: 1;">
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">처리 일자</label>
<input type="date" id="pc-flow-date" style="${inputStyle}" />
</div>
<div style="flex: 2;">
<label style="${labelStyle.replace('margin-bottom: 8px;', 'margin-bottom: 6px;')}">상세 사유</label>
<textarea id="pc-flow-details" rows="2" placeholder="미입력 시 기본 문구로 자동 입력됩니다." style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; font-family: inherit; font-size: 13px; resize: none; box-sizing: border-box; outline: none;"></textarea>
</div>
</div>
</div>
<!-- 오른쪽 영역: 선택 요약 & 사원 소유 자산 목록 -->
<div style="flex: 0.8; border-left: 1px solid var(--border-color); padding-left: 24px; display: flex; flex-direction: column; gap: 16px;">
<h3 style="margin: 0; font-size: 14px; font-weight: 800; border-bottom: 1px solid var(--border-color); padding-bottom: 8px;">선택 내역 요약</h3>
<!-- 사원 요약 카드 -->
<div id="summary-user-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
<div style="font-size: 11px; color: var(--text-muted);">대상 사원</div>
<div id="summary-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
<div id="summary-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
</div>
<!-- 인수 사원 요약 카드 (이동 전용) -->
<div id="summary-target-user-card" class="summary-card hidden" style="padding: 12px; background: #EEF2F6; border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
<div style="font-size: 11px; color: var(--text-muted);">새 인수 사원</div>
<div id="summary-target-user-name" style="font-weight: 700; font-size: 14px;">선택된 사원 없음</div>
<div id="summary-target-user-dept" style="font-size: 12px; color: var(--text-muted);">-</div>
</div>
<!-- 대상 PC 자산 요약 카드 -->
<div id="summary-pc-card" style="padding: 12px; background: var(--bg-light); border: 1px solid var(--border-color); border-radius: 6px; display: flex; flex-direction: column; gap: 4px;">
<div style="font-size: 11px; color: var(--text-muted);">대상 PC 자산</div>
<div id="summary-pc-code" style="font-weight: 700; font-size: 14px; color: var(--primary-color);">선택된 PC 없음</div>
<div id="summary-pc-model" style="font-size: 12px; color: var(--text-muted);">-</div>
</div>
<!-- 사용자 보유 PC 목록 선택 (반납/이동 시) -->
<div id="user-pcs-container" class="hidden" style="display: flex; flex-direction: column; gap: 8px;">
<div style="font-size: 12px; font-weight: 700; color: var(--text-muted);">사원 보유 PC 선택 (클릭하여 매핑)</div>
<div id="user-pcs-list" style="display: flex; flex-direction: column; gap: 8px; max-height: 200px; overflow-y: auto;"></div>
</div>
</div>
</div>
<div class="modal-footer">
<div></div>
<div class="footer-actions">
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline">취소</button>
<button id="btn-submit-pc-flow" class="btn btn-primary">이동/반납 처리 완료</button>
</div>
<div class="modal-footer" style="padding: 16px 24px; border-top: 1px solid var(--border-color); display: flex; justify-content: flex-end; gap: 12px; background: var(--bg-light);">
<button id="btn-cancel-pc-flow-modal" class="btn btn-outline" style="height: 42px;">취소</button>
<button id="btn-submit-pc-flow" class="btn btn-primary" style="height: 42px;">이동/반납 처리 완료</button>
</div>
</div>
</div>
<style>
.flow-type-label {
transition: all 0.2s;
border-color: var(--border-color);
background: white;
color: var(--text-muted);
}
.flow-type-label:hover {
border-color: var(--primary-color);
color: var(--primary-color);
}
.flow-type-label.active {
border-color: var(--primary-color);
background: var(--primary-light);
color: var(--primary-color);
}
.suggestion-item:hover {
background-color: var(--primary-light) !important;
}
</style>
`;
}
}

View File

@@ -1,7 +1,7 @@
import { state, savePartsMaster, deletePartsMaster } from '../../core/state';
import { BaseModal } from './BaseModal';
import { generateOptionsHTML, setFieldValue, getFieldValue } from './ModalUtils';
import { createIcons, X, Save, Plus } from 'lucide';
import { createIcons, X, Save, Database, Edit2, Plus } from 'lucide';
import { UI_TEXT } from '../../core/schema';
class PartsMasterModal extends BaseModal {
@@ -10,51 +10,52 @@ class PartsMasterModal extends BaseModal {
}
protected renderFrameHTML(): string {
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
const inputStyle = sharedStyle;
const selectStyle = sharedStyle;
return `
<div id="parts-master-asset-modal" class="modal-overlay hidden">
<div class="modal-content narrow">
<div class="modal-content" style="max-width: 500px; width: 100%;">
<div class="modal-header">
<div class="header-left">
<h2 id="parts-master-modal-title" class="modal-title">${this.title}</h2>
<div id="parts-master-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기">&times;</button>
<h2 id="parts-master-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">${this.title}</h2>
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">&times;</button>
</div>
<div class="modal-body">
<form id="parts-master-asset-form" class="grid-form vertical-form">
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
<form id="parts-master-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
<input type="hidden" id="parts-master-id" name="id" />
<div class="form-group">
<label>부품 분류</label>
<select id="parts-master-category" name="category">
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 분류</label>
<select id="parts-master-category" name="category" style="${selectStyle}">
<option value="CPU">CPU</option>
<option value="GPU">GPU</option>
<option value="RAM">RAM</option>
</select>
</div>
<div class="form-group">
<label>부품 표준 명칭</label>
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required />
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">부품 표준 명칭</label>
<input type="text" id="parts-master-component-name" name="component_name" placeholder="예: Intel Core i7-14700K" required style="${inputStyle} width: 100%;" />
</div>
<div class="form-group">
<label>성능 등급</label>
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required />
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">성능 등급</label>
<input type="text" id="parts-master-score-tier" name="score_tier" placeholder="예: i7 / S / 최적" required style="${inputStyle} width: 100%;" />
</div>
<div class="form-group">
<label>감점 점수 (양수로 입력)</label>
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required />
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">감점 점수 (양수로 입력)</label>
<input type="number" id="parts-master-deduction" name="deduction" placeholder="예: 5" required style="${inputStyle} width: 100%;" />
</div>
</form>
</div>
<div class="modal-footer">
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions">
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-parts-master-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-parts-master-asset" class="btn btn-primary">수정</button>
<div class="modal-footer" style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #f8fafc; border-top: 1px solid var(--border-color);">
<button id="btn-delete-parts-master-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
<div class="footer-actions" style="display: flex; gap: 8px;">
<button id="btn-revert-parts-master-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
<button id="btn-cancel-parts-master-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
<button id="btn-save-parts-master-asset" class="btn btn-primary" style="height: 42px;">수정</button>
</div>
</div>
</div>
@@ -108,13 +109,11 @@ class PartsMasterModal extends BaseModal {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 부품 마스터 정보를 삭제하시겠습니까?\n삭제 시 기존 등록 PC 중 이 부품명을 사용하는 PC의 자동완성 정합성 체크에 영향을 줄 수 있습니다.')) return;
if (await deletePartsMaster(Number(this.currentAsset.id))) {
if (await deletePartsMaster(this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
createIcons({ icons: { Plus, X, Save } });
}
protected fillFormData(asset: any): void {
@@ -123,49 +122,45 @@ class PartsMasterModal extends BaseModal {
setFieldValue('parts-master-component-name', asset.component_name || '');
setFieldValue('parts-master-score-tier', asset.score_tier || '');
setFieldValue('parts-master-deduction', asset.deduction !== undefined ? asset.deduction.toString() : '0');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('parts-master-modal-title');
if (titleEl) {
titleEl.textContent = (mode === 'add') ? '신규 부품 마스터 등록' : '부품 마스터 상세 편집';
if (mode === 'add') {
titleEl.textContent = '신규 부품 마스터 등록';
} else {
titleEl.textContent = '부품 마스터 상세 편집';
}
}
const deleteBtn = document.getElementById('btn-delete-parts-master-asset')!;
const saveBtn = document.getElementById('btn-save-parts-master-asset')!;
// 추가 모드일 때는 삭제 버튼 숨김
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add' || mode === 'edit') {
saveBtn.textContent = (mode === 'add') ? '등록' : '저장';
if (mode === 'add') {
this.setEditLockMode('edit');
this.isEditMode = true;
saveBtn.textContent = '등록';
saveBtn.style.display = 'block';
} else {
this.setEditLockMode('view');
this.isEditMode = false;
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('parts-master-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const cat = asset.category || '';
const name = asset.component_name || '';
container.innerHTML = `
<span class="asset-code-title">${name}</span>
<span class="service-type-badge">${cat}</span>
`;
}
}
export const partsMasterModal = new PartsMasterModal();
export function initPartsMasterModal(onSave: () => void, closeModals: () => void) { partsMasterModal.init(onSave, closeModals); }
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { partsMasterModal.open(asset, mode); }
export function initPartsMasterModal(onSave: () => void, closeModals: () => void) {
partsMasterModal.init(onSave, closeModals);
}
export function openPartsMasterModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
partsMasterModal.open(asset, mode);
}

Some files were not shown because too many files have changed in this diff Show More