From 407b9ba531427584cf2e6f771872a39702b7dfff Mon Sep 17 00:00:00 2001 From: JooWangi Date: Fri, 12 Jun 2026 10:40:30 +0900 Subject: [PATCH 01/13] =?UTF-8?q?style:=20=EC=97=B0=EB=8F=84=EB=B3=84=20PC?= =?UTF-8?q?=20=EB=85=B8=ED=9B=84=EB=8F=84=20=EC=98=88=EC=B8=A1=20=ED=91=9C?= =?UTF-8?q?=EC=9D=98=20=EC=99=BC=ED=8E=B8=20=EC=97=AC=EB=B0=B1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20=EB=B0=8F=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=A0=95=EB=A0=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Dashboard/HwDashboard.ts | 325 +++++++++++++++-------------- 1 file changed, 172 insertions(+), 153 deletions(-) diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 1b6c443..9364620 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -18,7 +18,7 @@ export function renderHwDashboard(container: HTMLElement) { // 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드 container.innerHTML = ` -
+
@@ -44,13 +44,13 @@ export function renderHwDashboard(container: HTMLElement) {
-
+
-
+
- -
+ +
@@ -108,51 +108,38 @@ export function renderHwDashboard(container: HTMLElement) { -
+ +
- -
+ +
-
- 등급별 보유 현황 +
+ 등급별 자산 종합 현황
- -
- -
-
- 최상급 PC (85점 이상) - 0대(0%) -
-
- -
-
- 상급 PC (70점 ~ 85점) - 0대(0%) -
-
- -
-
- 중급 PC (40점 ~ 70점) - 0대(0%) -
-
- -
-
- 보급 PC (40점 미만) - 0대(0%) -
-
+ +
+ + + + + + + + + + + + + +
구분 (등급)보유량할당량여분부족분
-
- +
+
등급별 보유 비율
@@ -163,7 +150,7 @@ export function renderHwDashboard(container: HTMLElement) {
-
+
최상급 @@ -185,48 +172,14 @@ export function renderHwDashboard(container: HTMLElement) {
- - -
-
- 유효 재고 현황 -
- -
- -
-
-
0대
- 최상급 재고 -
-
-
0대
- 중급 재고 -
-
- -
- -
-
-
0대
- 상급 재고 -
-
-
0대
- 보급 재고 -
-
-
-
-
+
-
+
직무별 사양 적정성 분석
@@ -236,15 +189,15 @@ export function renderHwDashboard(container: HTMLElement) {
-
+
- 연도별 PC 노후도 및 교체 주기 예측 + 연도별 PC 노후도 및 교체 주기 예측
-
- +
+
- + @@ -319,18 +272,18 @@ function updateDashboardData(pcs: any[], selectedDept: string) { jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0; }); - // 4. 등급 집계 (보유량 vs 유효 재고량) + // 4. 등급 집계 (보유량 vs 실제 할당량 vs 유효 재고량 vs 사양 부족량) const isStock = (p: any) => { return p.hw_status === '재고' || p.hw_status === '대기' || !(p.user_current || '').trim(); }; - const gradeCounts = { - premium: { total: 0, stock: 0 }, - high: { total: 0, stock: 0 }, - normal: { total: 0, stock: 0 }, - entry: { total: 0, stock: 0 } + const matrix = { + premium: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, + high: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, + normal: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, + entry: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] } }; let scoreSum = 0; @@ -344,35 +297,45 @@ function updateDashboardData(pcs: any[], selectedDept: string) { scoreSum += score; const stockYn = isStock(p); + let target: typeof matrix.premium; if (score >= 85) { - gradeCounts.premium.total++; - if (stockYn) gradeCounts.premium.stock++; + target = matrix.premium; } else if (score >= 70) { - gradeCounts.high.total++; - if (stockYn) gradeCounts.high.stock++; + target = matrix.high; } else if (score >= 40) { - gradeCounts.normal.total++; - if (stockYn) gradeCounts.normal.stock++; + target = matrix.normal; } else { - gradeCounts.entry.total++; - if (stockYn) gradeCounts.entry.stock++; + target = matrix.entry; } - // 직무 적정성 계산 (재직 중이고 실 사용자 매핑 자산만 검토 대상) - const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; - const avg = jobScores[job]?.avg || 0; + target.pcs.push(p); + target.total++; - if (avg > 0 && job !== '재고PC' && !stockYn) { - if (score < avg * 0.6) { - p._spec_status = '사양 부족'; - criticalList.push(p); - underSpecCount++; - } else if (score > avg * 1.5) { - p._spec_status = '오버스펙'; - criticalList.push(p); - overSpecCount++; - } else { - p._spec_status = '적정'; + if (stockYn) { + target.stock++; + target.stockPcs.push(p); + } else { + target.active++; + target.activePcs.push(p); + + // 직무 적정성 계산 (재직 중이고 실 사용자 매핑 자산만 검토 대상) + const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; + const avg = jobScores[job]?.avg || 0; + + if (avg > 0 && job !== '재고PC') { + if (score < avg * 0.6) { + p._spec_status = '사양 부족'; + criticalList.push(p); + underSpecCount++; + target.under++; + target.underPcs.push(p); + } else if (score > avg * 1.5) { + p._spec_status = '오버스펙'; + criticalList.push(p); + overSpecCount++; + } else { + p._spec_status = '적정'; + } } } @@ -382,39 +345,107 @@ function updateDashboardData(pcs: any[], selectedDept: string) { } }); - // 5. 핵심 텍스트형 지표 갱신 + // 5. 핵심 텍스트형 요약 지표 갱신 document.getElementById('metric-total-pcs')!.textContent = `${filtered.length}대`; document.getElementById('metric-under-spec')!.textContent = `${underSpecCount}명`; document.getElementById('metric-over-spec')!.textContent = `${overSpecCount}명`; document.getElementById('metric-win11-incompatible')!.textContent = `${win11IncompatibleCount}대`; - - // 6. 등급별 리스트 데이터 바 업데이트 - const total = filtered.length || 1; + // 6. 종합 매트릭스 테이블 렌더링 및 바인딩 + const matrixTbody = document.getElementById('pc-grade-matrix-tbody')!; - const updateCard = (id: string, counts: { total: number; stock: number }) => { - const card = document.getElementById(id)!; - const rate = Math.round((counts.total / total) * 100); + const renderMatrixRow = (gradeKey: keyof typeof matrix, label: string, color: string) => { + const data = matrix[gradeKey]; + const totalRate = filtered.length > 0 ? Math.round((data.total / filtered.length) * 100) : 0; - card.querySelector('.grade-count')!.textContent = `${counts.total}대`; - card.querySelector('.grade-rate')!.textContent = `(${rate}%)`; + const cellStyle = `padding: 9px 4px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s;`; + const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`; + + return ` + + + + + + + + `; }; - updateCard('grade-premium', gradeCounts.premium); - updateCard('grade-high', gradeCounts.high); - updateCard('grade-normal', gradeCounts.normal); - updateCard('grade-entry', gradeCounts.entry); + const totalPcs = filtered.length; + const totalActive = matrix.premium.active + matrix.high.active + matrix.normal.active + matrix.entry.active; + const totalStock = matrix.premium.stock + matrix.high.stock + matrix.normal.stock + matrix.entry.stock; + const totalUnder = matrix.premium.under + matrix.high.under + matrix.normal.under + matrix.entry.under; - // 6.2 Inventory Summary 수치 업데이트 (골드/민트 텍스트 영역) - const container = document.getElementById('view-body')?.parentElement || document.body; - const setStockVal = (cls: string, val: number) => { - const el = container.querySelector(`.${cls}`); - if (el) el.textContent = `${val}대`; - }; - setStockVal('summary-grade-stock-premium', gradeCounts.premium.stock); - setStockVal('summary-grade-stock-high', gradeCounts.high.stock); - setStockVal('summary-grade-stock-normal', gradeCounts.normal.stock); - setStockVal('summary-grade-stock-entry', gradeCounts.entry.stock); + const cellStyleHeader = `padding: 9px 4px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC;`; + const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`; + + matrixTbody.innerHTML = ` + ${renderMatrixRow('premium', '최상급 PC (85점 이상)', '#11302B')} + ${renderMatrixRow('high', '상급 PC (70점 ~ 85점)', '#1E8E7C')} + ${renderMatrixRow('normal', '중급 PC (40점 ~ 70점)', '#10B981')} + ${renderMatrixRow('entry', '보급 PC (40점 미만)', '#64748B')} + + + + + + + + `; + + // 셀별 동적 클릭 리스너 바인딩 + matrixTbody.querySelectorAll('.matrix-cell').forEach(cell => { + cell.addEventListener('click', () => { + const grade = cell.getAttribute('data-grade')!; + const type = cell.getAttribute('data-type')!; + + let targetList: any[] = []; + let title = ''; + + const getGradeLabel = (g: string) => { + if (g === 'premium') return '최상급 PC'; + if (g === 'high') return '상급 PC'; + if (g === 'normal') return '중급 PC'; + if (g === 'entry') return '보급 PC'; + return '전체 PC'; + }; + + const getTypeLabel = (t: string) => { + if (t === 'total') return '보유'; + if (t === 'active') return '할당 (운영)'; + if (t === 'stock') return '여분 (재고)'; + if (t === 'under') return '부족 (사양 부족)'; + return ''; + }; + + if (grade === 'all') { + if (type === 'total') { + targetList = filtered; + } else if (type === 'active') { + targetList = filtered.filter(p => !isStock(p)); + } else if (type === 'stock') { + targetList = filtered.filter(p => isStock(p)); + } else if (type === 'under') { + targetList = criticalList.filter(p => p._spec_status === '사양 부족'); + } + } else { + const data = matrix[grade as keyof typeof matrix]; + if (type === 'total') { + targetList = data.pcs; + } else if (type === 'active') { + targetList = data.activePcs; + } else if (type === 'stock') { + targetList = data.stockPcs; + } else if (type === 'under') { + targetList = data.underPcs; + } + } + + title = `${getGradeLabel(grade)} - ${getTypeLabel(type)} 자산 목록`; + showMiniListModal(title, targetList); + }); + }); // 7. 연도별 PC 노후도 집계 및 렌더링 const agingCounts = { @@ -442,9 +473,9 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const renderAgingRow = (label: string, list: any[], badgeText: string, badgeStyle: string, ageGroupKey: string) => { return ` - - - + + @@ -472,7 +503,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { }); }); - // 8. 각 등급 행 클릭 리스너 설정 + // 8. 요약 지표 카드 클릭 리스너 설정 const bindCardClick = (id: string, gradeTitle: string, filterFn: (p: any) => boolean) => { const card = document.getElementById(id)!; if (!card) return; @@ -488,23 +519,11 @@ function updateDashboardData(pcs: any[], selectedDept: string) { }; }; - bindCardClick('grade-premium', '최상급 PC', p => p._pc_score >= 85); - bindCardClick('grade-high', '상급 PC', p => p._pc_score >= 70 && p._pc_score < 85); - bindCardClick('grade-normal', '중급 PC', p => p._pc_score >= 40 && p._pc_score < 70); - bindCardClick('grade-entry', '보급 PC', p => p._pc_score < 40); - // 사양 부족 / 오버스펙 / 윈도우 11 불가 클릭 리스너 설정 bindCardClick('card-under-spec', '사양 부족 검토 대상', p => p._spec_status === '사양 부족'); bindCardClick('card-over-spec', '오버스펙 검토 대상', p => p._spec_status === '오버스펙'); bindCardClick('card-win11-incompatible', '윈도우 11 업그레이드 불가 PC', p => isWindows11Incompatible(p.cpu, p.ram)); - - // 8.2 유효 재고 현황 클릭 리스너 설정 - bindCardClick('stock-premium-card', '최상급 유효 재고', p => p._pc_score >= 85 && isStock(p)); - bindCardClick('stock-high-card', '상급 유효 재고', p => p._pc_score >= 70 && p._pc_score < 85 && isStock(p)); - bindCardClick('stock-normal-card', '중급 유효 재고', p => p._pc_score >= 40 && p._pc_score < 70 && isStock(p)); - bindCardClick('stock-entry-card', '보급 유효 재고', p => p._pc_score < 40 && isStock(p)); - // 9. 직무별 사양 적정성 대수 연산 및 차트 데이터 셋 구성 (누적 막대 그래프화) const activeJobs = Array.from( new Set(filtered.map((p: any) => p[ASSET_SCHEMA.USER_POSITION.key] || '미분류').filter(j => j !== '재고PC')) @@ -545,7 +564,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { // 10. 차트들 렌더링 호출 renderChart(activeJobs, underData, normalData, overData, filtered); - renderDonutChart(gradeCounts.premium.total, gradeCounts.high.total, gradeCounts.normal.total, gradeCounts.entry.total); + renderDonutChart(matrix.premium.total, matrix.high.total, matrix.normal.total, matrix.entry.total); // 전역 상태 등록 state.activeCharts = [jobChartInstance, donutChartInstance]; @@ -849,7 +868,7 @@ function renderDonutChart(premium: number, high: number, normal: number, entry: top: 50%; left: 50%; transform: translate(-50%, -46%); - font-size: 1.56rem; + font-size: 1.75rem; font-weight: 900; color: #1E5149; font-family: 'Pretendard', sans-serif; From a4b620099c5293d989523b3e60d2efed7db83529 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Mon, 15 Jun 2026 11:22:07 +0900 Subject: [PATCH 02/13] =?UTF-8?q?feat:=20=EB=8C=80=EC=8B=9C=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=ED=8F=B0=ED=8A=B8=20=ED=81=AC=EA=B8=B0,=20?= =?UTF-8?q?=ED=8C=A8=EB=94=A9=20=EC=A1=B0=EC=A0=88=20=EB=B0=8F=20=EC=9E=90?= =?UTF-8?q?=EC=82=B0=EB=AA=A9=EB=A1=9D=20=EC=A0=95=EB=A0=AC=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=20=EB=B3=80=EA=B2=BD(updated=5Fat=20=EB=82=B4?= =?UTF-8?q?=EB=A6=BC=EC=B0=A8=EC=88=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Dashboard/HwDashboard.ts | 252 ++++++++++++++++------------- src/views/List/ListFactory.ts | 25 ++- src/views/List/PcListView.ts | 13 +- 3 files changed, 171 insertions(+), 119 deletions(-) diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 9364620..b8208a5 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -18,10 +18,10 @@ export function renderHwDashboard(container: HTMLElement) { // 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드 container.innerHTML = ` -
+
-
+

개인 PC 자산 대시보드 @@ -44,62 +44,62 @@ export function renderHwDashboard(container: HTMLElement) {

-
+
-
+
-
+
-
+
- 보유 자산 수량 + 보유 자산 수량
-
0대
- 전사 보유 개인용 PC +
0대
+ 전사 보유 개인용 PC
- -
+ +
- 사양 부족 검토 + 사양 부족
-
0명
- 사양 교체 권고 자산 +
0대
+ 사양 교체 권고 자산
- -
+ +
- 오버스펙 검토 + 오버 스펙
-
0명
- 사양 회수 권고 자산 +
0대
+ 사양 회수 권고 자산
-
+
- 윈도우 11 불가 PC + 윈도우 11 불가 PC
-
0대
- 업데이트 미지원 하드웨어 +
0대
+ 업데이트 미지원 하드웨어
@@ -107,27 +107,25 @@ export function renderHwDashboard(container: HTMLElement) {
- - -
+ +
- -
+
-
- 등급별 자산 종합 현황 +
+ 등급별 자산 종합 현황
- -
-
구분 (사용 연한)구분 (사용 연한) 보유 대수 권장 조치
${label}${data.total}대 (${totalRate}%)${data.active}대${data.stock}대${data.under}대
합계 (Total)${totalPcs}대 (100%)${totalActive}대${totalStock}대${totalUnder}대
${label}${list.length}대 + ${label}${list.length}대 ${badgeText}
+ +
+
- - - - - - + + + + + + @@ -137,20 +135,40 @@ export function renderHwDashboard(container: HTMLElement) { - -
+
+ + + + +
+ + +
+
+ 직무별 사양 적정성 분석 +
+
+ +
+
+ + +
+ + +
-
- 등급별 보유 비율 +
+ 등급별 보유 비율
- -
-
+ +
+
- -
+ +
최상급 @@ -170,42 +188,26 @@ export function renderHwDashboard(container: HTMLElement) {
- -
- -
- -
- - -
-
- 직무별 사양 적정성 분석 -
-
- -
-
- - -
-
- 연도별 PC 노후도 및 교체 주기 예측 -
-
-
구분 (등급)보유량할당량여분부족분
구분 (등급)보유량운영중재고부족분
- - - - - - - - - - -
구분 (사용 연한)보유 대수권장 조치
+ +
+
+ 연도별 PC 노후도 및 예측 +
+
+ + + + + + + + + + + +
구분 (연한)보유권장 조치
+
@@ -322,20 +324,35 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; const avg = jobScores[job]?.avg || 0; + const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram); + let isUnder = false; + if (avg > 0 && job !== '재고PC') { if (score < avg * 0.6) { + isUnder = true; p._spec_status = '사양 부족'; - criticalList.push(p); - underSpecCount++; - target.under++; - target.underPcs.push(p); - } else if (score > avg * 1.5) { + } else if (score > avg * 1.5 && !win11Incompatible) { p._spec_status = '오버스펙'; criticalList.push(p); overSpecCount++; + } else if (win11Incompatible) { + isUnder = true; + p._spec_status = '사양 부족'; } else { p._spec_status = '적정'; } + } else { + if (win11Incompatible) { + isUnder = true; + p._spec_status = '사양 부족'; + } + } + + if (isUnder) { + criticalList.push(p); + underSpecCount++; + target.under++; + target.underPcs.push(p); } } @@ -347,8 +364,8 @@ function updateDashboardData(pcs: any[], selectedDept: string) { // 5. 핵심 텍스트형 요약 지표 갱신 document.getElementById('metric-total-pcs')!.textContent = `${filtered.length}대`; - document.getElementById('metric-under-spec')!.textContent = `${underSpecCount}명`; - document.getElementById('metric-over-spec')!.textContent = `${overSpecCount}명`; + document.getElementById('metric-under-spec')!.textContent = `${underSpecCount}대`; + document.getElementById('metric-over-spec')!.textContent = `${overSpecCount}대`; document.getElementById('metric-win11-incompatible')!.textContent = `${win11IncompatibleCount}대`; // 6. 종합 매트릭스 테이블 렌더링 및 바인딩 @@ -358,16 +375,18 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const data = matrix[gradeKey]; const totalRate = filtered.length > 0 ? Math.round((data.total / filtered.length) * 100) : 0; - const cellStyle = `padding: 9px 4px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s;`; + const cellStyle = `padding: 14px 12px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 1.25rem;`; const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`; + const shortage = Math.max(0, data.under - data.stock); + return ` - ${label} + ${label} ${data.total}대 (${totalRate}%) ${data.active}대 ${data.stock}대 - ${data.under}대 + ${shortage}대 `; }; @@ -375,9 +394,14 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const totalPcs = filtered.length; const totalActive = matrix.premium.active + matrix.high.active + matrix.normal.active + matrix.entry.active; const totalStock = matrix.premium.stock + matrix.high.stock + matrix.normal.stock + matrix.entry.stock; - const totalUnder = matrix.premium.under + matrix.high.under + matrix.normal.under + matrix.entry.under; + + const premiumShortage = Math.max(0, matrix.premium.under - matrix.premium.stock); + const highShortage = Math.max(0, matrix.high.under - matrix.high.stock); + const normalShortage = Math.max(0, matrix.normal.under - matrix.normal.stock); + const entryShortage = Math.max(0, matrix.entry.under - matrix.entry.stock); + const totalShortage = premiumShortage + highShortage + normalShortage + entryShortage; - const cellStyleHeader = `padding: 9px 4px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC;`; + const cellStyleHeader = `padding: 14px 12px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.25rem;`; const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`; matrixTbody.innerHTML = ` @@ -386,11 +410,11 @@ function updateDashboardData(pcs: any[], selectedDept: string) { ${renderMatrixRow('normal', '중급 PC (40점 ~ 70점)', '#10B981')} ${renderMatrixRow('entry', '보급 PC (40점 미만)', '#64748B')} - 합계 (Total) - ${totalPcs}대 (100%) + 합계 (Total) + ${totalPcs}대 (100%) ${totalActive}대 ${totalStock}대 - ${totalUnder}대 + ${totalShortage}대 `; @@ -413,8 +437,8 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const getTypeLabel = (t: string) => { if (t === 'total') return '보유'; - if (t === 'active') return '할당 (운영)'; - if (t === 'stock') return '여분 (재고)'; + if (t === 'active') return '운영중'; + if (t === 'stock') return '재고'; if (t === 'under') return '부족 (사양 부족)'; return ''; }; @@ -473,9 +497,9 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const renderAgingRow = (label: string, list: any[], badgeText: string, badgeStyle: string, ageGroupKey: string) => { return ` - ${label} - ${list.length}대 - + ${label} + ${list.length}대 + ${badgeText} @@ -519,9 +543,9 @@ function updateDashboardData(pcs: any[], selectedDept: string) { }; }; - // 사양 부족 / 오버스펙 / 윈도우 11 불가 클릭 리스너 설정 - bindCardClick('card-under-spec', '사양 부족 검토 대상', p => p._spec_status === '사양 부족'); - bindCardClick('card-over-spec', '오버스펙 검토 대상', p => p._spec_status === '오버스펙'); + // 사양 부족 / 오버 스펙 / 윈도우 11 불가 클릭 리스너 설정 + bindCardClick('card-under-spec', '사양 부족 대상', p => p._spec_status === '사양 부족'); + bindCardClick('card-over-spec', '오버 스펙 대상', p => p._spec_status === '오버스펙'); bindCardClick('card-win11-incompatible', '윈도우 11 업그레이드 불가 PC', p => isWindows11Incompatible(p.cpu, p.ram)); // 9. 직무별 사양 적정성 대수 연산 및 차트 데이터 셋 구성 (누적 막대 그래프화) @@ -714,7 +738,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[] categoryPercentage: 0.8 }, { - label: '오버스펙', + label: '오버 스펙', data: overData, backgroundColor: 'rgba(217, 119, 6, 0.85)', // Amber Orange borderColor: 'rgb(217, 119, 6)', @@ -758,7 +782,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[] return specStatus === clickedStatus; }); - showMiniListModal(`${clickedJob} - ${clickedStatus === '적정' ? '적정 사양' : clickedStatus} 자산`, matchedPcs); + showMiniListModal(`${clickedJob} - ${clickedStatus === '적정' ? '적정 사양' : (clickedStatus === '오버스펙' ? '오버 스펙' : clickedStatus)} 자산`, matchedPcs); } }, plugins: { @@ -766,10 +790,10 @@ function renderChart(labels: string[], underData: number[], normalData: number[] position: 'top', align: 'end', labels: { - font: { family: 'Pretendard', size: 11, weight: '700' }, + font: { family: 'Pretendard', size: 16, weight: '700' }, color: '#475569', - boxWidth: 8, - boxHeight: 8, + boxWidth: 12, + boxHeight: 12, usePointStyle: true } }, @@ -792,7 +816,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[] stacked: true, ticks: { callback: (val: any) => `${val}대`, - font: { family: 'Pretendard', size: 10, weight: '600' }, + font: { family: 'Pretendard', size: 14, weight: '600' }, color: '#64748B' }, grid: { color: '#EEF2F6' } @@ -800,7 +824,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[] y: { stacked: true, ticks: { - font: { family: 'Pretendard', size: 11, weight: '700' }, + font: { family: 'Pretendard', size: 16, weight: '700' }, color: '#475569' }, grid: { display: false } @@ -868,7 +892,7 @@ function renderDonutChart(premium: number, high: number, normal: number, entry: top: 50%; left: 50%; transform: translate(-50%, -46%); - font-size: 1.75rem; + font-size: 1.65rem; font-weight: 900; color: #1E5149; font-family: 'Pretendard', sans-serif; diff --git a/src/views/List/ListFactory.ts b/src/views/List/ListFactory.ts index ba46459..b5f799d 100644 --- a/src/views/List/ListFactory.ts +++ b/src/views/List/ListFactory.ts @@ -1,5 +1,5 @@ import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; -import { dynamicSort, renderPageHeader, calculateAssetAge, formatInline } from '../../core/utils'; +import { dynamicSort, renderPageHeader, calculateAssetAge, formatInline, isWindows11Incompatible } from '../../core/utils'; import { setupTableSorting, SortState } from '../../core/tableHandler'; import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler'; import { state } from '../../core/state'; @@ -921,14 +921,31 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { const score = pc['_pc_score']; const avg = jobScores[job].avg; + const cpu = pc[ASSET_SCHEMA.CPU.key] || ''; + const ram = pc[ASSET_SCHEMA.RAM.key] || ''; + const win11Incompatible = isWindows11Incompatible(cpu, ram); + + let isUnder = false; if (avg > 0) { if (score < avg * 0.6) { + isUnder = true; pc['_spec_status'] = '사양 부족'; - criticalPcList.push(pc); - } else if (score > avg * 1.5) { + } else if (score > avg * 1.5 && !win11Incompatible) { pc['_spec_status'] = '오버스펙'; criticalPcList.push(pc); + } else if (win11Incompatible) { + isUnder = true; + pc['_spec_status'] = '사양 부족'; } + } else { + if (win11Incompatible) { + isUnder = true; + pc['_spec_status'] = '사양 부족'; + } + } + + if (isUnder) { + criticalPcList.push(pc); } }); @@ -960,7 +977,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { ${user} ${dept} (${job}) - ${status} + ${status === '오버스펙' ? '오버 스펙' : status} ${assetCode} diff --git a/src/views/List/PcListView.ts b/src/views/List/PcListView.ts index 178d9ac..02a2e69 100644 --- a/src/views/List/PcListView.ts +++ b/src/views/List/PcListView.ts @@ -3,16 +3,27 @@ import { openHwModal } from '../../components/Modal/HWModal'; import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade } from '../../core/utils'; import { ASSET_SCHEMA } from '../../core/schema'; import { createListView } from './ListFactory'; +import { SortState } from '../../core/tableHandler'; + +let persistentSortState: SortState = { key: 'updated_at', direction: 'desc' }; export function renderPcList(container: HTMLElement) { createListView(container, { title: 'PC', + persistentSortState, dataSource: () => { const list = (state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC'); list.forEach((a: any) => { a['_pc_score'] = calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date); }); - return sortAssets(list); + // 변경일시(updated_at) 내림차순 정렬 (최신 변경 항목이 맨 위로) + return list.sort((a: any, b: any) => { + const dateA = a.updated_at || a.created_at || ''; + const dateB = b.updated_at || b.created_at || ''; + if (dateA < dateB) return 1; + if (dateA > dateB) return -1; + return 0; + }); }, searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'], filterOptions: { From 97cecb8b50522a264fc484c370c7650632445ee6 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Mon, 15 Jun 2026 13:12:07 +0900 Subject: [PATCH 03/13] =?UTF-8?q?feat:=205=EB=93=B1=EA=B8=89=20PC=20?= =?UTF-8?q?=EB=B6=84=EB=A5=98=20=EC=B2=B4=EA=B3=84=20=EB=8F=84=EC=9E=85=20?= =?UTF-8?q?(=EA=B5=90=EC=B2=B4=20=EB=8C=80=EC=83=81=20PC=20=EB=93=B1?= =?UTF-8?q?=EA=B8=89=20=EC=8B=A0=EC=84=A4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/utils.ts | 5 ++-- src/views/Dashboard/HwDashboard.ts | 39 +++++++++++++++++++----------- src/views/List/PcListView.ts | 5 ++-- 3 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/core/utils.ts b/src/core/utils.ts index 8993e3c..27dcab0 100644 --- a/src/core/utils.ts +++ b/src/core/utils.ts @@ -288,11 +288,12 @@ export function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string, /** * 성능 점수 기준 등급 뱃지 메타 정보 가져오기 */ -export function getPcGrade(score: number): { name: string; class: string; color: string } { +export function getPcGrade(score: number, isWin11Incompatible?: boolean): { name: string; class: string; color: string } { if (score >= 85) return { name: '최상급', class: 'b-purple', color: '#7C3AED' }; if (score >= 70) return { name: '상급', class: 'b-primary', color: '#4F46E5' }; if (score >= 40) return { name: '중급', class: 'b-green', color: '#10B981' }; - return { name: '보급', class: 'b-yellow', color: '#F59E0B' }; + if (score >= 20 && !isWin11Incompatible) return { name: '보급', class: 'b-yellow', color: '#F59E0B' }; + return { name: '교체 대상', class: 'badge-danger', color: '#EF4444' }; } /** diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index b8208a5..45507d0 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -182,9 +182,13 @@ export function renderHwDashboard(container: HTMLElement) { 중급
- + 보급
+
+ + 교체 대상 +
@@ -285,7 +289,8 @@ function updateDashboardData(pcs: any[], selectedDept: string) { premium: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, high: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, normal: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, - entry: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] } + entry: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, + replace: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] } }; let scoreSum = 0; @@ -298,6 +303,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const score = p._pc_score; scoreSum += score; const stockYn = isStock(p); + const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram); let target: typeof matrix.premium; if (score >= 85) { @@ -306,8 +312,10 @@ function updateDashboardData(pcs: any[], selectedDept: string) { target = matrix.high; } else if (score >= 40) { target = matrix.normal; - } else { + } else if (score >= 20 && !win11Incompatible) { target = matrix.entry; + } else { + target = matrix.replace; } target.pcs.push(p); @@ -324,7 +332,6 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; const avg = jobScores[job]?.avg || 0; - const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram); let isUnder = false; if (avg > 0 && job !== '재고PC') { @@ -392,14 +399,15 @@ function updateDashboardData(pcs: any[], selectedDept: string) { }; const totalPcs = filtered.length; - const totalActive = matrix.premium.active + matrix.high.active + matrix.normal.active + matrix.entry.active; - const totalStock = matrix.premium.stock + matrix.high.stock + matrix.normal.stock + matrix.entry.stock; + const totalActive = matrix.premium.active + matrix.high.active + matrix.normal.active + matrix.entry.active + matrix.replace.active; + const totalStock = matrix.premium.stock + matrix.high.stock + matrix.normal.stock + matrix.entry.stock + matrix.replace.stock; const premiumShortage = Math.max(0, matrix.premium.under - matrix.premium.stock); const highShortage = Math.max(0, matrix.high.under - matrix.high.stock); const normalShortage = Math.max(0, matrix.normal.under - matrix.normal.stock); const entryShortage = Math.max(0, matrix.entry.under - matrix.entry.stock); - const totalShortage = premiumShortage + highShortage + normalShortage + entryShortage; + const replaceShortage = Math.max(0, matrix.replace.under - matrix.replace.stock); + const totalShortage = premiumShortage + highShortage + normalShortage + entryShortage + replaceShortage; const cellStyleHeader = `padding: 14px 12px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.25rem;`; const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`; @@ -408,7 +416,8 @@ function updateDashboardData(pcs: any[], selectedDept: string) { ${renderMatrixRow('premium', '최상급 PC (85점 이상)', '#11302B')} ${renderMatrixRow('high', '상급 PC (70점 ~ 85점)', '#1E8E7C')} ${renderMatrixRow('normal', '중급 PC (40점 ~ 70점)', '#10B981')} - ${renderMatrixRow('entry', '보급 PC (40점 미만)', '#64748B')} + ${renderMatrixRow('entry', '보급 PC (20점 ~ 40점 & Win11 가능)', '#F59E0B')} + ${renderMatrixRow('replace', '교체 대상 PC (20점 미만 또는 Win11 불가)', '#EF4444')} 합계 (Total) ${totalPcs}대 (100%) @@ -432,6 +441,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { if (g === 'high') return '상급 PC'; if (g === 'normal') return '중급 PC'; if (g === 'entry') return '보급 PC'; + if (g === 'replace') return '교체 대상 PC'; return '전체 PC'; }; @@ -588,7 +598,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { // 10. 차트들 렌더링 호출 renderChart(activeJobs, underData, normalData, overData, filtered); - renderDonutChart(matrix.premium.total, matrix.high.total, matrix.normal.total, matrix.entry.total); + renderDonutChart(matrix.premium.total, matrix.high.total, matrix.normal.total, matrix.entry.total, matrix.replace.total); // 전역 상태 등록 state.activeCharts = [jobChartInstance, donutChartInstance]; @@ -837,7 +847,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[] /** * 실시간 사양 적정률 원형 도넛 그래프 (Active Spec Rate) */ -function renderDonutChart(premium: number, high: number, normal: number, entry: number) { +function renderDonutChart(premium: number, high: number, normal: number, entry: number, replace: number) { const ctx = document.getElementById('chart-overall-donut') as HTMLCanvasElement; if (!ctx || typeof Chart === 'undefined') return; @@ -846,19 +856,20 @@ function renderDonutChart(premium: number, high: number, normal: number, entry: donutChartInstance = null; } - const total = premium + high + normal + entry; + const total = premium + high + normal + entry + replace; donutChartInstance = new Chart(ctx, { type: 'doughnut', data: { - labels: ['최상급', '상급', '중급', '보급'], + labels: ['최상급', '상급', '중급', '보급', '교체 대상'], datasets: [{ - data: [premium, high, normal, entry], + data: [premium, high, normal, entry, replace], backgroundColor: [ '#11302B', // premium (Hanmac Dark Green) '#1E8E7C', // high (Hanmac Teal) '#10B981', // normal (Hanmac Mint) - '#94A3B8' // entry (Slate Gray) + '#F59E0B', // entry (Yellow-Orange) + '#EF4444' // replace (Red) ], borderColor: '#ffffff', borderWidth: 2 diff --git a/src/views/List/PcListView.ts b/src/views/List/PcListView.ts index 02a2e69..2409af1 100644 --- a/src/views/List/PcListView.ts +++ b/src/views/List/PcListView.ts @@ -1,6 +1,6 @@ import { state } from '../../core/state'; import { openHwModal } from '../../components/Modal/HWModal'; -import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade } from '../../core/utils'; +import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade, isWindows11Incompatible } from '../../core/utils'; import { ASSET_SCHEMA } from '../../core/schema'; import { createListView } from './ListFactory'; import { SortState } from '../../core/tableHandler'; @@ -104,7 +104,8 @@ export function renderPcList(container: HTMLElement) { width: '8%', render: a => { const score = a._pc_score !== undefined ? a._pc_score : calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date); - const grade = getPcGrade(score); + const isWin11Incompatible = isWindows11Incompatible(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key]); + const grade = getPcGrade(score, isWin11Incompatible); return `${grade.name}`; } } From c35f57acabe5f62423b27ec06a7ff4cb00f50908 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Mon, 15 Jun 2026 13:14:45 +0900 Subject: [PATCH 04/13] =?UTF-8?q?style:=20=EB=B3=B4=EA=B8=89=20PC=20?= =?UTF-8?q?=EB=9D=BC=EB=B2=A8=20=EB=8B=A8=EC=88=9C=ED=99=94=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B6=80=EC=A1=B1=EB=B6=84=20=EC=BB=AC=EB=9F=BC=EB=AA=85?= =?UTF-8?q?=EC=9D=84=20=EA=B5=AC=EB=A7=A4=20=ED=95=84=EC=9A=94=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Dashboard/HwDashboard.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 45507d0..d18856c 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -125,7 +125,7 @@ export function renderHwDashboard(container: HTMLElement) { 보유량 운영중 재고 - 부족분 + 구매 필요 @@ -416,7 +416,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { ${renderMatrixRow('premium', '최상급 PC (85점 이상)', '#11302B')} ${renderMatrixRow('high', '상급 PC (70점 ~ 85점)', '#1E8E7C')} ${renderMatrixRow('normal', '중급 PC (40점 ~ 70점)', '#10B981')} - ${renderMatrixRow('entry', '보급 PC (20점 ~ 40점 & Win11 가능)', '#F59E0B')} + ${renderMatrixRow('entry', '보급 PC (20점 ~ 40점)', '#F59E0B')} ${renderMatrixRow('replace', '교체 대상 PC (20점 미만 또는 Win11 불가)', '#EF4444')} 합계 (Total) @@ -449,7 +449,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) { if (t === 'total') return '보유'; if (t === 'active') return '운영중'; if (t === 'stock') return '재고'; - if (t === 'under') return '부족 (사양 부족)'; + if (t === 'under') return '구매 필요'; return ''; }; From d6e75f8b2c3dfd3fa34b6368ab8a5eeae3c6bb54 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Mon, 15 Jun 2026 13:17:01 +0900 Subject: [PATCH 05/13] =?UTF-8?q?refactor:=20=EA=B5=90=EC=B2=B4=20?= =?UTF-8?q?=EB=8C=80=EC=83=81=20PC=20=EA=B5=90=EC=B2=B4=20=EC=88=98?= =?UTF-8?q?=EC=9A=94=EB=A5=BC=20=EB=B3=B4=EA=B8=89=20PC=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=EA=B5=AC=EB=A7=A4=20=ED=95=84=EC=9A=94=20=EC=88=98?= =?UTF-8?q?=EB=9F=89=EC=9C=BC=EB=A1=9C=20=EC=9D=B4=EC=A0=84=20=ED=95=A9?= =?UTF-8?q?=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Dashboard/HwDashboard.ts | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index d18856c..3b8e88d 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -378,15 +378,13 @@ function updateDashboardData(pcs: any[], selectedDept: string) { // 6. 종합 매트릭스 테이블 렌더링 및 바인딩 const matrixTbody = document.getElementById('pc-grade-matrix-tbody')!; - const renderMatrixRow = (gradeKey: keyof typeof matrix, label: string, color: string) => { + const renderMatrixRow = (gradeKey: keyof typeof matrix, label: string, color: string, shortage: number) => { const data = matrix[gradeKey]; const totalRate = filtered.length > 0 ? Math.round((data.total / filtered.length) * 100) : 0; const cellStyle = `padding: 14px 12px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 1.25rem;`; const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`; - const shortage = Math.max(0, data.under - data.stock); - return ` ${label} @@ -405,19 +403,24 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const premiumShortage = Math.max(0, matrix.premium.under - matrix.premium.stock); const highShortage = Math.max(0, matrix.high.under - matrix.high.stock); const normalShortage = Math.max(0, matrix.normal.under - matrix.normal.stock); - const entryShortage = Math.max(0, matrix.entry.under - matrix.entry.stock); - const replaceShortage = Math.max(0, matrix.replace.under - matrix.replace.stock); + + // 보급 PC 구매 필요 = (보급 under + 교체대상 under) - 보급 stock (교체 대상을 위해 최소 보급 PC 사양을 구매한다는 논리 적용) + const entryShortage = Math.max(0, (matrix.entry.under + matrix.replace.under) - matrix.entry.stock); + + // 교체 대상 PC 자체는 새로 구매하는 기종이 아니므로 구매 필요 0대 + const replaceShortage = 0; + const totalShortage = premiumShortage + highShortage + normalShortage + entryShortage + replaceShortage; const cellStyleHeader = `padding: 14px 12px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.25rem;`; const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`; matrixTbody.innerHTML = ` - ${renderMatrixRow('premium', '최상급 PC (85점 이상)', '#11302B')} - ${renderMatrixRow('high', '상급 PC (70점 ~ 85점)', '#1E8E7C')} - ${renderMatrixRow('normal', '중급 PC (40점 ~ 70점)', '#10B981')} - ${renderMatrixRow('entry', '보급 PC (20점 ~ 40점)', '#F59E0B')} - ${renderMatrixRow('replace', '교체 대상 PC (20점 미만 또는 Win11 불가)', '#EF4444')} + ${renderMatrixRow('premium', '최상급 PC (85점 이상)', '#11302B', premiumShortage)} + ${renderMatrixRow('high', '상급 PC (70점 ~ 85점)', '#1E8E7C', highShortage)} + ${renderMatrixRow('normal', '중급 PC (40점 ~ 70점)', '#10B981', normalShortage)} + ${renderMatrixRow('entry', '보급 PC (20점 ~ 40점)', '#F59E0B', entryShortage)} + ${renderMatrixRow('replace', '교체 대상 PC (20점 미만 또는 Win11 불가)', '#EF4444', replaceShortage)} 합계 (Total) ${totalPcs}대 (100%) From 132e37d0d3189db9fe412818512b17d6a944f4e7 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Mon, 15 Jun 2026 13:19:10 +0900 Subject: [PATCH 06/13] =?UTF-8?q?refactor:=20=EC=82=AC=EC=96=91=20?= =?UTF-8?q?=EB=B6=80=EC=A1=B1=20=EB=B0=8F=20=EB=85=B8=ED=9B=84=20=EC=9E=A5?= =?UTF-8?q?=EB=B9=84=EC=9D=98=20=EA=B5=90=EC=B2=B4=20=EC=88=98=EC=9A=94?= =?UTF-8?q?=EB=A5=BC=20=ED=95=B4=EB=8B=B9=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EC=9D=98=20=EC=A7=81=EB=AC=B4=20=EA=B6=8C=EC=9E=A5=20=EC=82=AC?= =?UTF-8?q?=EC=96=91=20=EB=93=B1=EA=B8=89=EC=97=90=20=EB=A7=9E=EC=B6=94?= =?UTF-8?q?=EC=96=B4=20=EB=B6=84=EC=82=B0=20=ED=95=A9=EA=B3=84=20=EA=B3=84?= =?UTF-8?q?=EC=82=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/Dashboard/HwDashboard.ts | 48 ++++++++++++++++++++---------- 1 file changed, 32 insertions(+), 16 deletions(-) diff --git a/src/views/Dashboard/HwDashboard.ts b/src/views/Dashboard/HwDashboard.ts index 3b8e88d..58e49e7 100644 --- a/src/views/Dashboard/HwDashboard.ts +++ b/src/views/Dashboard/HwDashboard.ts @@ -305,28 +305,30 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const stockYn = isStock(p); const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram); - let target: typeof matrix.premium; + // 1. 현재 물리적 자산 등급 판정 + let currentGradeKey: keyof typeof matrix; if (score >= 85) { - target = matrix.premium; + currentGradeKey = 'premium'; } else if (score >= 70) { - target = matrix.high; + currentGradeKey = 'high'; } else if (score >= 40) { - target = matrix.normal; + currentGradeKey = 'normal'; } else if (score >= 20 && !win11Incompatible) { - target = matrix.entry; + currentGradeKey = 'entry'; } else { - target = matrix.replace; + currentGradeKey = 'replace'; } - target.pcs.push(p); - target.total++; + const currentTarget = matrix[currentGradeKey]; + currentTarget.pcs.push(p); + currentTarget.total++; if (stockYn) { - target.stock++; - target.stockPcs.push(p); + currentTarget.stock++; + currentTarget.stockPcs.push(p); } else { - target.active++; - target.activePcs.push(p); + currentTarget.active++; + currentTarget.activePcs.push(p); // 직무 적정성 계산 (재직 중이고 실 사용자 매핑 자산만 검토 대상) const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; @@ -358,8 +360,22 @@ function updateDashboardData(pcs: any[], selectedDept: string) { if (isUnder) { criticalList.push(p); underSpecCount++; - target.under++; - target.underPcs.push(p); + + // 2. 사양 부족 시 교체받아야 할 직무별 권장 목표 등급 판정 + let targetGradeKey: keyof typeof matrix; + if (avg >= 85) { + targetGradeKey = 'premium'; + } else if (avg >= 70) { + targetGradeKey = 'high'; + } else if (avg >= 40) { + targetGradeKey = 'normal'; + } else { + targetGradeKey = 'entry'; // 교체 대상은 최소 보급형 사양으로 교체 + } + + const targetGrade = matrix[targetGradeKey]; + targetGrade.under++; + targetGrade.underPcs.push(p); } } @@ -404,8 +420,8 @@ function updateDashboardData(pcs: any[], selectedDept: string) { const highShortage = Math.max(0, matrix.high.under - matrix.high.stock); const normalShortage = Math.max(0, matrix.normal.under - matrix.normal.stock); - // 보급 PC 구매 필요 = (보급 under + 교체대상 under) - 보급 stock (교체 대상을 위해 최소 보급 PC 사양을 구매한다는 논리 적용) - const entryShortage = Math.max(0, (matrix.entry.under + matrix.replace.under) - matrix.entry.stock); + // 보급 PC 구매 필요 = 보급 under - 보급 stock + const entryShortage = Math.max(0, matrix.entry.under - matrix.entry.stock); // 교체 대상 PC 자체는 새로 구매하는 기종이 아니므로 구매 필요 0대 const replaceShortage = 0; From e678f9d6531ecefa1474cf9f28f6677c41f38f04 Mon Sep 17 00:00:00 2001 From: JooWangi Date: Mon, 15 Jun 2026 13:26:11 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20=EB=B6=80=ED=92=88=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=ED=99=94=EB=A9=B4=20=EB=82=B4=20=EC=A7=81?= =?UTF-8?q?=EB=AC=B4=EB=B3=84=20=EA=B8=B0=EC=A4=80=20=EC=82=AC=EC=96=91=20?= =?UTF-8?q?CRUD=20=EB=B0=8F=20=EC=84=9C=EB=B8=8C=20=ED=83=AD=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- server.js | 78 ++++++++++ src/components/Modal/JobSpecModal.ts | 176 +++++++++++++++++++++ src/core/schema.ts | 10 ++ src/core/state.ts | 40 ++++- src/main.ts | 9 +- src/views/List/PartsMasterListView.ts | 213 +++++++++++++++++++------- 6 files changed, 470 insertions(+), 56 deletions(-) create mode 100644 src/components/Modal/JobSpecModal.ts diff --git a/server.js b/server.js index 04336bf..18a5d95 100644 --- a/server.js +++ b/server.js @@ -28,6 +28,32 @@ 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); @@ -151,6 +177,7 @@ 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; @@ -158,6 +185,7 @@ 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) { @@ -546,6 +574,56 @@ 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 { diff --git a/src/components/Modal/JobSpecModal.ts b/src/components/Modal/JobSpecModal.ts new file mode 100644 index 0000000..2152177 --- /dev/null +++ b/src/components/Modal/JobSpecModal.ts @@ -0,0 +1,176 @@ +import { state, saveJobSpec, deleteJobSpec } from '../../core/state'; +import { BaseModal } from './BaseModal'; +import { setFieldValue } from './ModalUtils'; +import { UI_TEXT } from '../../core/schema'; + +class JobSpecModal extends BaseModal { + constructor() { + super('job-spec', '직무별 기준 사양'); + } + + protected renderFrameHTML(): string { + const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;'; + const inputStyle = sharedStyle; + + return ` + + `; + } + + 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(); + } + }); + } + + 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() : '0'); + setFieldValue('job-spec-remarks', asset.remarks || ''); + } + + 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') { + this.setEditLockMode('edit'); + this.isEditMode = true; + saveBtn.textContent = '등록'; + saveBtn.style.display = 'block'; + } else { + this.setEditLockMode('view'); + this.isEditMode = false; + saveBtn.textContent = '수정'; + saveBtn.style.display = 'block'; + } + } +} + +export const 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); +} diff --git a/src/core/schema.ts b/src/core/schema.ts index d5405d8..288e56e 100644 --- a/src/core/schema.ts +++ b/src/core/schema.ts @@ -160,6 +160,16 @@ export const PAGE_DESCRIPTIONS: Record ({ ...l, assetId: l.asset_id || l.assetId, @@ -229,3 +232,38 @@ export async function deleteSystemUser(id: string) { } return false; } + +export async function saveJobSpec(spec: any) { + try { + const url = `${API_BASE_URL}/api/job-specs/save`; + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(spec) + }); + + if (response.ok) { + await loadMasterDataFromDB(); // 전역 상태 갱신 + return true; + } + } catch (err) { + console.error('직무별 기준 사양 저장 실패:', err); + } + return false; +} + +export async function deleteJobSpec(id: number) { + try { + const url = `${API_BASE_URL}/api/job-specs/${id}`; + const response = await fetch(url, { method: 'DELETE' }); + + if (response.ok) { + await loadMasterDataFromDB(); // 전역 상태 갱신 + return true; + } + } catch (err) { + console.error('직무별 기준 사양 삭제 실패:', err); + } + return false; +} + diff --git a/src/main.ts b/src/main.ts index f413815..ee7ecc4 100644 --- a/src/main.ts +++ b/src/main.ts @@ -9,7 +9,9 @@ import { initSwModal, openSwModal } from './components/Modal/SWModal'; import { initSwUserModal } from './components/Modal/SWUserModal'; import { initDomainModal, openDomainModal } from './components/Modal/DomainModal'; import { initPartsMasterModal, openPartsMasterModal } from './components/Modal/PartsMasterModal'; +import { initJobSpecModal, openJobSpecModal } from './components/Modal/JobSpecModal'; import { initUserModal, openUserModal } from './components/Modal/UserModal'; +import { activePartsMasterSubTab } from './views/List/PartsMasterListView'; import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal'; import { initGuide } from './components/Guide'; import { pcFlowModal } from './components/Modal/PCFlowModal'; @@ -85,6 +87,7 @@ function initApp() { }, closeAllModals); initDomainModal(() => refreshAllData(), closeAllModals); initPartsMasterModal(() => refreshAllData(), closeAllModals); + initJobSpecModal(() => refreshAllData(), closeAllModals); initUserModal(() => refreshAllData(), closeAllModals); initDashboardDetailModal(); @@ -114,7 +117,11 @@ function initApp() { if (cat === 'hw') { if (tab === '부품 마스터') { - openPartsMasterModal({ id: '' } as any, 'add'); + if (activePartsMasterSubTab === 'job-spec') { + openJobSpecModal({ id: '' } as any, 'add'); + } else { + openPartsMasterModal({ id: '' } as any, 'add'); + } } else { openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add'); } diff --git a/src/views/List/PartsMasterListView.ts b/src/views/List/PartsMasterListView.ts index 6608e9f..552189c 100644 --- a/src/views/List/PartsMasterListView.ts +++ b/src/views/List/PartsMasterListView.ts @@ -1,66 +1,171 @@ import { state } from '../../core/state'; import { openPartsMasterModal } from '../../components/Modal/PartsMasterModal'; +import { openJobSpecModal } from '../../components/Modal/JobSpecModal'; import { formatInline } from '../../core/utils'; import { createListView } from './ListFactory'; +export let activePartsMasterSubTab: 'parts-master' | 'job-spec' = 'parts-master'; + export function renderPartsMasterList(container: HTMLElement) { - createListView(container, { - title: '부품 마스터', - dataSource: () => state.masterData.partsMaster || [], - searchKeys: ['component_name', 'category', 'score_tier'], - filterOptions: { - keywordLabel: '부품명 / 등급 검색', - showLoc: false, - showDept: false, - showType: false - }, - onRowClick: (component) => openPartsMasterModal(component, 'view'), - columns: [ - { - header: 'ID', - sortKey: 'id', - align: 'center', - width: '5%', - render: c => c.id.toString() + if (activePartsMasterSubTab === 'parts-master') { + createListView(container, { + title: '부품 마스터', + dataSource: () => state.masterData.partsMaster || [], + searchKeys: ['component_name', 'category', 'score_tier'], + filterOptions: { + keywordLabel: '부품명 / 등급 검색', + showLoc: false, + showDept: false, + showType: false }, - { - header: '분류', - sortKey: 'category', - align: 'center', - width: '15%', - render: c => { - let badgeClass = 'badge-primary'; - if (c.category === 'CPU') badgeClass = 'b-primary'; - else if (c.category === 'GPU') badgeClass = 'b-purple'; - else if (c.category === 'RAM') badgeClass = 'b-green'; - return `${c.category}`; + onRowClick: (component) => openPartsMasterModal(component, 'view'), + columns: [ + { + header: 'ID', + sortKey: 'id', + align: 'center', + width: '5%', + render: c => c.id.toString() + }, + { + header: '분류', + sortKey: 'category', + align: 'center', + width: '15%', + render: c => { + let badgeClass = 'badge-primary'; + if (c.category === 'CPU') badgeClass = 'b-primary'; + else if (c.category === 'GPU') badgeClass = 'b-purple'; + else if (c.category === 'RAM') badgeClass = 'b-green'; + return `${c.category}`; + } + }, + { + header: '부품 표준 명칭', + sortKey: 'component_name', + render: c => formatInline(c.component_name || '-') + }, + { + header: '성능 등급', + sortKey: 'score_tier', + align: 'center', + width: '15%', + render: c => c.score_tier || '-' + }, + { + header: '감점 점수', + sortKey: 'deduction', + align: 'center', + width: '15%', + render: c => { + const score = c.deduction || 0; + let color = '#3b82f6'; // blue + if (score >= 20) color = '#ef4444'; // red + else if (score >= 10) color = '#f59e0b'; // orange + return `-${score}점`; + } } + ] + }); + } else { + createListView(container, { + title: '직무별 기준 사양', + dataSource: () => state.masterData.jobSpecs || [], + searchKeys: ['job_name', 'cpu_standard', 'ram_standard', 'gpu_standard', 'remarks'], + filterOptions: { + keywordLabel: '직무명 / 사양 검색', + showLoc: false, + showDept: false, + showType: false }, - { - header: '부품 표준 명칭', - sortKey: 'component_name', - render: c => formatInline(c.component_name || '-') - }, - { - header: '성능 등급', - sortKey: 'score_tier', - align: 'center', - width: '15%', - render: c => c.score_tier || '-' - }, - { - header: '감점 점수', - sortKey: 'deduction', - align: 'center', - width: '15%', - render: c => { - const score = c.deduction || 0; - let color = '#3b82f6'; // blue - if (score >= 20) color = '#ef4444'; // red - else if (score >= 10) color = '#f59e0b'; // orange - return `-${score}점`; + onRowClick: (jobSpec) => openJobSpecModal(jobSpec, 'view'), + columns: [ + { + header: 'ID', + sortKey: 'id', + align: 'center', + width: '5%', + render: j => j.id.toString() + }, + { + header: '직무명', + sortKey: 'job_name', + width: '15%', + render: j => `${formatInline(j.job_name || '-')}` + }, + { + header: '권장 CPU 사양', + sortKey: 'cpu_standard', + render: j => formatInline(j.cpu_standard || '-') + }, + { + header: '권장 RAM 사양', + sortKey: 'ram_standard', + width: '12%', + render: j => formatInline(j.ram_standard || '-') + }, + { + header: '권장 GPU 사양', + sortKey: 'gpu_standard', + render: j => formatInline(j.gpu_standard || '-') + }, + { + header: '기준 점수', + sortKey: 'min_score', + align: 'center', + width: '10%', + render: j => `${j.min_score || 0}점 이상` + }, + { + header: '비고', + sortKey: 'remarks', + width: '20%', + render: j => formatInline(j.remarks || '-') } - } - ] + ] + }); + } + + renderSubTabs(container); +} + +function renderSubTabs(container: HTMLElement) { + const header = container.querySelector('.page-header'); + if (!header) return; + + const tabContainer = document.createElement('div'); + tabContainer.className = 'sub-tab-container'; + tabContainer.style.cssText = 'display: flex; gap: 16px; margin-top: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--border-color); padding-bottom: 0;'; + + const tab1Active = activePartsMasterSubTab === 'parts-master'; + const tab2Active = activePartsMasterSubTab === 'job-spec'; + + tabContainer.innerHTML = ` + + + `; + + header.parentNode!.insertBefore(tabContainer, header.nextSibling); + + const tabPartsMaster = document.getElementById('tab-parts-master')!; + const tabJobSpec = document.getElementById('tab-job-spec')!; + + tabPartsMaster.addEventListener('click', () => { + if (activePartsMasterSubTab !== 'parts-master') { + activePartsMasterSubTab = 'parts-master'; + renderPartsMasterList(container); + } + }); + + tabJobSpec.addEventListener('click', () => { + if (activePartsMasterSubTab !== 'job-spec') { + activePartsMasterSubTab = 'job-spec'; + renderPartsMasterList(container); + } }); } + From 8c406fd0b8e65839ae610a7abc40237a3a1196da Mon Sep 17 00:00:00 2001 From: JooWangi Date: Mon, 15 Jun 2026 13:27:20 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20=EB=B6=80=ED=92=88=20=ED=91=9C?= =?UTF-8?q?=EC=A4=80=20=EC=A0=95=EB=B3=B4=20=EC=84=9C=EB=B8=8C=20=ED=83=AD?= =?UTF-8?q?=20=EB=A0=8C=EB=8D=94=EB=A7=81=20=EC=8B=9C=20DOM=20=EC=BF=BC?= =?UTF-8?q?=EB=A6=AC=20=ED=83=80=EC=9D=B4=EB=B0=8D=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20(document.getElementById=20->=20tabContain?= =?UTF-8?q?er.querySelector)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/views/List/PartsMasterListView.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/List/PartsMasterListView.ts b/src/views/List/PartsMasterListView.ts index 552189c..4f07d10 100644 --- a/src/views/List/PartsMasterListView.ts +++ b/src/views/List/PartsMasterListView.ts @@ -151,8 +151,8 @@ function renderSubTabs(container: HTMLElement) { header.parentNode!.insertBefore(tabContainer, header.nextSibling); - const tabPartsMaster = document.getElementById('tab-parts-master')!; - const tabJobSpec = document.getElementById('tab-job-spec')!; + const tabPartsMaster = tabContainer.querySelector('#tab-parts-master')!; + const tabJobSpec = tabContainer.querySelector('#tab-job-spec')!; tabPartsMaster.addEventListener('click', () => { if (activePartsMasterSubTab !== 'parts-master') { From 05e23883b882015625a6d382afbe2538d709428e Mon Sep 17 00:00:00 2001 From: JooWangi Date: Mon, 15 Jun 2026 13:29:10 +0900 Subject: [PATCH 09/13] =?UTF-8?q?feat:=20=EC=A7=81=EB=AC=B4=EB=B3=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=20=EC=82=AC=EC=96=91=20=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=8B=9C=20=EB=B6=80=ED=92=88=20=EB=A7=88=EC=8A=A4=ED=84=B0=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20=EA=B2=80=EC=83=89=20?= =?UTF-8?q?=EB=B0=8F=20=EC=84=B1=EB=8A=A5=20=EC=A0=90=EC=88=98=20=EC=8B=A4?= =?UTF-8?q?=EC=8B=9C=EA=B0=84=20=EC=9E=90=EB=8F=99=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Modal/JobSpecModal.ts | 128 ++++++++++++++++++++++++--- 1 file changed, 118 insertions(+), 10 deletions(-) diff --git a/src/components/Modal/JobSpecModal.ts b/src/components/Modal/JobSpecModal.ts index 2152177..336b384 100644 --- a/src/components/Modal/JobSpecModal.ts +++ b/src/components/Modal/JobSpecModal.ts @@ -2,6 +2,7 @@ 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() { @@ -14,6 +15,39 @@ class JobSpecModal extends BaseModal { return `