diff --git a/src/components/Modal/HWModal.ts b/src/components/Modal/HWModal.ts index 1e7f7f9..03ea7a1 100644 --- a/src/components/Modal/HWModal.ts +++ b/src/components/Modal/HWModal.ts @@ -1,987 +1,987 @@ -import { state, saveAsset, deleteAsset } from '../../core/state'; -import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; -import { API_BASE_URL, 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'; - -/** - * 하드웨어 자산 상세 모달 (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(); - 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'); - }); - - 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); - }); - - 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 prefix = TYPE_PREFIX_MAP[cat] || 'ETC'; - const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || ''; - try { - const res = await fetch(`${API_BASE_URL}/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_BASE_URL}/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 prefix = TYPE_PREFIX_MAP[cat] || 'ETC'; - const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || ''; - 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); - 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; - - 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-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'; - 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 = !['사무가구', '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'); - document.querySelectorAll('.hardware-section').forEach(el => (el as HTMLElement).style.display = (hasSpec || isParts) ? '' : 'none'); - } - - 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 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; } - container.innerHTML = logs.map(l => `
${l.log_date || ''}
${l.log_user || '시스템'}
${l.details}
`).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); } +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'; + +/** + * 하드웨어 자산 상세 모달 (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(); + 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'); + }); + + 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); + }); + + 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 prefix = TYPE_PREFIX_MAP[cat] || 'ETC'; + const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || ''; + 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); } + }); + + 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(`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); + }); + + 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 prefix = TYPE_PREFIX_MAP[cat] || 'ETC'; + const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || ''; + 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); + 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; + + 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-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'; + 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 = !['사무가구', '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'); + document.querySelectorAll('.hardware-section').forEach(el => (el as HTMLElement).style.display = (hasSpec || isParts) ? '' : 'none'); + } + + 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 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; } + container.innerHTML = logs.map(l => `
${l.log_date || ''}
${l.log_user || '시스템'}
${l.details}
`).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); } diff --git a/src/components/Modal/modal.css b/src/components/Modal/modal.css index 1325f86..af7bf1b 100644 --- a/src/components/Modal/modal.css +++ b/src/components/Modal/modal.css @@ -1,594 +1,599 @@ -/* Modal - Vercel Inspired Minimalist Design */ -.modal-overlay { - position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - background-color: rgba(0, 0, 0, 0.4); - display: flex; - align-items: center; - justify-content: center; - z-index: 1000; - opacity: 0; - visibility: hidden; - transition: opacity 0.2s ease, visibility 0.2s ease; - backdrop-filter: blur(2px); -} - -.modal-overlay:not(.hidden) { opacity: 1; visibility: visible; } - -.modal-content { - background-color: var(--canvas); - width: 100%; - max-width: 600px; - max-height: 90vh; - border-radius: 8px; - overflow: hidden; - box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); - transform: translateY(10px); - transition: transform 0.2s ease; - display: flex; - flex-direction: column; - border: 1px solid var(--hairline); -} - -.modal-overlay.sub-modal { - z-index: 1100; -} - - -.modal-header { - background-color: var(--canvas); - border-bottom: 1px solid var(--hairline); - padding: 1rem var(--spacing-base); - display: flex; - justify-content: space-between; - align-items: center; - flex-shrink: 0; -} - -.modal-header h2 { - font-size: var(--fs-md); - font-weight: 600; - color: var(--primary); - line-height: 1.2; -} - -.modal-header .btn-icon { - color: var(--mute) !important; - cursor: pointer; - background: none !important; - border: none !important; - font-size: 1.5rem; - line-height: 1; - transition: color 0.2s; -} - -.header-left { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.form-group.relative { - position: relative; -} - -/* 동적 리스트 컨테이너 */ -.dynamic-row-container { - display: flex; - flex-direction: column; - gap: 0.75rem; -} - -.volume-row, .remote-info-row { - display: flex; - gap: 0.5rem; -} - -.remote-info-row { - flex-direction: column; -} - -/* 파일 업로드 디스플레이 */ -.file-upload-display { - flex: 1; - border: 1px solid var(--hairline); - border-radius: 6px; - padding: 0 12px; - height: clamp(34px, 4.5vmin, 44px); - display: flex; - align-items: center; - font-size: var(--fs-sm); - color: var(--mute); - background-color: var(--canvas-soft); -} - -.btn-circle-remove { - width: clamp(34px, 4.5vmin, 44px); - height: clamp(34px, 4.5vmin, 44px); - border-radius: 50% !important; - display: inline-flex; - align-items: center; - justify-content: center; - padding: 0 !important; - color: var(--danger) !important; - border: 1px solid var(--danger) !important; - background: transparent; - font-size: 1.5rem !important; /* Larger X icon */ - line-height: 1; - flex-shrink: 0; - transition: all 0.2s; - cursor: pointer; -} - -.btn-circle-remove:hover { - background-color: var(--danger); - color: var(--white) !important; -} - - - -.modal-body { - padding: var(--spacing-base); - overflow-y: auto; - flex: 1; - background: var(--canvas); -} - -.grid-form { - display: grid; - grid-template-columns: 1fr 1fr; - gap: var(--spacing-base); -} - -.form-group { - display: flex; - flex-direction: column; - gap: 0.5rem; -} - -.form-group.full-width { - grid-column: span 2; -} - -/* Section Title for Grouping */ -.form-section-title { - grid-column: span 2; - font-size: var(--fs-xs); - font-weight: 600; - color: var(--mute); - padding: 1rem 0 0.5rem 0; - border-bottom: 1px solid var(--hairline); - margin-bottom: 1rem; - display: flex; - align-items: center; - text-transform: uppercase; - letter-spacing: -0.02em; -} - -.form-section-title:first-child { - padding-top: 0; -} - -.form-group label { - font-size: var(--fs-xs); - font-weight: 600; - color: var(--mute); -} - -.form-group input, -.form-group select, -.form-group textarea { - height: clamp(34px, 4.5vmin, 44px); - padding: 0 0.75rem; - border: 1px solid var(--hairline); - border-radius: 6px; - font-family: inherit; - font-size: var(--fs-sm); - outline: none; - transition: all 0.2s; - background-color: var(--canvas); - color: var(--primary); - box-sizing: border-box; -} - -.form-group input.font-mono, -.form-group select.font-mono { - font-family: var(--font-mono); - font-size: var(--fs-xs); -} - -.form-group textarea { - height: auto; - padding: 0.75rem; - resize: none; -} - -.form-group input:focus, -.form-group select:focus, -.form-group textarea:focus { - border-color: var(--primary); - box-shadow: 0 0 0 1px var(--primary); -} - -.form-group input:disabled, -.form-group select:disabled { - background-color: var(--canvas-soft); - color: var(--mute); - cursor: not-allowed; -} - -.modal-footer { - padding: 1rem 1.5rem; - border-top: 1px solid var(--hairline); - display: flex; - justify-content: space-between; - align-items: center; - background-color: var(--canvas-soft); - flex-shrink: 0; -} - -.modal-footer .btn { - height: 36px; - padding: 0 1.25rem; - font-size: var(--fs-xs); -} - -.footer-actions { - display: flex; - gap: 0.75rem; -} - -/* Modal Size Variants */ -.modal-content.wide { - max-width: 1000px; -} - -.modal-content.narrow { - max-width: 500px; -} - -.vertical-form { - grid-template-columns: 1fr !important; -} - - -.modal-body-split { - display: flex; - gap: 2rem; - min-height: 500px; -} - -.modal-form-area { - flex: 1.2; -} - -.modal-history-area { - flex: 0.8; - border-left: 1px solid var(--hairline); - padding-left: 2rem; - display: flex; - flex-direction: column; -} - -.history-header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 1.5rem; -} - -.history-header h3 { - font-size: var(--fs-md); - font-weight: 600; - display: flex; - align-items: center; - gap: 0.5rem; - color: var(--primary); -} - -/* 읽기 전용 필드 (자산번호 등) 통일 스타일 */ -.is-readonly-field { - border-color: transparent !important; - background-color: transparent !important; - pointer-events: none !important; - color: var(--primary) !important; - font-weight: 600 !important; - cursor: default; - padding-left: 0 !important; -} - -/* 조회 모드 (View Mode) 폼 필드 스타일 */ -.is-view-mode input, -.is-view-mode select, -.is-view-mode textarea { - border-color: transparent !important; - background-color: transparent !important; - color: var(--primary) !important; - font-weight: 600 !important; - padding-left: 0 !important; - -webkit-appearance: none !important; - -moz-appearance: none !important; - appearance: none !important; - box-shadow: none !important; -} - -/* 입력 필드 + 버튼 그룹 */ -.input-with-btn { - display: flex; - gap: 0.5rem; - align-items: center; - width: 100%; -} - -.input-with-btn input { - flex: 1; -} - -/* Remote Info UI Extras */ -.ri-creds-row { - display: flex; - gap: 0.5rem; - flex: 1; -} - -.ri-field-group { - display: flex; - align-items: center; - gap: 0.4rem; - background: var(--canvas-soft); - padding: 0 0.75rem; - border-radius: 6px; - border: 1px solid var(--hairline); - flex: 1; -} - -.is-view-mode .ri-field-group { - background: transparent; - border-color: transparent; - padding: 0; -} - -.ri-mini-label { - font-size: 10px; - font-weight: 700; - color: var(--mute); - text-transform: uppercase; - user-select: none; - flex-shrink: 0; -} - -.ri-field-group input { - border: none !important; - background: transparent !important; - padding: 0 !important; - height: 36px !important; - box-shadow: none !important; - width: 100%; -} - -.ri-line { - display: flex; - gap: 0.5rem; - align-items: center; - margin-bottom: 0.5rem; -} - -.ri-connector { - width: 24px; - height: 24px; - border-left: 2px solid var(--hairline); - border-bottom: 2px solid var(--hairline); - margin-left: 12px; - margin-top: -12px; - flex-shrink: 0; -} -.history-timeline { - flex: 1; - overflow-y: auto; - padding-right: 12px; -} - -.history-item { - position: relative; - padding-left: 24px; - padding-bottom: 24px; - border-left: 1px solid var(--hairline); -} - -.history-item:last-child { - border-left: 1px solid transparent; -} - -.history-item::before { - content: ''; - position: absolute; - left: -5px; - top: 0; - width: 9px; - height: 9px; - border-radius: 50%; - background-color: var(--canvas); - border: 2px solid var(--primary); - z-index: 1; -} - -.history-date { - font-size: var(--fs-xs); - color: var(--mute); - font-weight: 500; - margin-bottom: 4px; -} - -.history-details { - font-size: var(--fs-xs); - color: var(--primary); - line-height: 1.6; - background: var(--canvas-soft-2); - padding: 10px 12px; - border-radius: 6px; - border: 1px solid var(--hairline); -} - -/* Upload UI Refinement */ -.upload-sidebar { - width: 260px; - border-right: 1px solid var(--hairline); - background-color: var(--canvas-soft); - padding: 1.5rem 1rem; -} - -.upload-tab-btn { - padding: 0.8rem 1rem; - border-radius: 8px; - cursor: pointer; - font-size: var(--fs-xs); - font-weight: 500; - color: var(--body); - transition: all 0.2s; - border: 1px solid transparent; -} - -.upload-tab-btn.active { - background-color: var(--canvas); - color: var(--primary); - border-color: var(--hairline); - box-shadow: 0 1px 2px rgba(0,0,0,0.05); - font-weight: 600; -} - -/* --- Image Picker Overlay (View Location) --- */ -.image-picker-overlay { - position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - background-color: rgba(0, 0, 0, 0.7); - display: flex; - align-items: center; - justify-content: center; - z-index: 2000; - padding: 2rem; -} - -.image-picker-window { - width: 100%; - max-width: 900px; - height: 85vh; - display: flex; - flex-direction: column; - background: var(--canvas); - border-radius: 8px; - overflow: hidden; - box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); -} - -.image-picker-header { - display: flex; - justify-content: space-between; - align-items: center; - width: 100%; - background: var(--canvas); - color: var(--primary); - padding: 1rem 1.5rem; - border-bottom: 1px solid var(--hairline); -} - -.image-picker-header h3 { - margin: 0; - font-size: var(--fs-md); - font-weight: 600; - color: var(--primary); -} - -.image-picker-header .btn-icon { - color: var(--mute) !important; - cursor: pointer; - background: none !important; - border: none !important; - font-size: 1.5rem; - line-height: 1; -} - -.image-picker-content { - flex: 1; - width: 100%; - position: relative; - background: var(--canvas-soft); - display: flex; - align-items: center; - justify-content: center; - overflow: hidden; - box-shadow: 0 4px 20px rgba(0,0,0,0.15); -} - -.image-picker-footer { - display: flex; - justify-content: flex-end; - gap: 0.5rem; - width: 100%; - padding: 1rem 1.5rem; - background: var(--canvas); - border-radius: 0 0 8px 8px; - border-top: 1px solid var(--hairline); -} - -.layout-map-container { - position: relative; - display: inline-block; - cursor: crosshair; - background-color: var(--canvas-soft-2); - border-radius: 4px; - overflow: hidden; -} - -.layout-map-container.readonly { - cursor: default; -} - -.image-marker-wrapper { - position: relative; - display: inline-block; -} - -.layout-map-img { - display: block; - max-width: 100%; - max-height: 75vh; - user-select: none; - -webkit-user-drag: none; -} - - -.layout-marker { - position: absolute; - width: 16px; - height: 16px; - background-color: var(--danger); - border: 2px solid white; - border-radius: 50%; - transform: translate(-50%, -50%); - pointer-events: none; - z-index: 100; - box-shadow: 0 2px 4px rgba(0,0,0,0.3); -} - -.pulse-marker::after { - content: ''; - position: absolute; - top: 50%; left: 50%; - width: 100%; height: 100%; - background-color: var(--danger); - border-radius: 50%; - transform: translate(-50%, -50%); - animation: pulse 1.5s infinite; - z-index: -1; -} - -@keyframes pulse { - 0% { transform: translate(-50%, -50%) scale(1); opacity: 0.8; } - 100% { transform: translate(-50%, -50%) scale(3); opacity: 0; } -} - -.digital-overlay-layer { - position: absolute; - top: 0; left: 0; right: 0; bottom: 0; - pointer-events: none; -} +/* Modal - Vercel Inspired Minimalist Design */ +.modal-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background-color: rgba(0, 0, 0, 0.4); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + visibility: hidden; + transition: opacity 0.2s ease, visibility 0.2s ease; + backdrop-filter: blur(2px); +} + +.modal-overlay:not(.hidden) { opacity: 1; visibility: visible; } + +.modal-content { + background-color: var(--canvas); + width: 100%; + max-width: 600px; + max-height: 90vh; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.1); + transform: translateY(10px); + transition: transform 0.2s ease; + display: flex; + flex-direction: column; + border: 1px solid var(--hairline); +} + +.modal-overlay.sub-modal { + z-index: 1100; +} + + +.modal-header { + background-color: var(--canvas); + border-bottom: 1px solid var(--hairline); + padding: 1rem var(--spacing-base); + display: flex; + justify-content: space-between; + align-items: center; + flex-shrink: 0; +} + +.modal-header h2 { + font-size: var(--fs-md); + font-weight: 600; + color: var(--primary); + line-height: 1.2; +} + +.modal-header .btn-icon { + color: var(--mute) !important; + cursor: pointer; + background: none !important; + border: none !important; + font-size: 1.5rem; + line-height: 1; + transition: color 0.2s; +} + +.header-left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.form-group.relative { + position: relative; +} + +/* 동적 리스트 컨테이너 */ +.dynamic-row-container { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.volume-row, .remote-info-row { + display: flex; + gap: 0.5rem; +} + +.remote-info-row { + flex-direction: column; +} + +/* 파일 업로드 디스플레이 */ +.file-upload-display { + flex: 1; + border: 1px solid var(--hairline); + border-radius: 6px; + padding: 0 12px; + height: clamp(34px, 4.5vmin, 44px); + display: flex; + align-items: center; + font-size: var(--fs-sm); + color: var(--mute); + background-color: var(--canvas-soft); +} + +.btn-circle-remove { + width: clamp(34px, 4.5vmin, 44px); + height: clamp(34px, 4.5vmin, 44px); + border-radius: 50% !important; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 !important; + color: var(--danger) !important; + border: 1px solid var(--danger) !important; + background: transparent; + font-size: 1.5rem !important; /* Larger X icon */ + line-height: 1; + flex-shrink: 0; + transition: all 0.2s; + cursor: pointer; +} + +.btn-circle-remove:hover { + background-color: var(--danger); + color: var(--white) !important; +} + + + +.modal-body { + padding: var(--spacing-base); + overflow-y: auto; + flex: 1; + background: var(--canvas); +} + +.grid-form { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--spacing-base); +} + +.form-group { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.form-group.full-width { + grid-column: span 2; +} + +/* Section Title for Grouping */ +.form-section-title { + grid-column: span 2; + font-size: var(--fs-xs); + font-weight: 600; + color: var(--mute); + padding: 1rem 0 0.5rem 0; + border-bottom: 1px solid var(--hairline); + margin-bottom: 1rem; + display: flex; + align-items: center; + text-transform: uppercase; + letter-spacing: -0.02em; +} + +.form-section-title:first-child { + padding-top: 0; +} + +.form-group label { + font-size: var(--fs-xs); + font-weight: 600; + color: var(--mute); +} + +.form-group input, +.form-group select, +.form-group textarea { + height: clamp(34px, 4.5vmin, 44px); + padding: 0 0.75rem; + border: 1px solid var(--hairline); + border-radius: 6px; + font-family: inherit; + font-size: var(--fs-sm); + outline: none; + transition: all 0.2s; + background-color: var(--canvas); + color: var(--primary); + box-sizing: border-box; +} + +.form-group input.font-mono, +.form-group select.font-mono { + font-family: var(--font-mono); + font-size: var(--fs-xs); +} + +.form-group textarea { + height: auto; + padding: 0.75rem; + resize: none; +} + +.form-group input:focus, +.form-group select:focus, +.form-group textarea:focus { + border-color: var(--primary); + box-shadow: 0 0 0 1px var(--primary); +} + +.form-group input:disabled, +.form-group select:disabled { + background-color: var(--canvas-soft); + color: var(--mute); + cursor: not-allowed; +} + +.modal-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--hairline); + display: flex; + justify-content: space-between; + align-items: center; + background-color: var(--canvas-soft); + flex-shrink: 0; +} + +.modal-footer .btn { + height: 36px; + padding: 0 1.25rem; + font-size: var(--fs-xs); +} + +.footer-actions { + display: flex; + gap: 0.75rem; +} + +/* Modal Size Variants */ +.modal-content.wide { + max-width: 1000px; +} + +.modal-content.narrow { + max-width: 500px; +} + +.vertical-form { + grid-template-columns: 1fr !important; +} + + +.modal-body-split { + display: flex; + gap: 2rem; + min-height: 500px; +} + +.modal-form-area { + flex: 1.2; +} + +.modal-history-area { + flex: 0.8; + border-left: 1px solid var(--hairline); + padding-left: 2rem; + display: flex; + flex-direction: column; +} + +.history-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; +} + +.history-header h3 { + font-size: var(--fs-md); + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; + color: var(--primary); +} + +/* 읽기 전용 필드 (자산번호 등) 통일 스타일 */ +.is-readonly-field { + border-color: transparent !important; + background-color: transparent !important; + pointer-events: none !important; + color: var(--primary) !important; + font-weight: 600 !important; + cursor: default; + padding-left: 0 !important; +} + +/* 조회 모드 (View Mode) 폼 필드 스타일 */ +.is-view-mode input, +.is-view-mode select, +.is-view-mode textarea { + border-color: transparent !important; + background-color: transparent !important; + color: var(--primary) !important; + font-weight: 600 !important; + padding-left: 0 !important; + -webkit-appearance: none !important; + -moz-appearance: none !important; + appearance: none !important; + box-shadow: none !important; +} + +/* 입력 필드 + 버튼 그룹 */ +.input-with-btn { + display: flex; + gap: 0.5rem; + align-items: center; + width: 100%; +} + +.input-with-btn input { + flex: 1; +} + +/* Remote Info UI Extras */ +.ri-creds-row { + display: flex; + gap: 0.5rem; + flex: 1; +} + +.ri-field-group { + display: flex; + align-items: center; + gap: 0.4rem; + background: var(--canvas-soft); + padding: 0 0.75rem; + border-radius: 6px; + border: 1px solid var(--hairline); + flex: 1; +} + +.is-view-mode .ri-field-group { + background: transparent; + border-color: transparent; + padding: 0; +} + +.ri-mini-label { + font-size: 10px; + font-weight: 700; + color: var(--mute); + text-transform: uppercase; + user-select: none; + flex-shrink: 0; +} + +.ri-field-group input { + border: none !important; + background: transparent !important; + padding: 0 !important; + height: 36px !important; + box-shadow: none !important; + width: 100%; +} + +.ri-line { + display: flex; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; +} + +.ri-connector { + width: 24px; + height: 24px; + border-left: 2px solid var(--hairline); + border-bottom: 2px solid var(--hairline); + margin-left: 12px; + margin-top: -12px; + flex-shrink: 0; +} +.history-timeline { + flex: 1; + overflow-y: auto; + padding-right: 12px; +} + +.history-item { + position: relative; + padding-left: 24px; + padding-bottom: 24px; + border-left: 1px solid var(--hairline); +} + +.history-item:last-child { + border-left: 1px solid transparent; +} + +.history-item::before { + content: ''; + position: absolute; + left: -5px; + top: 0; + width: 9px; + height: 9px; + border-radius: 50%; + background-color: var(--canvas); + border: 2px solid var(--primary); + z-index: 1; +} + +.history-date { + font-size: var(--fs-xs); + color: var(--mute); + font-weight: 500; + margin-bottom: 4px; +} + +.history-details { + font-size: var(--fs-xs); + color: var(--primary); + line-height: 1.6; + background: var(--canvas-soft-2); + padding: 10px 12px; + border-radius: 6px; + border: 1px solid var(--hairline); +} + +/* Upload UI Refinement */ +.upload-sidebar { + width: 260px; + border-right: 1px solid var(--hairline); + background-color: var(--canvas-soft); + padding: 1.5rem 1rem; +} + +.upload-tab-btn { + padding: 0.8rem 1rem; + border-radius: 8px; + cursor: pointer; + font-size: var(--fs-xs); + font-weight: 500; + color: var(--body); + transition: all 0.2s; + border: 1px solid transparent; +} + +.upload-tab-btn.active { + background-color: var(--canvas); + color: var(--primary); + border-color: var(--hairline); + box-shadow: 0 1px 2px rgba(0,0,0,0.05); + font-weight: 600; +} + +/* --- Image Picker Overlay (View Location) --- */ +.image-picker-overlay { + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + padding: 2rem; +} + +.image-picker-window { + width: 100%; + max-width: 900px; + height: 85vh; + display: flex; + flex-direction: column; + background: var(--canvas); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); +} + +.image-picker-header { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + background: var(--canvas); + color: var(--primary); + padding: 1rem 1.5rem; + border-bottom: 1px solid var(--hairline); +} + +.image-picker-header h3 { + margin: 0; + font-size: var(--fs-md); + font-weight: 600; + color: var(--primary); +} + +.image-picker-header .btn-icon { + color: var(--mute) !important; + cursor: pointer; + background: none !important; + border: none !important; + font-size: 1.5rem; + line-height: 1; +} + +.image-picker-content { + flex: 1; + width: 100%; + position: relative; + background: var(--canvas-soft); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; + box-shadow: 0 4px 20px rgba(0,0,0,0.15); +} + +.image-picker-footer { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + width: 100%; + padding: 1rem 1.5rem; + background: var(--canvas); + border-radius: 0 0 8px 8px; + border-top: 1px solid var(--hairline); +} + +.layout-map-container { + position: relative; + display: inline-block; + cursor: crosshair; + background-color: var(--canvas-soft-2); + border-radius: 4px; + overflow: hidden; + max-width: 100%; + max-height: 100%; +} + +.layout-map-container.readonly { + cursor: default; +} + +.image-marker-wrapper { + position: relative; + display: inline-block; + max-width: 100%; + max-height: 100%; +} + +.layout-map-img { + display: block; + max-width: 100%; + max-height: 70vh; + object-fit: contain; + user-select: none; + -webkit-user-drag: none; +} + + +.layout-marker { + position: absolute; + width: 16px; + height: 16px; + background-color: var(--danger); + border: 2px solid white; + border-radius: 50%; + transform: translate(-50%, -50%); + pointer-events: none; + z-index: 100; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); +} + +.pulse-marker::after { + content: ''; + position: absolute; + top: 50%; left: 50%; + width: 100%; height: 100%; + background-color: var(--danger); + border-radius: 50%; + transform: translate(-50%, -50%); + animation: pulse 1.5s infinite; + z-index: -1; +} + +@keyframes pulse { + 0% { transform: translate(-50%, -50%) scale(1); opacity: 0.8; } + 100% { transform: translate(-50%, -50%) scale(3); opacity: 0; } +} + +.digital-overlay-layer { + position: absolute; + top: 0; left: 0; right: 0; bottom: 0; + pointer-events: none; +} diff --git a/vite.config.ts b/vite.config.ts index 5e89a3c..a3787f6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,29 +1,29 @@ -import { defineConfig } from 'vite'; -import { resolve } from 'path'; - -const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000'; - -export default defineConfig({ - server: { - port: 8080, - host: true, // Listen on all local IPs - proxy: { - '/api': { - target: proxyTarget, - changeOrigin: true, - }, - '/uploads': { - target: proxyTarget, - changeOrigin: true, - } - } - }, - build: { - rollupOptions: { - input: { - main: resolve(__dirname, 'index.html'), - map_editor: resolve(__dirname, 'map_editor.html'), - } - } - } -}); +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000'; + +export default defineConfig({ + server: { + port: 8080, + host: true, // Listen on all local IPs + proxy: { + '/api': { + target: proxyTarget, + changeOrigin: true, + }, + '/uploads': { + target: proxyTarget, + changeOrigin: true, + } + } + }, + build: { + rollupOptions: { + input: { + main: resolve(__dirname, 'index.html'), + map_editor: resolve(__dirname, 'map_editor.html'), + } + } + } +});