import { state, saveAsset, deleteAsset } from '../../core/state'; import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { calculatePcScoreDeductive, getPcGrade } from '../../core/utils'; import { generateOptionsHTML, setFieldValue, getFieldValue, parseAndSetLocation, bindLocationEvents, applyDateMask } from './ModalUtils'; import { CORP_LIST, LOCATION_DATA, CATEGORY_TYPE_MAP, HW_STATUS_LIST, ORG_LIST, IMAGE_LOCATIONS, TYPE_PREFIX_MAP } from './SharedData'; import { BaseModal } from './BaseModal'; class HwAssetModal extends BaseModal { private dynamicMapConfig: Record = {}; private masterComponents: any[] = []; constructor() { super('hw', '자산 상세 정보'); } protected renderFrameHTML(): string { const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;'; const inputStyle = sharedStyle; const btnStyle = `padding: 0 16px; display: inline-flex; align-items: center; justify-content: center; font-weight: 600; white-space: nowrap; cursor: pointer; ${sharedStyle}`; return ` `; } 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; const bldgSelect = document.getElementById('hw-bldg-select') as HTMLSelectElement; const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement; bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', ''); applyDateMask(document.getElementById('hw-purchase_date') as HTMLInputElement); this.fetchMasterComponents().then(() => { this.bindAutocomplete('hw-cpu', 'hw-cpu-autocomplete', 'CPU'); this.bindAutocomplete('hw-ram', 'hw-ram-autocomplete', 'RAM'); this.bindAutocomplete('hw-gpu', 'hw-gpu-autocomplete', 'GPU'); }); const specInputs = ['hw-cpu', 'hw-ram', 'hw-gpu', 'hw-purchase_date']; specInputs.forEach(id => { document.getElementById(id)?.addEventListener('input', () => this.updatePcGradeBadge()); document.getElementById(id)?.addEventListener('change', () => this.updatePcGradeBadge()); }); categorySelect.addEventListener('change', () => { const types = CATEGORY_TYPE_MAP[categorySelect.value] || []; typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, '', true) : ''; this.applyRoleVisibility(); }); typeSelect.addEventListener('change', () => { this.applyRoleVisibility(); }); document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => { const type = typeSelect.value; const cat = categorySelect.value; if (!type) { alert('유형을 먼저 선택해주세요.'); return; } const purchaseDateEl = document.getElementById('hw-purchase_date') as HTMLInputElement; const purchaseDate = purchaseDateEl?.value || ''; if (!purchaseDate) { alert('구매일자를 먼저 입력해야 자산번호 생성이 가능합니다.'); purchaseDateEl?.focus(); return; } // 유형 기반 매핑 우선, 없으면 구분 기반, 그래도 없으면 ETC const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC'; try { const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`); const data = await res.json(); if (data.nextCode) setFieldValue('hw-asset_code', data.nextCode); } catch (err) { console.error('코드 생성 실패:', err); } }); bldgSelect.addEventListener('change', () => setTimeout(() => this.updateMapButtonVisibility(), 100)); detailSelect.addEventListener('change', () => this.updateMapButtonVisibility()); document.getElementById('btn-reg-loc-map')?.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); await this.fetchMapConfig(); const images = this.getImagesForLocation(bldgSelect.value, detailSelect.value); if (images) this.openImagePicker(images, `${detailSelect.value} 위치 등록`); }); document.getElementById('btn-view-loc-map')?.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); await this.fetchMapConfig(); const x = getFieldValue('hw-loc_x'); const y = getFieldValue('hw-loc_y'); const savedImg = getFieldValue('hw-location_photo'); const bldg = getFieldValue('hw-bldg-select'); const detail = getFieldValue('hw-location_detail'); const images = this.getImagesForLocation(bldg, detail); if (images) { const imgPath = savedImg && images.includes(savedImg) ? savedImg : images[0]; this.openImagePreview(imgPath, `${detail} 위치 확인`, x, y); } }); deleteBtn.addEventListener('click', async () => { if (!this.currentAsset || !confirm(UI_TEXT.MESSAGES.CONFIRM_DELETE)) return; if (await deleteAsset(this.getCategoryKey(this.currentAsset), this.currentAsset.id)) { alert('성공적으로 삭제되었습니다.'); onSave(); this.close(); closeModals(); } }); revertBtn.addEventListener('click', () => { this.isEditMode = false; this.setEditLockMode('view'); if (this.currentAsset) this.fillFormData(this.currentAsset); this.updateMapButtonVisibility(); this.toggleEditOnlyBtns(false); }); // 동적 기능 이벤트 연결 document.getElementById('btn-add-volume')?.addEventListener('click', () => this.addVolumeRow()); document.getElementById('btn-add-remote-info')?.addEventListener('click', () => this.addRemoteInfoRow()); const fileInput = document.getElementById('hw-approval_document_file') as HTMLInputElement; const fileNameDisplay = document.getElementById('hw-file-name-display'); const fileLinkContainer = document.getElementById('hw-file-link-container'); fileInput?.addEventListener('change', async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; if (fileNameDisplay) fileNameDisplay.textContent = file.name; const reader = new FileReader(); reader.onload = async () => { try { const res = await fetch(`http://${location.hostname}:3000/api/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName: file.name, fileData: reader.result }) }); const data = await res.json(); if (data.success) { setFieldValue('hw-approval_document', data.filePath); if (fileLinkContainer) { fileLinkContainer.innerHTML = `[업로드 완료: 파일 보기]`; } } } catch (err) { console.error('파일 업로드 실패:', err); alert('파일 업로드 중 오류가 발생했습니다.'); } }; reader.readAsDataURL(file); }); saveBtn.addEventListener('click', async () => { if (!this.currentAsset) return; if (!this.isEditMode) { this.setEditLockMode('edit'); this.isEditMode = true; this.updateMapButtonVisibility(); this.toggleFileUploadUI(true); this.toggleEditOnlyBtns(true); return; } // CPU, RAM, GPU 마스터 테이블 기반 유효성 검사 (완전 강제 방식) const category = categorySelect.value; const type = typeSelect.value; const specCategories = ['PC', '서버', '노트북', '스토리지', '워크스테이션']; const hasSpec = specCategories.includes(category) || type.includes('서버PC'); if (hasSpec) { const cpuVal = (document.getElementById('hw-cpu') as HTMLInputElement)?.value || ''; const ramVal = (document.getElementById('hw-ram') as HTMLInputElement)?.value || ''; const gpuVal = (document.getElementById('hw-gpu') as HTMLInputElement)?.value || ''; const cpuMaster = this.masterComponents.filter(c => c.category === 'CPU').map(c => c.component_name); const ramMaster = this.masterComponents.filter(c => c.category === 'RAM').map(c => c.component_name); const gpuMaster = this.masterComponents.filter(c => c.category === 'GPU').map(c => c.component_name); if (cpuVal && !cpuMaster.includes(cpuVal)) { alert(`[입력 오류] '${cpuVal}'은(는) 마스터 테이블에 존재하지 않는 CPU 부품명입니다. 자동완성 추천 목록에서 올바른 부품명을 골라 선택해 주세요.`); return; } if (ramVal && !ramMaster.includes(ramVal)) { alert(`[입력 오류] '${ramVal}'은(는) 마스터 테이블에 존재하지 않는 RAM 부품명입니다. 자동완성 추천 목록에서 올바른 부품명을 골라 선택해 주세요.`); return; } if (gpuVal && !gpuMaster.includes(gpuVal)) { alert(`[입력 오류] '${gpuVal}'은(는) 마스터 테이블에 존재하지 않는 GPU 부품명입니다. 자동완성 추천 목록에서 올바른 부품명을 골라 선택해 주세요.`); return; } } // 동적 볼륨 데이터 수집 const vols: any[] = []; document.querySelectorAll('#hw-volume-container .volume-row').forEach((row, idx) => { const type = (row.querySelector('.vol-type') as HTMLSelectElement).value; const cap = (row.querySelector('.vol-cap') as HTMLInputElement).value; const unit = (row.querySelector('.vol-unit') as HTMLSelectElement).value; if (cap) vols.push({ type, capacity: parseFloat(cap), unit, slot: idx + 1 }); }); setFieldValue('hw-volumes-data', JSON.stringify(vols)); // 동적 네트워크/원격 데이터 수집 const nets: any[] = []; document.querySelectorAll('#hw-remote-info-container .remote-info-row').forEach(row => { const type = (row.querySelector('.ri-type') as HTMLSelectElement).value; const val1 = (row.querySelector('.ri-val1') as HTMLInputElement).value; if (type === 'IP' && val1) { const tool = (row.querySelector('.ri-tool') as HTMLSelectElement)?.value || ''; const id = (row.querySelector('.ri-id') as HTMLInputElement)?.value || ''; const pw = (row.querySelector('.ri-pw') as HTMLInputElement)?.value || ''; const val2Str = (id || pw) ? JSON.stringify({ id, pw }) : ''; nets.push({ type: 'IP', name: tool, val1: val1, val2: val2Str }); } else if (type === 'MAC' && val1) { nets.push({ type: 'MAC', name: 'MAC 주소', val1: val1, val2: '' }); } }); setFieldValue('hw-remotes-data', JSON.stringify(nets)); const formData = new FormData(this.formEl!); const updated = { ...this.currentAsset }; formData.forEach((value, key) => { if (key !== 'id') updated[key] = value; }); updated.location = getFieldValue('hw-bldg-select'); if (await saveAsset(this.getCategoryKey(updated), updated)) { alert(UI_TEXT.MESSAGES.SAVE_SUCCESS); onSave(); this.close(); closeModals(); } }); } private addVolumeRow(vol: any = { type: 'SSD', capacity: '', unit: 'GB' }) { const container = document.getElementById('hw-volume-container'); if (!container) return; const row = document.createElement('div'); row.className = 'volume-row'; row.style.display = 'flex'; row.style.gap = '8px'; row.style.alignItems = 'center'; const inputStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0; padding: 0 8px;'; row.innerHTML = ` `; row.querySelector('.btn-remove-row')?.addEventListener('click', () => row.remove()); container.appendChild(row); } private addRemoteInfoRow(info: any = { type: 'IP', name: '원격접속', val1: '', val2: '' }) { const container = document.getElementById('hw-remote-info-container'); if (!container) return; // Parse val2 (which contains JSON with id and pw if type is IP) let parsedId = ''; let parsedPw = ''; if (info.type === 'IP' && info.val2) { try { const parsed = typeof info.val2 === 'string' ? JSON.parse(info.val2) : info.val2; parsedId = parsed.id || ''; parsedPw = parsed.pw || ''; } catch (e) { // Legacy fallback if val2 was just a simple string parsedId = info.val2; } } const row = document.createElement('div'); row.className = 'remote-info-row'; // First Line: Type & Address const line1 = document.createElement('div'); line1.className = 'ri-line'; line1.innerHTML = ` `; // Second Line: Tool & Credentials (Only for IP) const line2 = document.createElement('div'); line2.className = 'ri-line ri-cred-line'; line2.style.display = info.type === 'IP' ? 'flex' : 'none'; line2.innerHTML = `
`; row.appendChild(line1); row.appendChild(line2); // Toggle logic const typeSelect = row.querySelector('.ri-type') as HTMLSelectElement; typeSelect.addEventListener('change', (e) => { const isIP = (e.target as HTMLSelectElement).value === 'IP'; line2.style.display = isIP ? 'flex' : 'none'; if (!isIP) { (row.querySelector('.ri-id') as HTMLInputElement).value = ''; (row.querySelector('.ri-pw') as HTMLInputElement).value = ''; } }); row.querySelector('.btn-remove-row')?.addEventListener('click', () => row.remove()); container.appendChild(row); } private toggleEditOnlyBtns(isEdit: boolean) { ['btn-add-volume', 'btn-add-remote-info'].forEach(id => { const btn = document.getElementById(id); if (btn) btn.style.display = isEdit ? 'inline-flex' : 'none'; }); document.querySelectorAll('.edit-only-btn').forEach(btn => { (btn as HTMLElement).style.display = isEdit ? 'inline-flex' : 'none'; }); // 동적 생성된 필드들 (볼륨/원격정보)의 상태 일괄 토글 const containers = ['#hw-volume-container', '#hw-remote-info-container']; containers.forEach(selector => { document.querySelectorAll(`${selector} input`).forEach(input => { if (isEdit) input.removeAttribute('readonly'); else input.setAttribute('readonly', 'true'); }); document.querySelectorAll(`${selector} select`).forEach(select => { if (isEdit) select.removeAttribute('disabled'); else select.setAttribute('disabled', 'true'); }); }); } protected fillFormData(asset: any): void { setFieldValue('hw-id', asset.id); setFieldValue('hw-asset_code', asset.asset_code || ''); setFieldValue('hw-purchase_corp', asset.purchase_corp || ''); setFieldValue('hw-category', asset.category || ''); const types = CATEGORY_TYPE_MAP[asset.category] || []; const typeSelect = document.getElementById('hw-asset_type') as HTMLSelectElement; if (typeSelect) typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, asset.asset_type, true) : ''; setFieldValue('hw-asset_type', asset.asset_type || ''); setFieldValue('hw-hw_status', asset.hw_status || '운영'); setFieldValue('hw-service_type', asset.service_type || '외부'); setFieldValue('hw-asset_purpose', asset.asset_purpose || ''); setFieldValue('hw-current_dept', asset.current_dept || ''); setFieldValue('hw-manager_primary', asset.manager_primary || ''); setFieldValue('hw-manager_secondary', asset.manager_secondary || ''); setFieldValue('hw-user_current', asset.user_current || ''); setFieldValue('hw-user_position', asset.user_position || ''); setFieldValue('hw-previous_user', asset.previous_user || ''); setFieldValue('hw-model_name', asset.model_name || ''); setFieldValue('hw-asset_mfr', asset.asset_mfr || ''); setFieldValue('hw-os', asset.os || ''); setFieldValue('hw-cpu', asset.cpu || ''); setFieldValue('hw-ram', asset.ram || ''); setFieldValue('hw-gpu', asset.gpu || ''); setFieldValue('hw-mainboard', asset.mainboard || ''); // 동적 볼륨 렌더링 const volumeContainer = document.getElementById('hw-volume-container'); if (volumeContainer) volumeContainer.innerHTML = ''; let vols = []; try { vols = asset.volumes ? (typeof asset.volumes === 'string' ? JSON.parse(asset.volumes) : asset.volumes) : []; } catch(e) {} vols.forEach((v: any) => this.addVolumeRow(v)); // 통합 원격 접속 정보 렌더링 초기화 및 생성 const remoteInfoContainer = document.getElementById('hw-remote-info-container'); if (remoteInfoContainer) { remoteInfoContainer.innerHTML = ''; let nets = []; try { nets = asset.remotes ? (typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes) : []; } catch(e) {} // Fallback: 서버에서 배열을 안 줬지만 기존 평탄화 데이터가 있는 경우 if (nets.length === 0 && (asset.ip_address || asset.mac_address || asset.remote_tool || asset.remote_id)) { if (asset.ip_address) { const tool = asset.remote_tool || '원격접속'; const creds = (asset.remote_id || asset.remote_pw) ? JSON.stringify({ id: asset.remote_id || '', pw: asset.remote_pw || '' }) : ''; nets.push({ type: 'IP', name: tool, val1: asset.ip_address, val2: creds }); } if (asset.mac_address) { nets.push({ type: 'MAC', name: 'MAC 주소', val1: asset.mac_address, val2: '' }); } if (!asset.ip_address && (asset.remote_tool || asset.remote_id)) { const creds = JSON.stringify({ id: asset.remote_id || '', pw: asset.remote_pw || '' }); nets.push({ type: 'IP', name: asset.remote_tool || '기타', val1: '', val2: creds }); } } nets.forEach((n: any) => this.addRemoteInfoRow(n)); } setFieldValue('hw-monitoring', asset.monitoring || '비대상'); setFieldValue('hw-serial_num', asset.serial_num || ''); setFieldValue('hw-monitor_inch', asset.monitor_inch || ''); setFieldValue('hw-volume', asset.volume || ''); setFieldValue('hw-asset_count', asset.asset_count || ''); setFieldValue('hw-purchase_date', asset.purchase_date || ''); setFieldValue('hw-purchase_vendor', asset.purchase_vendor || ''); setFieldValue('hw-purchase_amount', asset.purchase_amount || ''); setFieldValue('hw-approval_document', asset.approval_document || ''); const docName = document.getElementById('hw-file-name-display'); if (docName) docName.textContent = asset.approval_document ? asset.approval_document.split('/').pop() : '파일 선택...'; const fileLinkContainer = document.getElementById('hw-file-link-container'); if (fileLinkContainer && asset.approval_document) { fileLinkContainer.innerHTML = `[파일 보기]`; } else if (fileLinkContainer) { fileLinkContainer.innerHTML = ''; } setFieldValue('hw-memo', asset.memo || ''); setFieldValue('hw-location_detail', asset.location_detail || ''); setFieldValue('hw-loc_x', asset.loc_x || ''); setFieldValue('hw-loc_y', asset.loc_y || ''); setFieldValue('hw-location_photo', asset.location_photo || asset.loc_img || ''); parseAndSetLocation(asset.location || '', asset.location_detail || '', 'hw-bldg-select', 'hw-location_detail'); this.renderHistory(asset.id); this.applyRoleVisibility(); this.updatePcGradeBadge(); } protected onAfterOpen(asset: any, mode: string): void { const genBtn = document.getElementById('btn-gen-hw-code'); if (genBtn) genBtn.style.display = (mode === 'add') ? 'inline-flex' : 'none'; this.toggleFileUploadUI(mode !== 'view'); this.toggleEditOnlyBtns(mode !== 'view'); this.updateMapButtonVisibility(asset); this.applyRoleVisibility(); } private toggleFileUploadUI(showUpload: boolean) { const fileBtn = document.getElementById('btn-file-select') as HTMLElement; if (fileBtn) fileBtn.style.display = showUpload ? 'inline-flex' : 'none'; } private applyRoleVisibility(): void { const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || ''; const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || ''; const infraCategories = ['서버', '저장매체', '네트워크', '보안장비', '공간정보장비']; const isInfra = infraCategories.includes(category) || type.includes('서버') || type.includes('저장시스템'); const personalCategories = ['PC', '노트북', '모바일', '태블릿']; const isPersonal = (personalCategories.includes(category) || type.includes('개인PC') || type.includes('노트북')) && !type.includes('서버PC'); const specCategories = ['PC', '서버', '노트북', '스토리지', '워크스테이션']; const hasSpec = specCategories.includes(category) || type.includes('서버PC'); const noNetCategories = ['저장매체', '네트워크', '공간정보장비', 'PC부품', '사무가구']; const showNet = (isInfra || isPersonal) && !noNetCategories.includes(category); const hasSN = !['사무가구', 'PC부품'].includes(category); const isParts = ['PC부품', '사무가구'].includes(category); const showRemote = category === '서버' || type.includes('서버'); document.querySelectorAll('.remote-section, .remote-field, .monitoring-field').forEach(el => (el as HTMLElement).style.display = showRemote ? '' : 'none'); document.querySelectorAll('.net-only').forEach(el => (el as HTMLElement).style.display = showNet ? '' : 'none'); document.querySelectorAll('.spec-only').forEach(el => (el as HTMLElement).style.display = hasSpec ? '' : 'none'); document.querySelectorAll('.location-section, .location-field').forEach(el => (el as HTMLElement).style.display = (isInfra || category === '공간정보장비') ? '' : 'none'); 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('.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'); } private updateMapButtonVisibility(asset?: any) { const bldg = asset ? (asset.location || '') : getFieldValue('hw-bldg-select'); const detail = asset ? (asset.location_detail || '') : getFieldValue('hw-location_detail'); const x = asset ? (asset.loc_x || '') : getFieldValue('hw-loc_x'); const y = asset ? (asset.loc_y || '') : getFieldValue('hw-loc_y'); const hasCoords = (x !== '' && y !== '' && x !== 'null' && y !== 'null'); const hasImage = !!this.getImagesForLocation(bldg, detail); const regLocBtn = document.getElementById('btn-reg-loc-map')!; const viewLocBtn = document.getElementById('btn-view-loc-map')!; if (hasImage && this.isEditMode) regLocBtn.style.display = 'inline-flex'; else regLocBtn.style.display = 'none'; if (hasImage && hasCoords) { viewLocBtn.style.display = 'inline-flex'; viewLocBtn.style.pointerEvents = 'auto'; viewLocBtn.style.opacity = '1'; } else { viewLocBtn.style.display = 'none'; } } private getImagesForLocation(bldg: string, detail: string): string[] | null { if (!bldg || !detail) return null; return IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null; } private async fetchMapConfig() { try { const res = await fetch(`http://${location.hostname}:3000/api/maps`); this.dynamicMapConfig = await res.json(); } catch (err) { console.error('Failed to fetch map config:', err); } } private generateDynamicSVG(imagePath: string): string { const boxes = this.dynamicMapConfig[imagePath] || []; if (boxes.length === 0) return ''; return `${boxes.map((b) => ``).join('')}`; } private openImagePicker(imagePaths: string[], title: string) { let currentIdx = 0; const overlay = document.createElement('div'); overlay.className = 'image-picker-overlay'; const renderContent = () => { const imgPath = imagePaths[currentIdx]; const isMulti = imagePaths.length > 1; const isHtmlMap = imgPath.toLowerCase().endsWith('.html'); const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imgPath); overlay.innerHTML = `

${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}

${isMulti ? ` ` : ''}
${isHtmlMap ? `` : `
${digitalMap}
` }
`; let selectedX = ''; let selectedY = ''; if (isMulti) { overlay.querySelector('.picker-nav.prev')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx > 0) { currentIdx--; renderContent(); } }); overlay.querySelector('.picker-nav.next')?.addEventListener('click', (e) => { e.stopPropagation(); if (currentIdx < imagePaths.length - 1) { currentIdx++; renderContent(); } }); } if (isHtmlMap) { // HTML 지도 메시지 리스너 const handleMessage = (e: MessageEvent) => { if (e.data.type === 'PICK_LOCATION') { selectedX = e.data.x; selectedY = e.data.y; } }; window.addEventListener('message', handleMessage); overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); }); overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => { window.removeEventListener('message', handleMessage); overlay.remove(); }); overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => { if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; } setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY); setFieldValue('hw-location_photo', imagePaths[currentIdx]); this.updateMapButtonVisibility(); window.removeEventListener('message', handleMessage); overlay.remove(); }); } else { const container = overlay.querySelector('#picker-container') as HTMLElement; const marker = overlay.querySelector('#picker-marker') as HTMLElement; container.addEventListener('click', (e) => { const rectBound = container.getBoundingClientRect(); const clickX = ((e.clientX - rectBound.left) / rectBound.width) * 100; const clickY = ((e.clientY - rectBound.top) / rectBound.height) * 100; let snapped = false; overlay.querySelectorAll('rect').forEach(rect => { const rx = parseFloat(rect.getAttribute('x') || '0'); const ry = parseFloat(rect.getAttribute('y') || '0'); const rw = parseFloat(rect.getAttribute('width') || '0'); const rh = parseFloat(rect.getAttribute('height') || '0'); if (clickX >= rx && clickX <= rx + rw && clickY >= ry && clickY <= ry + rh) { overlay.querySelectorAll('rect').forEach(r => { r.style.fill = 'rgba(30,81,73,0.05)'; r.style.stroke = 'rgba(30,81,73,0.2)'; r.style.strokeWidth = '0.2'; }); rect.style.fill = 'rgba(255, 61, 0, 0.4)'; rect.style.stroke = '#FF3D00'; rect.style.strokeWidth = '0.8'; selectedX = rx.toFixed(2); selectedY = ry.toFixed(2); marker.style.left = `${rx + rw/2}%`; marker.style.top = `${ry + rh/2}%`; marker.classList.remove('hidden'); snapped = true; } }); if (!snapped) { selectedX = ''; selectedY = ''; marker.classList.add('hidden'); overlay.querySelectorAll('rect').forEach(r => { r.style.fill = 'rgba(30,81,73,0.05)'; r.style.stroke = 'rgba(30,81,73,0.2)'; r.style.strokeWidth = '0.2'; }); } }); overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove()); overlay.querySelector('#btn-picker-cancel')?.addEventListener('click', () => overlay.remove()); overlay.querySelector('#btn-picker-save')?.addEventListener('click', () => { if (!selectedX || !selectedY) { alert('위치를 선택해주세요.'); return; } setFieldValue('hw-loc_x', selectedX); setFieldValue('hw-loc_y', selectedY); setFieldValue('hw-location_photo', imagePaths[currentIdx]); this.updateMapButtonVisibility(); overlay.remove(); }); } }; renderContent(); document.body.appendChild(overlay); } private openImagePreview(imagePath: string, title: string, x: string, y: string) { const overlay = document.createElement('div'); overlay.className = 'image-picker-overlay'; const isHtmlMap = imagePath.toLowerCase().endsWith('.html'); const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imagePath); // HTML 지도인 경우 좌표를 쿼리 파라미터로 전달 const finalPath = isHtmlMap ? `${imagePath}?markerX=${x}&markerY=${y}` : imagePath; overlay.innerHTML = `

${title}

${isHtmlMap ? `` : `
${digitalMap}
` }
`; document.body.appendChild(overlay); if (!isHtmlMap && digitalMap) { const curX = parseFloat(x || '0'); const curY = parseFloat(y || '0'); overlay.querySelectorAll('rect').forEach(rect => { const sx = parseFloat(rect.getAttribute('x') || '0'); const sy = parseFloat(rect.getAttribute('y') || '0'); if (Math.abs(sx - curX) < 0.01 && Math.abs(sy - curY) < 0.01) { rect.style.fill = 'rgba(255, 61, 0, 0.4)'; rect.style.stroke = '#FF3D00'; rect.style.strokeWidth = '0.8'; const w = parseFloat(rect.getAttribute('width') || '0'); const h = parseFloat(rect.getAttribute('height') || '0'); const marker = overlay.querySelector('#preview-marker') as HTMLElement; if (marker) { marker.style.left = `${sx + w/2}%`; marker.style.top = `${sy + h/2}%`; } } }); } overlay.querySelector('.btn-close-picker')?.addEventListener('click', () => overlay.remove()); overlay.querySelector('#btn-preview-close')?.addEventListener('click', () => overlay.remove()); } private renderHistory(assetId: string) { const container = document.getElementById('hw-history-list'); if (!container) return; // state.masterData.logs에서 해당 자산의 이력 필터링 (최신순) const logs = (state.masterData.logs || []) .filter(l => l.asset_id === assetId) .sort((a, b) => new Date(b.created_at || b.log_date || '').getTime() - new Date(a.created_at || a.log_date || '').getTime()); if (logs.length === 0) { container.innerHTML = '
기록된 변동 이력이 없습니다.
'; return; } container.innerHTML = logs.map(l => { let eventTag = '기타'; let tagClass = 'tag-default'; let itemClass = ''; switch(l.event_type) { case 'DEPT_CHANGE': eventTag = '조직'; tagClass = 'tag-dept'; itemClass = 'evt-dept'; break; case 'USER_CHANGE': eventTag = '사용자'; tagClass = 'tag-user'; itemClass = 'evt-user'; break; case 'ROLE_CHANGE': eventTag = '용도'; tagClass = 'tag-role'; itemClass = 'evt-role'; break; case 'STATUS_CHANGE': eventTag = '상태'; tagClass = 'tag-status'; itemClass = 'evt-status'; break; } // 화살표 기호(➔)를 사용하여 변경 사항 강조 const formattedDetails = (l.details || '').replace(' -> ', ' '); return `
${eventTag} ${l.log_date || ''}
${l.log_user || '시스템'}
${formattedDetails}
`; }).join(''); } private getCategoryKey(asset: any): string { const cat = asset.category; const code = asset.asset_code || ''; if (asset.asset_type === '서버PC') return 'pc'; if (cat === '서버' || code.startsWith('SVR')) return 'server'; if (cat === '스토리지' || code.startsWith('STO')) return 'storage'; if (cat === '네트워크' || code.startsWith('NET')) return 'network'; if (cat === '업무지원장비' || code.startsWith('EQP')) return 'equipment'; if (cat === '공간정보장비') return 'survey'; if (cat === 'PC부품') return 'pcParts'; return (cat === 'PC' || code.startsWith('PC')) ? 'pc' : 'officeSupplies'; } private async fetchMasterComponents(): Promise { try { const res = await fetch(`http://${location.hostname}:3000/api/hardware-components`); this.masterComponents = await res.json(); } catch (err) { console.error('Failed to fetch master components:', err); } } private bindAutocomplete(inputId: string, autocompleteId: string, category: string) { const input = document.getElementById(inputId) as HTMLInputElement; const list = document.getElementById(autocompleteId) as HTMLDivElement; if (!input || !list) return; const showList = (filterText: string = '') => { if (!this.isEditMode) return; const items = this.masterComponents.filter(c => c.category === category); const filtered = filterText ? items.filter(c => c.component_name.toLowerCase().includes(filterText.toLowerCase())) : items; if (filtered.length === 0) { list.innerHTML = '
검색 결과 없음
'; } else { list.innerHTML = filtered.map(c => `
${c.component_name}
`).join(''); } list.classList.remove('hidden'); }; input.addEventListener('focus', () => { showList(input.value); }); input.addEventListener('input', () => { showList(input.value); }); // 아이템 클릭 이벤트 위임 list.addEventListener('mousedown', (e) => { const item = (e.target as HTMLElement).closest('.autocomplete-item'); if (item && item.getAttribute('data-val')) { input.value = item.getAttribute('data-val') || ''; list.classList.add('hidden'); this.updatePcGradeBadge(); // 뱃지 즉시 업데이트 } }); // 아웃사이드 클릭 시 닫기 document.addEventListener('mousedown', (e) => { if (e.target !== input && !list.contains(e.target as Node)) { list.classList.add('hidden'); } }); } private updatePcGradeBadge(): void { const cpu = (document.getElementById('hw-cpu') as HTMLInputElement)?.value || ''; const ram = (document.getElementById('hw-ram') as HTMLInputElement)?.value || ''; const gpu = (document.getElementById('hw-gpu') as HTMLInputElement)?.value || ''; const date = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || ''; const score = calculatePcScoreDeductive(cpu, ram, gpu, date); const grade = getPcGrade(score); const badge = document.getElementById('hw-pc-grade-badge'); if (badge) { badge.textContent = `${grade.name} (${score}점)`; badge.className = `badge ${grade.class}`; } } } export const hwModal = new HwAssetModal(); export function initHwModal(onSave: () => void, closeModals: () => void) { hwModal.init(onSave, closeModals); } export function openHwModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') { hwModal.open(asset, mode); }