From 8129f85071d209a595441e5bce31346efeaf395f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=83=9C=ED=9B=88?= Date: Fri, 26 Jun 2026 17:31:39 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat/refactor:=20=EC=9E=90=EC=82=B0?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B3=A0=EB=8F=84=ED=99=94=20=EB=B0=8F=20UI/UX=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. 컬럼 드래그 너비 조정 버그 수정 및 개선 (ListFactory.ts) - 드래그 완료 시 click 이벤트 전파 차단으로 정렬(sorting) 오작동 방지 - getBoundingClientRect().width 활용한 소수점 정밀 너비 고정 및 레이아웃 시프트 방지 - 마우스 업 시점의 모든 컬럼 너비를 config.columns에 동기화하여 재렌더링 시 너비 영속성 보장 2. PC 자산 모달 필드 잠금 정책 세분화 (HWModal.ts) - 자산 추가(add) 모드에서는 모든 필드(사용자 정보 포함) 입력 허용 - 자산 수정(edit) 모드에서만 사용자/조직 정보 관련 필드(lockedUserFields) 선택적 잠금 적용 - 시스템 사양, 네트워크, 위치, 구매 등 다른 모든 섹션은 수정 가능하도록 복구 및 안내 배너 갱신 3. 관리자 전용 메뉴 단일 페이지 앱(SPA) 통합 (Navigation.ts, main.ts, MapEditor.ts) - 기존의 실사 승인 탭과 독립 실행형 좌표 에디터(MapEditor)를 GNB '관리도구' 하위 메뉴로 통합 - '실사 승인', '위치지정'을 GNB에서 ↳ 화살표 및 11px 폰트의 계층형 탭 스타일로 렌더링 - 내부 서브 탭 바를 삭제하고 메인 영역 전체 높이(calc(100vh - var(--header-height) - 48px))를 확보 - 다른 탭으로 이동 시 MapEditor 인스턴스의 window 이벤트 및 전역 바인딩을 소거하는 destroy() 리사이클 구현 4. 자산 이력(History) 가독성 개선 및 포맷팅 (HWModal.ts, SWModal.ts, DomainModal.ts) - 자산 변경 이력 로그를 일자별로 그룹화하여 타임라인 렌더링 - 최초 등록 데이터에 녹색 '[최초등록]' 배지 추가 - 기존의 생 JSON 이력 데이터를 친절한 한국어 텍스트 포맷으로 가공하여 가독성 극대화 --- src/components/Modal/DomainModal.ts | 52 +++++++++- src/components/Modal/HWModal.ts | 153 +++++++++++++++++++++++++++- src/components/Modal/SWModal.ts | 53 +++++++++- src/components/Navigation.ts | 35 ++++--- src/main.ts | 54 +++++++++- src/views/List/ListFactory.ts | 28 +++-- src/views/MapEditor.ts | 87 +++++++++------- 7 files changed, 397 insertions(+), 65 deletions(-) diff --git a/src/components/Modal/DomainModal.ts b/src/components/Modal/DomainModal.ts index ff51bdb..7e8fdf8 100644 --- a/src/components/Modal/DomainModal.ts +++ b/src/components/Modal/DomainModal.ts @@ -202,7 +202,57 @@ class DomainAssetModal extends BaseModal { if (logs.length === 0) { container.innerHTML = '
이력이 없습니다.
'; } else { - container.innerHTML = logs.map(l => `
${l.log_date || ''}
${l.log_user || '시스템'}
${l.details}
`).join(''); + const createdDate = this.currentAsset?.created_at ? this.currentAsset.created_at.substring(0, 10) : ''; + + const grouped: Record = {}; + logs.forEach(l => { + const date = l.log_date || '날짜 미지정'; + if (!grouped[date]) grouped[date] = []; + grouped[date].push(l); + }); + + container.innerHTML = Object.entries(grouped).map(([date, dateLogs]) => { + const entriesHtml = dateLogs.map((l, idx) => { + const isLast = idx === dateLogs.length - 1; + const borderStyle = isLast ? '' : 'border-bottom: 1px dashed var(--hairline); padding-bottom: 8px; margin-bottom: 8px;'; + + let displayDetails = l.details; + if (l.details && l.details.trim().startsWith('{')) { + try { + const data = JSON.parse(l.details); + if (data.type === 'checkout') { + displayDetails = `[불출] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`; + } else if (data.type === 'return') { + displayDetails = `[반납] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`; + } else if (data.type === 'move') { + displayDetails = `[이동] ${data.user || ''} (${data.dept || ''}) ➔ ${data.targetUser || ''} (${data.targetDept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`; + } + } catch (e) {} + } + + return ` +
+
+ + ${l.log_user || '시스템'} +
+
${displayDetails}
+
+ `; + }).join(''); + + const isInitialReg = date === createdDate; + const regBadge = isInitialReg ? `최초등록` : ''; + + return ` +
+
${date} ${regBadge}
+
+ ${entriesHtml} +
+
+ `; + }).join(''); } } } diff --git a/src/components/Modal/HWModal.ts b/src/components/Modal/HWModal.ts index ec0b4e6..5b78f7f 100644 --- a/src/components/Modal/HWModal.ts +++ b/src/components/Modal/HWModal.ts @@ -98,6 +98,9 @@ class HwAssetModal extends BaseModal {
사용자 및 조직 정보
+
@@ -138,6 +141,10 @@ class HwAssetModal extends BaseModal {
+
+ + +
@@ -309,6 +316,12 @@ class HwAssetModal extends BaseModal { typeSelect.addEventListener('change', () => { this.applyRoleVisibility(); this.updateHeaderIdentity(this.currentAsset); + + if (typeSelect.value === '공용PC') { + setFieldValue('hw-user_current', ''); + setFieldValue('hw-emp_no', ''); + setFieldValue('hw-user_position', '공용PC'); + } }); bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', ''); @@ -320,9 +333,15 @@ class HwAssetModal extends BaseModal { document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => { const cat = categorySelect.value; if (!cat) { alert('구분을 먼저 선택해주세요.'); return; } + + const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || ''; + if (!purchaseDate.trim()) { + alert('구매일자를 먼저 입력해 주세요. 구매일자가 없으면 자산번호를 생성할 수 없습니다.'); + return; + } + const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || ''; const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC'; - const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || ''; try { const res = await fetch(`/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`); const data = await res.json(); @@ -749,7 +768,8 @@ class HwAssetModal extends BaseModal { const hasSpec = specCategories.includes(category) || type.includes('서버PC'); const noNetCategories = ['저장매체', '네트워크', '공간정보장비', 'PC부품', '사무가구']; const showNet = (isInfra || isPersonal) && !noNetCategories.includes(category); - const hasSN = !['사무가구', 'PC부품'].includes(category); + const hasSN = ['외부SW', '내부SW'].includes(category); + const showMainboard = category === 'PC'; const isParts = ['PC부품', '사무가구'].includes(category); const showRemote = category === '서버' || type.includes('서버'); const showServiceType = category === '서버' || type === '서버PC'; @@ -762,9 +782,83 @@ class HwAssetModal extends BaseModal { document.querySelectorAll('.org-user-section, .org-user-field').forEach(el => (el as HTMLElement).style.display = (isPersonal || isParts || category === '업무지원장비') ? '' : 'none'); document.querySelectorAll('.personal-only').forEach(el => (el as HTMLElement).style.display = isPersonal ? '' : 'none'); document.querySelectorAll('.sn-only').forEach(el => (el as HTMLElement).style.display = hasSN ? '' : 'none'); + document.querySelectorAll('.mainboard-only').forEach(el => (el as HTMLElement).style.display = showMainboard ? '' : 'none'); document.querySelectorAll('.monitor-only').forEach(el => (el as HTMLElement).style.display = type.includes('모니터') ? '' : 'none'); document.querySelectorAll('.parts-only').forEach(el => (el as HTMLElement).style.display = isParts ? '' : 'none'); document.querySelectorAll('.hardware-section').forEach(el => (el as HTMLElement).style.display = (hasSpec || isParts) ? '' : 'none'); + + // Lock only User and Organization Information for PC category during edit mode + const isEditMode = this.currentMode === 'edit'; + const isPC = category === 'PC'; + + const noticeEl = document.getElementById('hw-pc-workflow-notice'); + if (noticeEl) { + if (isPC && isEditMode) { + noticeEl.classList.remove('hidden'); + } else { + noticeEl.classList.add('hidden'); + } + } + + const lockedUserFields = [ + 'hw-current_dept', + 'hw-manager_primary', + 'hw-manager_secondary', + 'hw-user_current', + 'hw-emp_no', + 'hw-user_position', + 'hw-previous_user' + ]; + + const allFormControls = this.formEl ? this.formEl.querySelectorAll('input, select, textarea, button') : []; + + allFormControls.forEach(control => { + const el = control as HTMLElement; + const id = el.id; + + if (el.tagName === 'INPUT' && (el as HTMLInputElement).type === 'hidden') return; + if (id === 'hw-asset_code' || id === 'btn-gen-hw-code') return; + + if (isPC && isEditMode && lockedUserFields.includes(id)) { + // Lock user information fields for PC in edit mode + if (el.tagName === 'SELECT') { + el.setAttribute('disabled', 'true'); + } else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { + el.setAttribute('readonly', 'true'); + (el as HTMLInputElement).style.backgroundColor = '#f1f5f9'; + (el as HTMLInputElement).style.cursor = 'not-allowed'; + } else if (el.tagName === 'BUTTON') { + el.setAttribute('disabled', 'true'); + } + } else { + // Normal behavior based on modal edit/view mode (includes add mode which has this.isEditMode = true) + if (!this.isEditMode) { + if (el.tagName === 'SELECT') { + el.setAttribute('disabled', 'true'); + } else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { + el.setAttribute('readonly', 'true'); + (el as HTMLInputElement).style.backgroundColor = ''; + (el as HTMLInputElement).style.cursor = ''; + } else if (el.tagName === 'BUTTON') { + if (id !== 'btn-print-hw-qr' && id !== 'btn-close-hw-modal') { + el.setAttribute('disabled', 'true'); + } + } + } else { + if (el.tagName === 'SELECT') { + el.removeAttribute('disabled'); + } else if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { + if (id !== 'hw-emp_no') { + el.removeAttribute('readonly'); + (el as HTMLInputElement).style.backgroundColor = ''; + (el as HTMLInputElement).style.cursor = ''; + } + } else if (el.tagName === 'BUTTON') { + el.removeAttribute('disabled'); + } + } + } + }); } private updateMapButtonVisibility() { @@ -976,6 +1070,8 @@ class HwAssetModal extends BaseModal { const showList = (filterText: string = '') => { if (!this.isEditMode) return; + const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || ''; + if (category === 'PC') return; const users = state.masterData.users || []; const query = filterText.trim().toLowerCase(); @@ -1053,7 +1149,58 @@ class HwAssetModal extends BaseModal { if (!container) return; const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId); if (logs.length === 0) { container.innerHTML = '
기록된 변동 이력이 없습니다.
'; return; } - container.innerHTML = logs.map(l => `
${l.log_date || ''}
${l.log_user || '시스템'}
${l.details}
`).join(''); + + const createdDate = this.currentAsset?.created_at ? this.currentAsset.created_at.substring(0, 10) : ''; + + const grouped: Record = {}; + logs.forEach(l => { + const date = l.log_date || '날짜 미지정'; + if (!grouped[date]) grouped[date] = []; + grouped[date].push(l); + }); + + container.innerHTML = Object.entries(grouped).map(([date, dateLogs]) => { + const entriesHtml = dateLogs.map((l, idx) => { + const isLast = idx === dateLogs.length - 1; + const borderStyle = isLast ? '' : 'border-bottom: 1px dashed var(--hairline); padding-bottom: 8px; margin-bottom: 8px;'; + + let displayDetails = l.details; + if (l.details && l.details.trim().startsWith('{')) { + try { + const data = JSON.parse(l.details); + if (data.type === 'checkout') { + displayDetails = `[불출] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`; + } else if (data.type === 'return') { + displayDetails = `[반납] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`; + } else if (data.type === 'move') { + displayDetails = `[이동] ${data.user || ''} (${data.dept || ''}) ➔ ${data.targetUser || ''} (${data.targetDept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`; + } + } catch (e) {} + } + + return ` +
+
+ + ${l.log_user || '시스템'} +
+
${displayDetails}
+
+ `; + }).join(''); + + const isInitialReg = date === createdDate; + const regBadge = isInitialReg ? `최초등록` : ''; + + return ` +
+
${date} ${regBadge}
+
+ ${entriesHtml} +
+
+ `; + }).join(''); } private getCategoryKey(asset: any): string { diff --git a/src/components/Modal/SWModal.ts b/src/components/Modal/SWModal.ts index 9ad8731..1f13e1d 100644 --- a/src/components/Modal/SWModal.ts +++ b/src/components/Modal/SWModal.ts @@ -389,7 +389,58 @@ class SwAssetModal extends BaseModal { if (!container) return; const logs = (state.masterData.logs || []).filter(l => l.asset_id === swId); if (logs.length === 0) { container.innerHTML = '
수정 이력이 없습니다.
'; return; } - container.innerHTML = logs.map(l => `
${l.log_date || ''}
${l.log_user || '시스템'}
${l.details}
`).join(''); + + const createdDate = this.currentAsset?.created_at ? this.currentAsset.created_at.substring(0, 10) : ''; + + const grouped: Record = {}; + logs.forEach(l => { + const date = l.log_date || '날짜 미지정'; + if (!grouped[date]) grouped[date] = []; + grouped[date].push(l); + }); + + container.innerHTML = Object.entries(grouped).map(([date, dateLogs]) => { + const entriesHtml = dateLogs.map((l, idx) => { + const isLast = idx === dateLogs.length - 1; + const borderStyle = isLast ? '' : 'border-bottom: 1px dashed var(--hairline); padding-bottom: 8px; margin-bottom: 8px;'; + + let displayDetails = l.details; + if (l.details && l.details.trim().startsWith('{')) { + try { + const data = JSON.parse(l.details); + if (data.type === 'checkout') { + displayDetails = `[불출] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`; + } else if (data.type === 'return') { + displayDetails = `[반납] ${data.user || ''} (${data.dept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`; + } else if (data.type === 'move') { + displayDetails = `[이동] ${data.user || ''} (${data.dept || ''}) ➔ ${data.targetUser || ''} (${data.targetDept || ''}) ${data.memo ? `| 메모: ${data.memo}` : ''}`; + } + } catch (e) {} + } + + return ` +
+
+ + ${l.log_user || '시스템'} +
+
${displayDetails}
+
+ `; + }).join(''); + + const isInitialReg = date === createdDate; + const regBadge = isInitialReg ? `최초등록` : ''; + + return ` +
+
${date} ${regBadge}
+
+ ${entriesHtml} +
+
+ `; + }).join(''); } } diff --git a/src/components/Navigation.ts b/src/components/Navigation.ts index 300c4bc..a89bb0e 100644 --- a/src/components/Navigation.ts +++ b/src/components/Navigation.ts @@ -65,7 +65,7 @@ export function renderNavigation(onTabChange: (tab: string) => void) { }); if (state.currentUserRole === 'admin' && catKey === 'hw') { - visibleTabs = ['대시보드', '실사 승인']; + visibleTabs = ['대시보드', '관리도구', '실사 승인', '위치지정']; } if (visibleTabs.length === 0) return; @@ -75,29 +75,36 @@ export function renderNavigation(onTabChange: (tab: string) => void) { const item = document.createElement('div'); const isActive = state.activeSubTab === tab; item.className = `gnb-trigger ${isActive ? 'active' : ''}`; - item.textContent = tab; - item.style.fontSize = 'var(--fs-sm)'; // Ensure small but standard font + + const isSubMenu = tab === '실사 승인' || tab === '위치지정'; + if (isSubMenu) { + item.innerHTML = `${tab}`; + item.style.fontSize = '11px'; + item.style.fontWeight = '500'; + item.style.marginLeft = '6px'; + if (!isActive) { + item.style.color = 'var(--mute)'; + } + } else { + item.textContent = tab; + item.style.fontSize = 'var(--fs-sm)'; + } item.addEventListener('click', (e) => { e.stopPropagation(); state.activeCategory = catKey as any; - state.activeSubTab = tab; + if (tab === '관리도구') { + state.activeSubTab = '실사 승인'; + } else { + state.activeSubTab = tab; + } render(); - onTabChange(tab); + onTabChange(state.activeSubTab); }); navList.appendChild(item); }); }); - // 3. 관리자 전용 '관리도구' - if (state.currentUserRole === 'admin') { - const adminTrigger = document.createElement('div'); - adminTrigger.className = 'gnb-trigger admin-trigger'; - adminTrigger.innerHTML = '관리도구'; - adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank')); - navList.appendChild(adminTrigger); - } - // 4. 이벤트 바인딩 document.getElementById('btn-home-logo')?.addEventListener('click', () => location.reload()); diff --git a/src/main.ts b/src/main.ts index 1211b1b..8131dff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,6 +6,7 @@ import { renderDashboard } from './views/DashboardView'; import { renderSWTable } from './views/SW_Table'; import { renderLocationView } from './views/LocationView'; import { renderAuditApprovalView } from './views/AuditApprovalView'; +import { MapEditor } from './views/MapEditor'; import { initBaseModal } from './components/Modal/BaseModal'; import { initHwModal, openHwModal } from './components/Modal/HWModal'; import { initSwModal, openSwModal } from './components/Modal/SWModal'; @@ -21,11 +22,19 @@ import { pcFlowModal } from './components/Modal/PCFlowModal'; import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Laptop, CalendarClock, Key, Cpu, Layers, Users, Paperclip, Edit2, History, RefreshCcw, BookOpen, Settings } from 'lucide'; +let activeMapEditorInstance: MapEditor | null = null; + // 화면 갱신 통합 핸들러 -function refreshView(tab?: string) { +async function refreshView(tab?: string) { const mainContent = document.getElementById('main-content')!; if (!mainContent) return; + // Clean up any active MapEditor instance when navigating away + if (activeMapEditorInstance) { + activeMapEditorInstance.destroy(); + activeMapEditorInstance = null; + } + const activeTab = tab || state.activeSubTab; if (activeTab === '대시보드') { @@ -34,7 +43,48 @@ function refreshView(tab?: string) { } if (activeTab === '실사 승인') { - renderAuditApprovalView(mainContent); + await renderAuditApprovalView(mainContent); + return; + } + + if (activeTab === '위치지정') { + // Render Map Editor directly into main content to maximize working area + mainContent.innerHTML = ` +
+ +
+ + +
+
+ Map Image +
+
+ + + +
+ `; + + // Initialize MapEditor instance + const editor = new MapEditor(); + await editor.init(); + activeMapEditorInstance = editor; return; } diff --git a/src/views/List/ListFactory.ts b/src/views/List/ListFactory.ts index 2fa5b2b..fc8a5a1 100644 --- a/src/views/List/ListFactory.ts +++ b/src/views/List/ListFactory.ts @@ -708,7 +708,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { function makeColumnsResizable(tableElement: HTMLTableElement) { const headers = tableElement.querySelectorAll('th'); - headers.forEach(th => { + headers.forEach((th, index) => { const resizer = th.querySelector('.resizer') as HTMLElement; if (!resizer) return; @@ -733,28 +733,44 @@ export function createListView(container: HTMLElement, config: ListViewConfig) { resizer.classList.remove('resizing'); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); + + // Save the widths of all columns back to the config so they persist on re-render + headers.forEach((hdr, idx) => { + if (config.columns[idx]) { + config.columns[idx].width = hdr.style.width; + } + }); }; resizer.addEventListener('mousedown', (e: MouseEvent) => { - // Prevents header click sorting trigger from firing + // Prevents header click sorting trigger from firing on mousedown e.stopPropagation(); e.preventDefault(); - // Freeze all columns at their current pixel width before dragging + // Freeze all columns at their current precise pixel width before dragging headers.forEach(header => { - header.style.width = `${header.offsetWidth}px`; + header.style.width = `${header.getBoundingClientRect().width}px`; }); + // Freeze the table at its current precise pixel width immediately + tableElement.style.width = `${tableElement.getBoundingClientRect().width}px`; + startX = e.clientX; - startWidth = th.offsetWidth; + startWidth = th.getBoundingClientRect().width; // Capture the initial physical width of the entire table - startTableWidth = tableElement.offsetWidth; + startTableWidth = tableElement.getBoundingClientRect().width; resizer.classList.add('resizing'); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }); + + // Prevents header click sorting trigger from firing on mouseup/click + resizer.addEventListener('click', (e: MouseEvent) => { + e.stopPropagation(); + e.preventDefault(); + }); }); } diff --git a/src/views/MapEditor.ts b/src/views/MapEditor.ts index 448ba28..1ebe054 100644 --- a/src/views/MapEditor.ts +++ b/src/views/MapEditor.ts @@ -1,6 +1,7 @@ import { IMAGE_LOCATIONS } from '../components/Modal/SharedData'; import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide'; import { QRPrinter } from '../core/qr_print'; +import './map-editor.css'; export class MapEditor { private container: HTMLElement; @@ -114,6 +115,45 @@ export class MapEditor { this.render(); } + private onWindowMouseMove = (e: MouseEvent) => { + if (!this.isDrawing || !this.currentBox) return; + const rect = this.wrapper.getBoundingClientRect(); + const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); + const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height)); + + const width = currentX - this.startX; + const height = currentY - this.startY; + + this.currentBox.style.width = Math.abs(width) + 'px'; + this.currentBox.style.height = Math.abs(height) + 'px'; + this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px'; + this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px'; + }; + + private onWindowMouseUp = () => { + if (!this.isDrawing || !this.currentBox) return; + this.isDrawing = false; + + const width = parseFloat(this.currentBox.style.width); + const height = parseFloat(this.currentBox.style.height); + + if (width > 3 && height > 3) { + const rect = this.wrapper.getBoundingClientRect(); + const boxData = { + x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2), + y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2), + w: (width / rect.width * 100).toFixed(2), + h: (height / rect.height * 100).toFixed(2), + asset_id: null + }; + this.boxes.push(boxData); + this.render(); + } + + this.currentBox.remove(); + this.currentBox = null; + }; + private bindEvents() { this.wrapper.addEventListener('mousedown', (e) => { if (e.button !== 0) return; @@ -135,44 +175,8 @@ export class MapEditor { this.wrapper.appendChild(this.currentBox); }); - window.addEventListener('mousemove', (e) => { - if (!this.isDrawing || !this.currentBox) return; - const rect = this.wrapper.getBoundingClientRect(); - const currentX = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); - const currentY = Math.max(0, Math.min(e.clientY - rect.top, rect.height)); - - const width = currentX - this.startX; - const height = currentY - this.startY; - - this.currentBox.style.width = Math.abs(width) + 'px'; - this.currentBox.style.height = Math.abs(height) + 'px'; - this.currentBox.style.left = (width > 0 ? this.startX : currentX) + 'px'; - this.currentBox.style.top = (height > 0 ? this.startY : currentY) + 'px'; - }); - - window.addEventListener('mouseup', () => { - if (!this.isDrawing || !this.currentBox) return; - this.isDrawing = false; - - const width = parseFloat(this.currentBox.style.width); - const height = parseFloat(this.currentBox.style.height); - - if (width > 3 && height > 3) { - const rect = this.wrapper.getBoundingClientRect(); - const boxData = { - x: (parseFloat(this.currentBox.style.left) / rect.width * 100).toFixed(2), - y: (parseFloat(this.currentBox.style.top) / rect.height * 100).toFixed(2), - w: (width / rect.width * 100).toFixed(2), - h: (height / rect.height * 100).toFixed(2), - asset_id: null - }; - this.boxes.push(boxData); - this.render(); - } - - this.currentBox.remove(); - this.currentBox = null; - }); + window.addEventListener('mousemove', this.onWindowMouseMove); + window.addEventListener('mouseup', this.onWindowMouseUp); (window as any).removeBox = (index: number) => { this.boxes.splice(index, 1); @@ -341,6 +345,13 @@ export class MapEditor { }]); }; } + + public destroy() { + window.removeEventListener('mousemove', this.onWindowMouseMove); + window.removeEventListener('mouseup', this.onWindowMouseUp); + delete (window as any).removeBox; + delete (window as any).printBoxQR; + } } function getCleanMapKey(path: string) { From dea6beee0b119049097a897768463565deba66f0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=83=9C=ED=9B=88?= Date: Mon, 29 Jun 2026 15:27:17 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=EB=B6=80=ED=92=88=20=EB=A7=88?= =?UTF-8?q?=EC=8A=A4=ED=84=B0=20=EC=84=9C=EB=B8=8C=20=ED=83=AD=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EB=B0=8F=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20QR=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=A0=91=EA=B7=BC=20=EC=8B=9C=20Nginx=20?= =?UTF-8?q?=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EC=98=A4=EB=A5=98=20=ED=95=B4?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부품 마스터 페이지 내 상단 헤더(.page-header)가 존재하지 않는 경우에도 '부품 표준 등급' 및 '직무별 기준 사양' 탭이 최상단에 안전하게 표시되도록 보완 - 모바일 QR 스캔 시 확장자 없는 가상 경로(/mobile) 요청이 데스크톱 메인 페이지(/index.html)로 Fallback되던 현상을 수정하기 위해 Nginx 설정에 내부 rewrite 규칙 추가 - 에이전트 개발 규칙 파일(.agents/AGENTS.md) 등록 --- .agents/AGENTS.md | 31 +++++++++++++++++++++++++++ docker/frontend/default.conf | 1 + src/views/List/PartsMasterListView.ts | 10 +++++---- 3 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 .agents/AGENTS.md diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 0000000..16d72c3 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,31 @@ +# Development Rules (개발 및 관리 규칙) + +1. **언어 설정**: 영어로 생각하되, 모든 답변은 **한국어**로 작성한다. +2. **임의 수정 절대 금지 (Zero-Arbitrary Change)**: + - 사용자가 명시적으로 지시한 부분 외에는 **단 한 줄의 코드도, 그 어떤 파일도 임의로 수정, 정리, 리팩토링하지 않는다.** + - 지시받지 않은 다른 파트의 코드는 절대 건드리지 않으며, 영향 범위가 요청 범위를 벗어나지 않도록 '외과 수술식(Surgical) 수정'을 원칙으로 한다. +3. **개선 작업 절차 (Test-First Approach)**: + - 사용자가 개선(Refactoring, Optimization 등)을 지시한 경우, **수정 전 현재 시스템이 정상적으로 잘 작동하는지 먼저 전수 확인**한다. + - 기존 동작 방식과 성능을 기준(Baseline)으로 삼고, 수정 후에도 **기존의 모든 기능이 무결하게 유지되는지 반드시 테스트하여 입증**한다. + - 검증 결과를 바탕으로 "무엇을, 왜, 어떻게" 바꿀지 상세 보고 후, 사용자로부터 **'진행시켜'** 승인을 얻은 뒤에만 집행한다. +4. **선보고 후승인**: 모든 기능 수정 및 코드 변경 전에는 예상 방안을 먼저 보고하고 승인 절차를 거친다. +5. **DB 삭제 및 초기화 절대 엄금 (Strict DB Deletion Policy)**: + - 어떠한 경우에도 `DELETE`, `DROP`, `TRUNCATE` 등 데이터를 삭제하거나 테이블을 초기화하는 작업은 사전에 사용자에게 상세 사유를 보고하고 **명시적 승인**을 얻은 후에만 시행한다. + - 기존 데이터의 가치를 최우선으로 하며, 작업 전 백업 여부를 반드시 확인한다. +6. **RED–GREEN–Refactor 개발 원칙**: + - 모든 기능 개발과 버그 수정은 **RED → GREEN → Refactor** 순서로 진행한다. + - **RED**: 요구사항을 명확히 표현하는 테스트를 먼저 작성하고, 해당 테스트가 기능 미구현 또는 결함으로 인해 실패하는지 확인한다. + - **GREEN**: 실패한 테스트를 통과시키는 데 필요한 최소한의 코드만 구현하며, 불필요한 기능 추가나 구조 변경을 하지 않는다. + - **Refactor**: 관련 테스트와 기존 테스트가 모두 통과하는 상태에서만 중복 제거, 명칭 개선, 책임 분리 등 코드 구조를 개선하며 동작은 변경하지 않는다. + - 각 단계가 끝날 때마다 관련 테스트와 기존 기능의 회귀 여부를 검증한다. + - 테스트 작성이 현실적으로 불가능한 경우에는 그 사유와 대체 검증 방법을 먼저 보고하고 승인을 받은 후 진행한다. + - 본 원칙을 적용할 때에도 기존의 **선보고 후승인** 및 **외과 수술식 수정** 규칙을 준수한다. + +# Server Run & External Access (서버 구동 및 외부 접속 규칙) + +1. **포트 고정**: 개발 서버는 반드시 **8080** 포트를 사용한다. (`vite.config.ts` 설정 준수) +2. **외부 접속 허용 (Host)**: 사무실 내 타 직원이 접속할 수 있도록 `--host` 모드로 구동한다. +3. **구동 명령어**: `npm run dev` + - 해당 명령어 실행 시 `0.0.0.0` 또는 `Network: http://[내-IP]:8080/` 경로로 타인 접속이 가능하다. +4. **IP 확인 방법**: + - Windows: `ipconfig` 명령어로 'IPv4 주소' 확인 후 공유. diff --git a/docker/frontend/default.conf b/docker/frontend/default.conf index d7f2a4f..7aa5cab 100644 --- a/docker/frontend/default.conf +++ b/docker/frontend/default.conf @@ -24,6 +24,7 @@ server { # Serve static files with SPA fallback location / { + rewrite ^/mobile$ /mobile.html last; try_files $uri $uri/ /index.html; } diff --git a/src/views/List/PartsMasterListView.ts b/src/views/List/PartsMasterListView.ts index 84d0ee4..625c5dc 100644 --- a/src/views/List/PartsMasterListView.ts +++ b/src/views/List/PartsMasterListView.ts @@ -130,9 +130,6 @@ export function renderPartsMasterList(container: HTMLElement) { } function renderSubTabs(container: HTMLElement) { - const header = container.querySelector('.page-header'); - if (!header) return; - // 기존에 생성된 탭 바가 있다면 제거하여 중복 방지 (스타일만 수정하는 최소 침습 방식) const existingTabs = container.querySelector('.sub-tab-container'); if (existingTabs) existingTabs.remove(); @@ -153,7 +150,12 @@ function renderSubTabs(container: HTMLElement) { `; - header.parentNode!.insertBefore(tabContainer, header.nextSibling); + const header = container.querySelector('.page-header'); + if (header) { + header.parentNode!.insertBefore(tabContainer, header.nextSibling); + } else { + container.insertBefore(tabContainer, container.firstChild); + } const tabPartsMaster = tabContainer.querySelector('#tab-parts-master')!; const tabJobSpec = tabContainer.querySelector('#tab-job-spec')!; From 578196d9d43f6a588863ca42727cf40e730d5e9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=83=9C=ED=9B=88?= Date: Mon, 29 Jun 2026 15:33:46 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20CI/CD=20=EB=B0=B0=ED=8F=AC=20?= =?UTF-8?q?=EC=8B=9C=20Nginx=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=20?= =?UTF-8?q?=EC=9E=90=EB=8F=99=20=EC=9E=AC=EC=8B=9C=EC=9E=91=20=EA=B5=AC?= =?UTF-8?q?=EB=AC=B8=20=EC=B6=94=EA=B0=80=20(502=20Bad=20Gateway=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EB=B0=A9=EC=A7=80)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/itam_production_deploy.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitea/workflows/itam_production_deploy.yml b/.gitea/workflows/itam_production_deploy.yml index 05698fc..013bac8 100644 --- a/.gitea/workflows/itam_production_deploy.yml +++ b/.gitea/workflows/itam_production_deploy.yml @@ -117,7 +117,7 @@ jobs: scp .env.deploy "${PROD_USER}@${PROD_HOST}:${PROD_DEPLOY_PATH}/.env" - ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && chmod 600 .env && docker compose -f docker-compose.prod.yaml config && docker compose -f docker-compose.prod.yaml up -d --build" + ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && chmod 600 .env && docker compose -f docker-compose.prod.yaml config && docker compose -f docker-compose.prod.yaml up -d --build && docker compose -f docker-compose.prod.yaml restart nginx" - name: Post-deploy status check env: From 10e27c009640d48c4f89c8ee8335b3964a0d5ff6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=83=9C=ED=9B=88?= Date: Mon, 29 Jun 2026 15:39:25 +0900 Subject: [PATCH 4/6] =?UTF-8?q?fix:=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=95=B1=20=EC=8A=A4=EC=BA=90=EB=84=88=EB=A1=9C=20QR?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C=20=EC=8A=A4=EC=BA=94=20=EC=8B=9C=20URL=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=EC=9E=90=EB=8F=99=20=ED=8C=8C=EC=8B=B1=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 인앱 스캐너 카메라로 스캔한 결과에 웹 주소(URL)가 포함되어 있는 경우, 쿼리 스트링 파라미터에서 순수 위치 코드(loc) 및 자산 코드(asset) 값을 자동으로 추출하여 서버로 전송하도록 수정 --- src/mobile-main.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/mobile-main.ts b/src/mobile-main.ts index 51cfdb6..48d0e9f 100644 --- a/src/mobile-main.ts +++ b/src/mobile-main.ts @@ -81,7 +81,21 @@ document.addEventListener('DOMContentLoaded', () => { function processScannedCode(rawCode: string) { // QR 코드 인쇄 폼 등으로 인한 개행 문자(\r, \n) 및 모든 공백 문자(\s)를 제거 - const code = rawCode.replace(/[\r\n]/g, '').replace(/\s+/g, '').trim(); + let code = rawCode.replace(/[\r\n]/g, '').replace(/\s+/g, '').trim(); + + // 만약 스캔된 텍스트가 전체 URL 주소 형식이라면 파라미터 값만 추출하여 정제 + if (code.includes('http://') || code.includes('https://') || code.includes('/mobile')) { + try { + const urlObj = new URL(code, window.location.origin); + const locParam = urlObj.searchParams.get('loc'); + const assetParam = urlObj.searchParams.get('asset'); + + if (locParam) code = locParam; + else if (assetParam) code = assetParam; + } catch (e) { + console.error("URL 파싱 에러:", e); + } + } // 1. Check if the code is a physical location code if (code.startsWith('LOC-')) { From 615523fb9030663419ef59a2abd5ab3cac491cb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=ED=83=9C=ED=9B=88?= Date: Mon, 29 Jun 2026 16:50:04 +0900 Subject: [PATCH 5/6] =?UTF-8?q?=EC=9E=90=EC=82=B0=20=EC=83=81=EC=84=B8=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EC=88=98=EC=A0=95=20=EC=B7=A8=EC=86=8C=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95,=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20QR=20=EC=8A=A4=EC=BA=94=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20=EB=AA=A8=EB=8B=AC=20=EB=8F=84=EC=9E=85,?= =?UTF-8?q?=20=EB=B6=80=ED=92=88=20=EB=A7=88=EC=8A=A4=ED=84=B0=20=ED=95=84?= =?UTF-8?q?=ED=84=B0/=EC=A0=95=EB=A0=AC=20=EA=B0=9C=EC=84=A0,=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=EB=A7=88=EC=8A=A4=ED=84=B0=20GNB=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20=ED=95=98=EC=9C=84=20=EB=A9=94=EB=89=B4?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=B4=EB=8F=99=20=EB=B0=8F=20=EA=B7=B8=EB=9D=BC?= =?UTF-8?q?=ED=8C=8C=EB=82=98=20=EA=B2=80=ED=86=A0=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EB=AC=B4=EC=8B=9C=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + mobile.html | 22 +++++++++++++ src/components/Modal/DomainModal.ts | 1 + src/components/Modal/HWModal.ts | 7 ++++ src/components/Modal/JobSpecModal.ts | 1 + src/components/Modal/PartsMasterModal.ts | 1 + src/components/Modal/SWModal.ts | 1 + src/components/Modal/UserModal.ts | 1 + src/components/Navigation.ts | 7 ++-- src/core/filterHandler.ts | 42 ++++++++++++++++++++---- src/mobile-main.ts | 41 +++++++++++++++++++++-- src/views/List/ListFactory.ts | 12 ++++++- src/views/List/PartsMasterListView.ts | 5 ++- 13 files changed, 127 insertions(+), 15 deletions(-) diff --git a/.gitignore b/.gitignore index ad4db5d..df4b3f8 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ Thumbs.db backups/ mysql_data/ +/docs/grafana_integration_proposal.md diff --git a/mobile.html b/mobile.html index 8050aa3..f144251 100644 --- a/mobile.html +++ b/mobile.html @@ -294,6 +294,28 @@
+ + + + + diff --git a/src/components/Modal/DomainModal.ts b/src/components/Modal/DomainModal.ts index 7e8fdf8..9f79459 100644 --- a/src/components/Modal/DomainModal.ts +++ b/src/components/Modal/DomainModal.ts @@ -133,6 +133,7 @@ class DomainAssetModal extends BaseModal { revertBtn.addEventListener('click', () => { this.setEditLockMode('view'); + this.isEditMode = false; if (this.currentAsset) this.fillFormData(this.currentAsset); }); diff --git a/src/components/Modal/HWModal.ts b/src/components/Modal/HWModal.ts index 5b78f7f..e14320f 100644 --- a/src/components/Modal/HWModal.ts +++ b/src/components/Modal/HWModal.ts @@ -272,6 +272,7 @@ class HwAssetModal extends BaseModal { protected initChildLogic(onSave: () => void, closeModals: () => void): void { const saveBtn = document.getElementById('btn-save-hw-asset')!; + const revertBtn = document.getElementById('btn-revert-hw-edit')!; const deleteBtn = document.getElementById('btn-delete-hw-asset')!; const categorySelect = document.getElementById('hw-category') as HTMLSelectElement; const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement; @@ -412,6 +413,12 @@ class HwAssetModal extends BaseModal { } }); + revertBtn.addEventListener('click', () => { + if (this.currentAsset) { + this.open(this.currentAsset, 'view'); + } + }); + saveBtn.addEventListener('click', async () => { if (!this.currentAsset) return; diff --git a/src/components/Modal/JobSpecModal.ts b/src/components/Modal/JobSpecModal.ts index b290cd7..b38b958 100644 --- a/src/components/Modal/JobSpecModal.ts +++ b/src/components/Modal/JobSpecModal.ts @@ -144,6 +144,7 @@ class JobSpecModal extends BaseModal { revertBtn.addEventListener('click', () => { this.setEditLockMode('view'); + this.isEditMode = false; if (this.currentAsset) this.fillFormData(this.currentAsset); }); diff --git a/src/components/Modal/PartsMasterModal.ts b/src/components/Modal/PartsMasterModal.ts index b3c7061..58babac 100644 --- a/src/components/Modal/PartsMasterModal.ts +++ b/src/components/Modal/PartsMasterModal.ts @@ -101,6 +101,7 @@ class PartsMasterModal extends BaseModal { revertBtn.addEventListener('click', () => { this.setEditLockMode('view'); + this.isEditMode = false; if (this.currentAsset) this.fillFormData(this.currentAsset); }); diff --git a/src/components/Modal/SWModal.ts b/src/components/Modal/SWModal.ts index 1f13e1d..9da0725 100644 --- a/src/components/Modal/SWModal.ts +++ b/src/components/Modal/SWModal.ts @@ -278,6 +278,7 @@ class SwAssetModal extends BaseModal { revertBtn.addEventListener('click', () => { this.setEditLockMode('view'); + this.isEditMode = false; if (this.currentAsset) this.fillFormData(this.currentAsset); }); diff --git a/src/components/Modal/UserModal.ts b/src/components/Modal/UserModal.ts index 5a60419..420223d 100644 --- a/src/components/Modal/UserModal.ts +++ b/src/components/Modal/UserModal.ts @@ -107,6 +107,7 @@ class UserModal extends BaseModal { revertBtn.addEventListener('click', () => { this.setEditLockMode('view'); + this.isEditMode = false; if (this.currentAsset) this.fillFormData(this.currentAsset); }); diff --git a/src/components/Navigation.ts b/src/components/Navigation.ts index a89bb0e..aa53723 100644 --- a/src/components/Navigation.ts +++ b/src/components/Navigation.ts @@ -3,7 +3,7 @@ import { state } from '../core/state'; const MENU_CONFIG: any = { hw: { label: '하드웨어', - tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '부품 마스터', '네트워크', '업무지원장비'] + tabs: ['대시보드', '서버', 'PC', '스토리지', '공간정보장비', 'PC부품', '네트워크', '업무지원장비'] }, sw: { label: '소프트웨어', @@ -65,18 +65,17 @@ export function renderNavigation(onTabChange: (tab: string) => void) { }); if (state.currentUserRole === 'admin' && catKey === 'hw') { - visibleTabs = ['대시보드', '관리도구', '실사 승인', '위치지정']; + visibleTabs = ['대시보드', '관리도구', '실사 승인', '위치지정', '부품 마스터']; } if (visibleTabs.length === 0) return; visibleTabs.forEach((tab: string) => { - if (tab === '부품 마스터') return; const item = document.createElement('div'); const isActive = state.activeSubTab === tab; item.className = `gnb-trigger ${isActive ? 'active' : ''}`; - const isSubMenu = tab === '실사 승인' || tab === '위치지정'; + const isSubMenu = tab === '실사 승인' || tab === '위치지정' || tab === '부품 마스터'; if (isSubMenu) { item.innerHTML = `${tab}`; item.style.fontSize = '11px'; diff --git a/src/core/filterHandler.ts b/src/core/filterHandler.ts index 8d18954..1153640 100644 --- a/src/core/filterHandler.ts +++ b/src/core/filterHandler.ts @@ -15,6 +15,8 @@ export interface FilterOptions { showField?: boolean; showType?: boolean; showStatus?: boolean; + showPartCategory?: boolean; + showPartTier?: boolean; extraHTML?: string; onFilterChange: (filters: any) => void; initialFilters?: any; @@ -37,9 +39,11 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions) showField = false, showType = false, showStatus = false, + showPartCategory = false, + showPartTier = false, extraHTML = '', onFilterChange, - initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '' }, + initialFilters = { keyword: '', corp: '', dept: '', loc: '', field: '', type: '', status: '', partCategory: '', partTier: '' }, fullList = [] } = options; @@ -104,6 +108,22 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions) ${getUnique('CURRENT_DEPT').map(v => ``).join('')} ` : ''} + ${showPartCategory ? ` +
+ + +
` : ''} + ${showPartTier ? ` +
+ + +
` : ''} ${extraHTML}