import { state, saveAsset, deleteAsset } from '../../core/state'; import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { calculatePcScoreDeductive, getPcGrade, API_BASE_URL } 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'; import { QRPrinter } from '../../core/qr_print'; /** * 하드웨어 자산 상세 모달 (Styled Main Edition) * - 내용/순서는 main 버전 준수 * - 스타일은 ux_setting의 Vercel 디자인 준수 */ class HwAssetModal extends BaseModal { private dynamicMapConfig: Record = {}; private masterComponents: any[] = []; constructor() { super('hw', '자산 상세 정보'); } protected renderFrameHTML(): string { return ` `; } protected initChildLogic(onSave: () => void, closeModals: () => void): void { const saveBtn = document.getElementById('btn-save-hw-asset')!; 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; this.fetchMapConfig(); const qrPrintBtn = document.getElementById('btn-print-hw-qr'); qrPrintBtn?.addEventListener('click', () => { if (this.currentAsset && this.currentAsset.asset_code) { QRPrinter.print([{ type: 'asset', code: this.currentAsset.asset_code, title: '[ HM IT ASSET ]', subtitle: this.currentAsset.model_name || this.currentAsset.asset_purpose || this.currentAsset.category || 'IT 자산', dept: this.currentAsset.current_dept || '-', user: this.currentAsset.user_current || '-' }]); } }); this.fetchMasterComponents().then(() => { this.bindAutocomplete('hw-cpu', 'hw-cpu-list', 'CPU'); this.bindAutocomplete('hw-ram', 'hw-ram-list', 'RAM'); this.bindAutocomplete('hw-gpu', 'hw-gpu-list', 'GPU'); this.bindUserAutocomplete(); }); categorySelect.addEventListener('change', () => { const cat = categorySelect.value; const types = CATEGORY_TYPE_MAP[cat] || []; typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, '', true) : ''; this.applyRoleVisibility(); const identityContainer = document.getElementById('hw-header-identity'); if (identityContainer) { identityContainer.innerHTML = cat ? `${cat}` : ''; } }); 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', '', ''); bldgSelect.addEventListener('change', () => setTimeout(() => this.updateMapButtonVisibility(), 100)); detailSelect.addEventListener('change', () => this.updateMapButtonVisibility()); applyDateMask(document.getElementById('hw-purchase_date') as HTMLInputElement); 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'; try { const res = await fetch(`/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); } }); const fileSelectBtn = document.getElementById('btn-file-select'); const fileInput = document.getElementById('hw-approval_document_file') as HTMLInputElement; fileSelectBtn?.addEventListener('click', () => fileInput.click()); fileInput?.addEventListener('change', async (e) => { const file = (e.target as HTMLInputElement).files?.[0]; if (!file) return; const fileNameDisplay = document.getElementById('hw-file-name-display'); const fileLinkContainer = document.getElementById('hw-file-link-container'); if (fileNameDisplay) fileNameDisplay.textContent = file.name; const reader = new FileReader(); reader.onload = async () => { try { const res = await fetch('/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); }); document.getElementById('btn-reg-loc-map')?.addEventListener('click', async (e) => { e.preventDefault(); e.stopPropagation(); const bldg = bldgSelect.value; const detail = detailSelect.value; await this.fetchMapConfig(); const images = this.getImagesForLocation(bldg, detail); if (images) this.openImagePicker(images, `${detail} 위치 등록`); }); 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 = bldgSelect.value; const detail = detailSelect.value; const images = this.getImagesForLocation(bldg, detail); if (images) { const imgPath = savedImg && images.includes(savedImg) ? savedImg : images[0]; this.openImagePreview(imgPath, `${detail} 위치 확인`, x, y); } }); document.getElementById('btn-add-volume')?.addEventListener('click', () => this.addVolumeRow()); document.getElementById('btn-add-remote-info')?.addEventListener('click', () => this.addRemoteInfoRow()); 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(); } }); saveBtn.addEventListener('click', async () => { if (!this.currentAsset) return; // [추가] 조회 모드인 경우 수정 모드로 전환 if (!this.isEditMode) { this.open(this.currentAsset, 'edit'); return; } // 자산코드가 비어있는 경우 자동 생성 처리 let assetCode = getFieldValue('hw-asset_code').trim(); if (!assetCode) { const cat = categorySelect.value; if (!cat) { 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(); if (data.nextCode) { setFieldValue('hw-asset_code', data.nextCode); assetCode = data.nextCode; } else { alert('자산코드 자동 생성에 실패했습니다. 수동으로 생성 버튼을 눌러주세요.'); return; } } catch (err) { console.error('코드 자동 생성 실패:', err); alert('자산코드 자동 생성 중 오류가 발생했습니다.'); 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 = bldgSelect.value; // 부품 마스터 기준 정합성 검증 (CPU, GPU, RAM) const checkFields = [ { name: 'cpu', label: 'CPU', category: 'CPU' }, { name: 'gpu', label: 'GPU', category: 'GPU' }, { name: 'ram', label: 'RAM', category: 'RAM' } ]; for (const field of checkFields) { const value = String(updated[field.name] || '').trim(); if (value) { const isExists = this.masterComponents.some(c => c.category.toUpperCase() === field.category && c.component_name.trim().toLowerCase() === value.toLowerCase() ); if (!isExists) { alert(`입력하신 ${field.label} "${value}"은(는) 부품 마스터에 등록되지 않은 규격입니다. 자동완성 목록에서 선택하거나 부품마스터에 먼저 등록해 주세요.`); return; } } } 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 items-center'; row.innerHTML = ` `; row.querySelector('.btn-circle-remove')?.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; 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) { parsedId = info.val2; } } const row = document.createElement('div'); row.className = 'remote-info-row w-full'; const line1 = document.createElement('div'); line1.className = 'ri-line items-center'; line1.innerHTML = ` `; const line2 = document.createElement('div'); line2.className = 'ri-line ri-cred-line items-center'; if (info.type !== 'IP') line2.classList.add('hidden'); line2.innerHTML = `
`; row.appendChild(line1); row.appendChild(line2); const typeSelect = row.querySelector('.ri-type') as HTMLSelectElement; typeSelect.addEventListener('change', (e) => { const isIP = (e.target as HTMLSelectElement).value === 'IP'; line2.classList.toggle('hidden', !isIP); if (!isIP) { (row.querySelector('.ri-id') as HTMLInputElement).value = ''; (row.querySelector('.ri-pw') as HTMLInputElement).value = ''; } }); row.querySelector('.btn-circle-remove')?.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.classList.toggle('hidden', !isEdit); }); 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-emp_no', asset.emp_no || ''); 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 for legacy data 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(); this.updateHeaderIdentity(asset); } 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'; const qrBtn = document.getElementById('btn-print-hw-qr'); if (qrBtn) { const hasCode = asset && asset.asset_code && asset.asset_code.trim() !== ''; qrBtn.classList.toggle('hidden', mode !== 'view' || !hasCode); } const approvedBadge = document.getElementById('hw-modal-audit-approved-badge'); if (approvedBadge) { const isApproved = asset && asset.is_audit_approved; approvedBadge.style.display = (mode === 'view' && isApproved) ? 'inline-flex' : 'none'; } this.toggleFileUploadUI(mode !== 'view'); this.toggleEditOnlyBtns(mode !== 'view'); this.updateMapButtonVisibility(); this.applyRoleVisibility(); this.updateHeaderIdentity(asset); } private updateHeaderIdentity(asset: any) { const container = document.getElementById('hw-header-identity'); if (!container) return; if (this.currentMode === 'add') { container.innerHTML = '신규 등록'; return; } const category = (document.getElementById('hw-category') as HTMLSelectElement)?.value || asset.category || ''; const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || asset.asset_type || ''; const code = (document.getElementById('hw-asset_code') as HTMLInputElement)?.value || asset.asset_code || '미부여'; const serviceType = (document.getElementById('hw-service_type') as HTMLSelectElement)?.value || asset.service_type || '외부'; container.innerHTML = ` ${code} ${serviceType} ${category} ${type} `; } 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 = ['외부SW', '내부SW'].includes(category); const showMainboard = category === 'PC'; const isParts = ['PC부품', '사무가구'].includes(category); const showRemote = category === '서버' || type.includes('서버'); const showServiceType = category === '서버' || type === '서버PC'; document.querySelectorAll('.remote-section, .remote-field, .monitoring-field').forEach(el => (el as HTMLElement).style.display = showRemote ? '' : 'none'); document.querySelectorAll('.service-type-field').forEach(el => (el as HTMLElement).style.display = showServiceType ? '' : '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('.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() { const bldg = getFieldValue('hw-bldg-select'); const detail = getFieldValue('hw-location_detail'); const x = getFieldValue('hw-loc_x'); const 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.classList.remove('hidden'); else regLocBtn.classList.add('hidden'); if (hasImage && hasCoords) viewLocBtn.classList.remove('hidden'); else viewLocBtn.classList.add('hidden'); } 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(`${API_BASE_URL}/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) { 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); 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 bindUserAutocomplete() { const input = document.getElementById('hw-user_current') as HTMLInputElement; const list = document.getElementById('hw-user-current-list') as HTMLDivElement; const deptSelect = document.getElementById('hw-current_dept') as HTMLSelectElement; const positionInput = document.getElementById('hw-user_position') as HTMLInputElement; const empNoInput = document.getElementById('hw-emp_no') as HTMLInputElement; if (!input || !list) return; 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(); const filtered = query ? users.filter((u: any) => u.user_name.toLowerCase().includes(query) || (u.dept_name && u.dept_name.toLowerCase().includes(query)) || (u.emp_no && u.emp_no.toLowerCase().includes(query)) ) : users; if (filtered.length === 0) { list.innerHTML = '
일치하는 사원 없음
'; } else { const seen = new Set(); const uniqueFiltered = filtered.filter((u: any) => { const key = `${u.user_name}-${u.dept_name}-${u.emp_no}`; if (seen.has(key)) return false; seen.add(key); return true; }).slice(0, 15); list.innerHTML = uniqueFiltered.map((u: any) => `
${u.user_name}
${u.dept_name || '부서 없음'} / 사번: ${u.emp_no || '-'} / ${u.position || '직급 없음'}
`).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('.user-suggestion-item'); if (item) { const name = item.getAttribute('data-name') || ''; const dept = item.getAttribute('data-dept') || ''; const pos = item.getAttribute('data-pos') || ''; const emp = item.getAttribute('data-emp') || ''; input.value = name; if (positionInput) positionInput.value = pos; if (empNoInput) empNoInput.value = emp; if (deptSelect && dept) { for (let i = 0; i < deptSelect.options.length; i++) { if (deptSelect.options[i].value === dept) { deptSelect.selectedIndex = i; break; } } } list.classList.add('hidden'); } }); document.addEventListener('mousedown', (e) => { if (e.target !== input && !list.contains(e.target as Node)) { list.classList.add('hidden'); } }); } private renderHistory(assetId: string) { const container = document.getElementById('hw-history-list'); if (!container) return; const logs = (state.masterData.logs || []).filter(l => l.asset_id === assetId); if (logs.length === 0) { container.innerHTML = '
기록된 변동 이력이 없습니다.
'; return; } 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 { 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(`${API_BASE_URL}/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); }