style: unify UI styling & restore dashboard logic

- Restored HW/SW Dashboard full features (Chart.js, filters, tables) from main
- Unified Search Bar & Filter Bar across all views (List, Location)
- Integrated asset identity info into all Modal Headers
- Standardized 'Remove Row' buttons as high-visibility circular circles
- Centralized hardcoded inline styles into dedicated CSS files
- Fixed various ReferenceErrors and layout regressions in HWModal
This commit is contained in:
2026-06-17 12:29:26 +09:00
parent b37981506e
commit 89d3ac2e89
16 changed files with 1440 additions and 596 deletions

View File

@@ -16,7 +16,10 @@ class DomainAssetModal extends BaseModal {
<div id="domain-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="domain-modal-title" class="modal-title">${this.title}</h2>
<div class="header-left">
<h2 id="domain-modal-title" class="modal-title">${this.title}</h2>
<div id="domain-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-domain-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
@@ -158,6 +161,7 @@ class DomainAssetModal extends BaseModal {
setFieldValue('domain-remarks', asset.remarks || '');
this.renderHistory(asset.id);
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
@@ -166,6 +170,28 @@ class DomainAssetModal extends BaseModal {
const deleteBtn = document.getElementById('btn-delete-domain-asset');
if (deleteBtn) deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('domain-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const type = getFieldValue('domain-type') || asset.type || '';
const serviceName = getFieldValue('domain-service-name') || asset.service_name || '';
const domainName = getFieldValue('domain-name') || asset.domain_name || '';
container.innerHTML = `
<span class="asset-code-title">${serviceName}</span>
<span class="service-type-badge">${type}</span>
<span class="asset-type-label">${domainName}</span>
`;
}
private renderHistory(assetId: string) {

View File

@@ -30,9 +30,9 @@ class HwAssetModal extends BaseModal {
<div id="hw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<div class="header-left" style="display: flex; align-items: center; gap: 0.75rem;">
<div class="header-left">
<h2 id="hw-modal-title" class="modal-title">${this.title}</h2>
<div class="category-badge-wrapper" id="hw-category-badge-container"></div>
<div id="hw-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
@@ -134,17 +134,17 @@ class HwAssetModal extends BaseModal {
<label>${ASSET_SCHEMA.OS.ui}</label>
<input type="text" id="hw-os" name="os" />
</div>
<div class="form-group spec-only" style="position: relative;">
<div class="form-group spec-only relative">
<label>${ASSET_SCHEMA.CPU.ui}</label>
<input type="text" id="hw-cpu" name="cpu" autocomplete="off" />
<div id="hw-cpu-list" class="autocomplete-list hidden"></div>
</div>
<div class="form-group spec-only" style="position: relative;">
<div class="form-group spec-only relative">
<label>${ASSET_SCHEMA.RAM.ui}</label>
<input type="text" id="hw-ram" name="ram" autocomplete="off" />
<div id="hw-ram-list" class="autocomplete-list hidden"></div>
</div>
<div class="form-group spec-only" style="position: relative;">
<div class="form-group spec-only relative">
<label>${ASSET_SCHEMA.GPU.ui}</label>
<input type="text" id="hw-gpu" name="gpu" autocomplete="off" />
<div id="hw-gpu-list" class="autocomplete-list hidden"></div>
@@ -165,16 +165,16 @@ class HwAssetModal extends BaseModal {
<!-- 동적 볼륨 정보 -->
<div class="form-group full-width spec-only">
<label>디스크 구성 (Volume)</label>
<div id="hw-volume-container" style="display: flex; flex-direction: column; gap: 8px;"></div>
<button type="button" id="btn-add-volume" class="btn btn-outline btn-sm" style="margin-top: 8px;">+ 볼륨 추가</button>
<div id="hw-volume-container" class="dynamic-row-container"></div>
<button type="button" id="btn-add-volume" class="btn btn-outline btn-sm">+ 볼륨 추가</button>
</div>
<!-- [SECTION 4] 네트워크 및 원격 정보 -->
<div class="form-section-title net-only">네트워크 및 원격 관리</div>
<div class="form-group full-width net-only">
<label>접속 정보 (IP / MAC / Remote)</label>
<div id="hw-remote-info-container" style="display: flex; flex-direction: column; gap: 12px;"></div>
<button type="button" id="btn-add-remote-info" class="btn btn-outline btn-sm" style="margin-top: 8px;">+ 접속 정보 추가</button>
<div id="hw-remote-info-container" class="dynamic-row-container"></div>
<button type="button" id="btn-add-remote-info" class="btn btn-outline btn-sm">+ 접속 정보 추가</button>
</div>
<!-- [SECTION 5] 위치 정보 -->
@@ -186,7 +186,7 @@ class HwAssetModal extends BaseModal {
<div class="form-group location-field">
<label>상세 위치</label>
<div class="input-with-btn">
<select id="hw-location_detail" name="location_detail" style="flex: 1;">
<select id="hw-location_detail" name="location_detail">
<option value="">층을 먼저 선택하세요</option>
</select>
<button type="button" id="btn-reg-loc-map" class="btn btn-outline hidden">위치 등록</button>
@@ -216,9 +216,9 @@ class HwAssetModal extends BaseModal {
<label>${ASSET_SCHEMA.APPROVAL_DOC.ui}</label>
<div class="input-with-btn">
<input type="hidden" id="hw-approval_document" name="approval_document" />
<div id="hw-file-name-display" class="is-readonly-field" style="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);">파일 선택...</div>
<div id="hw-file-name-display" class="file-upload-display">파일 선택...</div>
<div id="hw-file-link-container"></div>
<input type="file" id="hw-approval_document_file" style="display: none;" />
<input type="file" id="hw-approval_document_file" class="hidden" />
<button type="button" id="btn-file-select" class="btn btn-outline">파일 찾기</button>
</div>
</div>
@@ -247,34 +247,6 @@ class HwAssetModal extends BaseModal {
</div>
</div>
<style>
.autocomplete-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 150px;
overflow-y: auto;
background-color: white;
border: 1px solid var(--border-color, #E2E8F0);
border-top: none;
border-radius: 0 0 4px 4px;
z-index: 1000;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.autocomplete-item {
padding: 8px 12px;
font-size: 13px;
color: #334155;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.autocomplete-item:hover {
background-color: #F1F5F9;
color: #1E5149;
font-weight: 600;
}
.hidden {
display: none !important;
}
@@ -303,13 +275,16 @@ class HwAssetModal extends BaseModal {
typeSelect.innerHTML = types.length > 0 ? generateOptionsHTML(types, '', true) : '<option value="">구분을 먼저 선택하세요</option>';
this.applyRoleVisibility();
const badgeContainer = document.getElementById('hw-category-badge-container');
if (badgeContainer) {
badgeContainer.innerHTML = cat ? `<span class="badge badge-primary">${cat}</span>` : '';
const identityContainer = document.getElementById('hw-header-identity');
if (identityContainer) {
identityContainer.innerHTML = cat ? `<span class="service-type-badge">${cat}</span>` : '';
}
});
typeSelect.addEventListener('change', () => this.applyRoleVisibility());
typeSelect.addEventListener('change', () => {
this.applyRoleVisibility();
this.updateHeaderIdentity(this.currentAsset);
});
bindLocationEvents('hw-bldg-select', 'hw-location_detail', '', '');
bldgSelect.addEventListener('change', () => setTimeout(() => this.updateMapButtonVisibility(), 100));
@@ -439,22 +414,21 @@ class HwAssetModal extends BaseModal {
const container = document.getElementById('hw-volume-container');
if (!container) return;
const row = document.createElement('div');
row.className = 'volume-row flex items-center gap-2';
const inputStyle = 'height: clamp(34px, 4.5vmin, 44px) !important; box-sizing: border-box !important; font-size: var(--fs-sm); margin: 0; padding: 0 8px;';
row.className = 'volume-row items-center';
row.innerHTML = `
<select class="vol-type" style="${inputStyle} width: 80px;" ${!this.isEditMode ? 'disabled' : ''}>
<select class="vol-type" style="width: 80px; flex-shrink: 0;" ${!this.isEditMode ? 'disabled' : ''}>
<option value="SSD" ${vol.type === 'SSD' ? 'selected' : ''}>SSD</option>
<option value="HDD" ${vol.type === 'HDD' ? 'selected' : ''}>HDD</option>
<option value="NVMe" ${vol.type === 'NVMe' ? 'selected' : ''}>NVMe</option>
</select>
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" style="${inputStyle} flex: 1;" ${!this.isEditMode ? 'readonly' : ''} />
<select class="vol-unit" style="${inputStyle} width: 70px;" ${!this.isEditMode ? 'disabled' : ''}>
<input type="number" class="vol-cap" value="${vol.capacity || ''}" placeholder="용량" style="flex: 1; min-width: 0;" ${!this.isEditMode ? 'readonly' : ''} />
<select class="vol-unit" style="width: 70px; flex-shrink: 0;" ${!this.isEditMode ? 'disabled' : ''}>
<option value="GB" ${vol.unit === 'GB' ? 'selected' : ''}>GB</option>
<option value="TB" ${vol.unit === 'TB' ? 'selected' : ''}>TB</option>
</select>
<button type="button" class="btn btn-outline btn-remove-row edit-only-btn" style="height: clamp(34px, 4.5vmin, 44px) !important; padding: 0 12px; color: var(--danger); border-color: var(--danger); display: ${this.isEditMode ? 'inline-flex' : 'none'};">&times;</button>
<button type="button" class="btn-circle-remove edit-only-btn" style="display: ${this.isEditMode ? 'inline-flex' : 'none'};">&times;</button>
`;
row.querySelector('.btn-remove-row')?.addEventListener('click', () => row.remove());
row.querySelector('.btn-circle-remove')?.addEventListener('click', () => row.remove());
container.appendChild(row);
}
@@ -475,34 +449,32 @@ class HwAssetModal extends BaseModal {
}
const row = document.createElement('div');
row.className = 'remote-info-row flex-col gap-1 w-full';
const baseStyle = 'height: clamp(34px, 4.5vmin, 44px) !important; box-sizing: border-box !important; margin: 0;';
const compactStyle = `${baseStyle} font-size: var(--fs-xs); padding: 0 6px;`;
row.className = 'remote-info-row w-full';
const line1 = document.createElement('div');
line1.className = 'ri-line flex items-center gap-1.5';
line1.className = 'ri-line items-center';
line1.innerHTML = `
<select class="ri-type" ${!this.isEditMode ? 'disabled' : ''} style="${compactStyle} width: 75px; flex-shrink: 0;">
<select class="ri-type" ${!this.isEditMode ? 'disabled' : ''} style="width: 75px; flex-shrink: 0; font-size: var(--fs-xs); padding: 0 6px;">
<option value="IP" ${info.type === 'IP' ? 'selected' : ''}>IP 주소</option>
<option value="MAC" ${info.type === 'MAC' ? 'selected' : ''}>MAC 주소</option>
</select>
<input type="text" class="ri-val1" value="${info.val1 || ''}" placeholder="주소 입력" ${!this.isEditMode ? 'readonly' : ''} style="${compactStyle} flex: 1; min-width: 0;" />
<button type="button" class="btn-outline btn-remove-row ri-remove-btn edit-only-btn" style="height: clamp(34px, 4.5vmin, 44px) !important; padding: 0 10px; color: var(--danger); border-color: var(--danger); flex-shrink: 0; display: ${this.isEditMode ? 'inline-flex' : 'none'};">&times;</button>
<input type="text" class="ri-val1" value="${info.val1 || ''}" placeholder="주소 입력" ${!this.isEditMode ? 'readonly' : ''} style="flex: 1; min-width: 0; font-size: var(--fs-xs); padding: 0 6px;" />
<button type="button" class="btn-circle-remove edit-only-btn" style="display: ${this.isEditMode ? 'inline-flex' : 'none'};">&times;</button>
`;
const line2 = document.createElement('div');
line2.className = 'ri-line ri-cred-line flex items-center gap-1.5';
line2.className = 'ri-line ri-cred-line items-center';
if (info.type !== 'IP') line2.classList.add('hidden');
line2.innerHTML = `
<div class="ri-connector" style="width: 16px; border-left: 1px solid var(--hairline); border-bottom: 1px solid var(--hairline); height: 16px; margin-left: 10px; margin-top: -12px; flex-shrink: 0;"></div>
<select class="ri-tool" ${!this.isEditMode ? 'disabled' : ''} style="${compactStyle} width: 85px; flex-shrink: 0;">
<div class="ri-connector" style="width: 16px; height: 16px; margin-top: -12px;"></div>
<select class="ri-tool" ${!this.isEditMode ? 'disabled' : ''} style="width: 85px; flex-shrink: 0; font-size: var(--fs-xs); padding: 0 6px;">
<option value="원격접속" ${info.name === '원격접속' ? 'selected' : ''}>원격접속</option>
<option value="리눅스" ${info.name === '리눅스' ? 'selected' : ''}>리눅스</option>
<option value="기타" ${info.name === '기타' ? 'selected' : ''}>기타</option>
</select>
<input type="text" class="ri-id" value="${parsedId}" placeholder="원격 ID" ${!this.isEditMode ? 'readonly' : ''} style="${compactStyle} flex: 1; min-width: 0;" />
<input type="text" class="ri-pw" value="${parsedPw}" placeholder="원격 PW" ${!this.isEditMode ? 'readonly' : ''} style="${compactStyle} flex: 1; min-width: 0;" />
<input type="text" class="ri-id" value="${parsedId}" placeholder="원격 ID" ${!this.isEditMode ? 'readonly' : ''} style="flex: 1; min-width: 0; font-size: var(--fs-xs); padding: 0 6px;" />
<input type="text" class="ri-pw" value="${parsedPw}" placeholder="원격 PW" ${!this.isEditMode ? 'readonly' : ''} style="flex: 1; min-width: 0; font-size: var(--fs-xs); padding: 0 6px;" />
`;
row.appendChild(line1);
@@ -518,7 +490,7 @@ class HwAssetModal extends BaseModal {
}
});
row.querySelector('.btn-remove-row')?.addEventListener('click', () => row.remove());
row.querySelector('.btn-circle-remove')?.addEventListener('click', () => row.remove());
container.appendChild(row);
}
@@ -633,11 +605,7 @@ class HwAssetModal extends BaseModal {
this.renderHistory(asset.id);
this.applyRoleVisibility();
this.updatePcGradeBadge();
const badgeContainer = document.getElementById('hw-category-badge-container');
if (badgeContainer) {
badgeContainer.innerHTML = asset.category ? `<span class="badge badge-primary">${asset.category}</span>` : '';
}
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
@@ -647,6 +615,29 @@ class HwAssetModal extends BaseModal {
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 = '<span class="badge badge-primary">신규 등록</span>';
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 = `
<span class="asset-code-title">${code}</span>
<span class="service-type-badge">${serviceType}</span>
<span class="service-type-badge" style="background: var(--canvas-soft-2); color: var(--primary); border: 1px solid var(--hairline);">${category}</span>
<span class="asset-type-label">${type}</span>
`;
}
private toggleFileUploadUI(showUpload: boolean) {
@@ -723,40 +714,115 @@ class HwAssetModal extends BaseModal {
overlay.className = 'image-picker-overlay';
const renderContent = () => {
const imgPath = imagePaths[currentIdx];
const digitalMap = this.generateDynamicSVG(imgPath);
const isMulti = imagePaths.length > 1;
const isHtmlMap = imgPath.toLowerCase().endsWith('.html');
const digitalMap = isHtmlMap ? '' : this.generateDynamicSVG(imgPath);
overlay.innerHTML = `
<div class="image-picker-window">
<div class="image-picker-header"><h3>${title}</h3><button class="btn-icon btn-close-picker">&times;</button></div>
<div class="image-picker-header">
<h3>${title} ${isMulti ? `(${currentIdx + 1}/${imagePaths.length})` : ''}</h3>
<button class="btn-icon btn-close-picker" aria-label="닫기">&times;</button>
</div>
<div class="image-picker-content">
${isMulti ? `
<div class="picker-nav prev ${currentIdx === 0 ? 'disabled' : ''}" style="position: absolute; left: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">&#10094;</div>
<div class="picker-nav next ${currentIdx === imagePaths.length - 1 ? 'disabled' : ''}" style="position: absolute; right: 10px; top: 50%; transform: translateY(-50%); z-index: 100; cursor: pointer; background: rgba(0,0,0,0.5); color: white; padding: 20px 10px; border-radius: 5px; font-size: 24px; user-select: none;">&#10095;</div>
` : ''}
<div class="layout-map-container" id="picker-container">
<div class="image-marker-wrapper" style="position: relative; display: inline-block;">
<img src="${imgPath}" class="layout-map-img" style="display: block; max-width: 100%; max-height: 70vh;" />
<div id="picker-marker" class="layout-marker hidden"></div>
<div class="digital-overlay-layer">${digitalMap}</div>
</div>
${isHtmlMap
? `<iframe src="${imgPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
: `<div class="image-marker-wrapper">
<img src="${imgPath}" class="layout-map-img" />
<div id="picker-marker" class="layout-marker hidden"></div>
<div class="digital-overlay-layer">${digitalMap}</div>
</div>`
}
</div>
</div>
<div class="image-picker-footer"><button id="btn-picker-cancel" class="btn btn-outline">취소</button><button id="btn-picker-save" class="btn btn-primary">위치 확정</button></div>
</div>`;
let selectedX = ''; let selectedY = '';
const container = overlay.querySelector('#picker-container') as HTMLElement;
const marker = overlay.querySelector('#picker-marker') as HTMLElement;
container.addEventListener('click', (e) => {
const rect = container.getBoundingClientRect();
const x = ((e.clientX - rect.left) / rect.width) * 100;
const y = ((e.clientY - rect.top) / rect.height) * 100;
selectedX = x.toFixed(2); selectedY = y.toFixed(2);
marker.style.left = `${selectedX}%`; marker.style.top = `${selectedY}%`;
marker.classList.remove('hidden');
});
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();
});
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);
}
@@ -770,27 +836,34 @@ class HwAssetModal extends BaseModal {
overlay.innerHTML = `
<div class="image-picker-window">
<div class="image-picker-header"><h3>${title}</h3><button class="btn-icon btn-close-picker">&times;</button></div>
<div class="image-picker-header"><h3>${title}</h3><button class="btn-icon btn-close-picker" aria-label="닫기">&times;</button></div>
<div class="image-picker-content">
<div class="layout-map-container readonly">
<div class="image-marker-wrapper" style="position: relative; display: inline-block;">
<img src="${imagePath}" class="layout-map-img" style="display: block; max-width: 100%; max-height: 70vh;" />
<div class="digital-overlay-layer">${digitalMap}</div>
</div>
${isHtmlMap
? `<iframe src="${finalPath}" style="width:100%; height:100%; border:none; display:block;"></iframe>`
: `<div class="image-marker-wrapper">
<img src="${imagePath}" class="layout-map-img" />
<div id="preview-marker" class="layout-marker pulse-marker" style="left:${x}%; top:${y}%;"></div>
<div class="digital-overlay-layer">${digitalMap}</div>
</div>`
}
</div>
</div>
<div class="image-picker-footer"><button id="btn-preview-close" class="btn btn-primary">확인</button></div>
</div>`;
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.1 && Math.abs(sy - curY) < 0.1) {
rect.style.fill = 'rgba(255, 61, 0, 0.5)';
rect.style.stroke = '#FF3D00'; rect.style.strokeWidth = '1.2';
rect.style.filter = 'drop-shadow(0 0 6px rgba(255, 61, 0, 0.8))';
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}%`; }
}
});
}

View File

@@ -184,9 +184,9 @@ export function createModalFrameHTML(
</div>
<div class="modal-history-area">
<div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> ${options.historyTitle}</h3>
<button type="button" id="${options.addLogBtnId}" class="btn btn-outline btn-sm">
내역 추가 <i data-lucide="plus" style="width:14px; height:14px;"></i>
<h3><i data-lucide="history" class="icon-sm"></i> ${options.historyTitle}</h3>
<button type="button" id="btn-add-${idPrefix}-log" class="btn btn-outline btn-sm">
내역 추가 <i data-lucide="plus" class="icon-sm"></i>
</button>
</div>
<div id="${idPrefix}-history-list" class="history-timeline"></div>

View File

@@ -508,7 +508,7 @@ export class PCFlowModal {
</div>
<!-- 3. 새 인수자 검색 (이동 시 노출) -->
<div id="target-user-search-container" class="form-group hidden" style="position: relative;">
<div id="target-user-search-container" class="form-group hidden relative">
<label>새 인수 사원 검색</label>
<div class="input-with-icon">
<input type="text" id="pc-flow-target-user-search" placeholder="사원명, 부서, 사번 검색..." />
@@ -518,7 +518,7 @@ export class PCFlowModal {
</div>
<!-- 4. 재고 PC 검색 (불출 시 노출) -->
<div id="stock-pc-search-container" class="form-group" style="position: relative;">
<div id="stock-pc-search-container" class="form-group relative">
<label>3. 불출할 재고 PC 선택</label>
<div class="input-with-icon">
<input type="text" id="pc-flow-stock-search" placeholder="자산코드 또는 모델명 검색..." />
@@ -547,7 +547,7 @@ export class PCFlowModal {
<h3>선택 내역 요약</h3>
</div>
<div style="display: flex; flex-direction: column; gap: 1rem;">
<div class="dynamic-row-container">
<!-- 사원 요약 카드 -->
<div id="summary-user-card" class="summary-info-card">
<div class="detail-label-sm">대상 사원</div>
@@ -556,7 +556,7 @@ export class PCFlowModal {
</div>
<!-- 인수 사원 요약 카드 (이동 전용) -->
<div id="summary-target-user-card" class="summary-info-card hidden" style="background: var(--primary-light);">
<div id="summary-target-user-card" class="summary-info-card hidden bg-primary-light">
<div class="detail-label-sm">새 인수 사원</div>
<div id="summary-target-user-name" class="detail-value-lg">선택된 사원 없음</div>
<div id="summary-target-user-dept" class="detail-label-sm">-</div>
@@ -565,7 +565,7 @@ export class PCFlowModal {
<!-- 대상 PC 자산 요약 카드 -->
<div id="summary-pc-card" class="summary-info-card">
<div class="detail-label-sm">대상 PC 자산</div>
<div id="summary-pc-code" class="detail-value-lg" style="color: var(--success);">선택된 PC 없음</div>
<div id="summary-pc-code" class="detail-value-lg text-success">선택된 PC 없음</div>
<div id="summary-pc-model" class="detail-label-sm">-</div>
</div>

View File

@@ -12,13 +12,16 @@ class PartsMasterModal extends BaseModal {
protected renderFrameHTML(): string {
return `
<div id="parts-master-asset-modal" class="modal-overlay hidden">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-content narrow">
<div class="modal-header">
<h2 id="parts-master-modal-title" class="modal-title">${this.title}</h2>
<div class="header-left">
<h2 id="parts-master-modal-title" class="modal-title">${this.title}</h2>
<div id="parts-master-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-parts-master-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<form id="parts-master-asset-form" class="grid-form" style="grid-template-columns: 1fr;">
<form id="parts-master-asset-form" class="grid-form vertical-form">
<input type="hidden" id="parts-master-id" name="id" />
<div class="form-group">
@@ -120,6 +123,7 @@ class PartsMasterModal extends BaseModal {
setFieldValue('parts-master-component-name', asset.component_name || '');
setFieldValue('parts-master-score-tier', asset.score_tier || '');
setFieldValue('parts-master-deduction', asset.deduction !== undefined ? asset.deduction.toString() : '0');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
@@ -144,6 +148,25 @@ class PartsMasterModal extends BaseModal {
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('parts-master-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const cat = asset.category || '';
const name = asset.component_name || '';
container.innerHTML = `
<span class="asset-code-title">${name}</span>
<span class="service-type-badge">${cat}</span>
`;
}
}

View File

@@ -22,7 +22,10 @@ class SwAssetModal extends BaseModal {
<div id="sw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<h2 id="sw-modal-title" class="modal-title">${this.title}</h2>
<div class="header-left">
<h2 id="sw-modal-title" class="modal-title">${this.title}</h2>
<div id="sw-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-sw-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
@@ -170,14 +173,14 @@ class SwAssetModal extends BaseModal {
</div>
<!-- 계약 업데이트 서브 모달 -->
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 500px;">
<div id="sw-update-modal" class="modal-overlay hidden sub-modal">
<div class="modal-content narrow">
<div class="modal-header">
<h2 class="modal-title">계약 업데이트 반영</h2>
<button id="btn-close-sw-update" class="btn-icon">&times;</button>
</div>
<div class="modal-body">
<div class="grid-form" style="grid-template-columns: 1fr;">
<div class="grid-form vertical-form">
<div class="form-group">
<label>업데이트 일자</label>
<input type="date" id="sw-update-date" />
@@ -266,7 +269,7 @@ class SwAssetModal extends BaseModal {
const log = { assetId: this.currentAsset.id, date, details: `[계약갱신] ${note} (${start} ~ ${end}, 비용: ${cost})`, user: '관리자' };
await fetch(`${API_BASE_URL}/api/asset/history/batch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
headers: 'application/json',
body: JSON.stringify([...state.masterData.logs, log])
});
@@ -330,10 +333,32 @@ class SwAssetModal extends BaseModal {
}
this.renderHistory(asset.id);
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
this.applySwTypeUI(asset.asset_type || asset.type);
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('sw-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const type = getFieldValue('sw-asset-type') || asset.asset_type || asset.type || '';
const name = getFieldValue('sw-제품명') || asset.product_name || '';
const corp = getFieldValue('sw-법인') || asset.purchase_corp || '';
container.innerHTML = `
<span class="asset-code-title">${name}</span>
<span class="service-type-badge">${corp}</span>
<span class="asset-type-label">${type}</span>
`;
}
private applySwTypeUI(type: string) {

View File

@@ -22,9 +22,9 @@ class SwUserModal extends BaseModal {
<div class="modal-body">
<div class="sw-info-summary" id="sw-user-sw-info"></div>
<div class="user-list-toolbar" style="display:flex; justify-content:space-between; margin-bottom:1rem; align-items:center;">
<h3 class="detail-section-title">할당된 사용자 목록</h3>
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus"></i> 사용자 추가</button>
<div class="flex justify-between items-center mb-4">
<h3 class="detail-section-title mb-0">할당된 사용자 목록</h3>
<button type="button" id="btn-open-add-user" class="btn btn-primary btn-sm"><i data-lucide="plus" class="icon-sm"></i> 사용자 추가</button>
</div>
<div class="table-container">
@@ -35,9 +35,9 @@ class SwUserModal extends BaseModal {
<th>부서</th>
<th>직위</th>
<th>이름</th>
<th>사용기간</th>
<th>신청서</th>
<th>관리</th>
<th class="text-center">사용기간</th>
<th class="text-center">신청서</th>
<th class="text-center">관리</th>
</tr>
</thead>
<tbody id="sw-user-table-body"></tbody>
@@ -54,14 +54,14 @@ class SwUserModal extends BaseModal {
</div>
<!-- 사용자 추가/수정 서브 모달 -->
<div id="sw-user-edit-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 400px;">
<div id="sw-user-edit-modal" class="modal-overlay hidden sub-modal">
<div class="modal-content narrow">
<div class="modal-header">
<h3 id="sw-user-edit-title" class="modal-title">사용자 정보</h3>
<button id="btn-close-user-edit" class="btn-icon">&times;</button>
</div>
<div class="modal-body">
<form id="sw-user-edit-form" class="grid-form" style="grid-template-columns: 1fr;">
<form id="sw-user-edit-form" class="grid-form vertical-form">
<input type="hidden" id="edit-user-index" value="-1" />
<div class="form-group">
<label>조직</label>
@@ -84,7 +84,7 @@ class SwUserModal extends BaseModal {
<div class="input-with-btn">
<input type="text" id="new-user-시작일" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-시작일-picker'); p.value = document.getElementById('new-user-시작일').value; p.showPicker();">
<i data-lucide="calendar"></i>
<i data-lucide="calendar" class="icon-sm"></i>
</button>
<input type="date" id="new-user-시작일-picker" class="hidden-picker" onchange="document.getElementById('new-user-시작일').value = this.value" tabindex="-1" />
</div>
@@ -94,7 +94,7 @@ class SwUserModal extends BaseModal {
<div class="input-with-btn">
<input type="text" id="new-user-종료일" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('new-user-종료일-picker'); p.value = document.getElementById('new-user-종료일').value; p.showPicker();">
<i data-lucide="calendar"></i>
<i data-lucide="calendar" class="icon-sm"></i>
</button>
<input type="date" id="new-user-종료일-picker" class="hidden-picker" onchange="document.getElementById('new-user-종료일').value = this.value" tabindex="-1" />
</div>
@@ -163,7 +163,7 @@ class SwUserModal extends BaseModal {
protected fillFormData(asset: any): void {
const swInfo = document.getElementById('sw-user-sw-info')!;
swInfo.innerHTML = `
<div class="sw-info-header" style="margin-bottom: 1.5rem; border-bottom: 1px solid var(--hairline); padding-bottom: 1rem;">
<div class="sw-info-header border-b border-hairline pb-4 mb-6">
<div class="detail-label-sm">${asset.purchase_corp || asset. || ''}</div>
<div class="asset-code-title">${asset.product_name || asset. || ''}</div>
</div>
@@ -184,7 +184,7 @@ class SwUserModal extends BaseModal {
if (!tbody) return;
tbody.innerHTML = '';
if (this.tempSwUsers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center; padding:2rem; color:var(--mute);">할당된 사용자가 없습니다.</td></tr>';
tbody.innerHTML = '<tr><td colspan="7" class="empty-cell text-center p-8">할당된 사용자가 없습니다.</td></tr>';
return;
}
@@ -195,12 +195,12 @@ class SwUserModal extends BaseModal {
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td>${user. || ''}</td>
<td style="text-align:center;">${user. ? '<i data-lucide="paperclip" class="text-primary"></i>' : '-'}</td>
<td>
<div style="display:flex; gap:0.5rem;">
<td class="text-center">${user. || ''}</td>
<td class="text-center">${user. ? '<i data-lucide="paperclip" class="text-primary icon-sm"></i>' : '-'}</td>
<td class="text-center">
<div class="flex gap-2 justify-center items-center">
<button class="btn btn-outline btn-sm btn-edit-user" data-idx="${idx}">수정</button>
<button class="btn btn-outline btn-sm btn-danger btn-del-user" data-idx="${idx}">삭제</button>
<button class="btn-circle-remove btn-del-user" data-idx="${idx}">&times;</button>
</div>
</td>
`;

View File

@@ -12,13 +12,16 @@ class UserModal extends BaseModal {
protected renderFrameHTML(): string {
return `
<div id="user-asset-modal" class="modal-overlay hidden">
<div class="modal-content" style="max-width: 500px;">
<div class="modal-content narrow">
<div class="modal-header">
<h2 id="user-modal-title" class="modal-title">${this.title}</h2>
<div class="header-left">
<h2 id="user-modal-title" class="modal-title">${this.title}</h2>
<div id="user-header-identity" class="header-identity"></div>
</div>
<button id="btn-close-user-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
<div class="modal-body">
<form id="user-asset-form" class="grid-form" style="grid-template-columns: 1fr;">
<form id="user-asset-form" class="grid-form vertical-form">
<input type="hidden" id="user-id" name="id" />
<div class="form-group">
@@ -127,6 +130,7 @@ class UserModal extends BaseModal {
setFieldValue('user-dept', asset.dept_name || '');
setFieldValue('user-position-input', asset.position || '');
setFieldValue('user-status', asset.status || '재직');
this.updateHeaderIdentity(asset);
}
protected onAfterOpen(asset: any, mode: string): void {
@@ -151,6 +155,27 @@ class UserModal extends BaseModal {
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
this.updateHeaderIdentity(asset);
}
private updateHeaderIdentity(asset: any) {
const container = document.getElementById('user-header-identity');
if (!container) return;
if (this.currentMode === 'add') {
container.innerHTML = '<span class="badge badge-primary">신규 등록</span>';
return;
}
const empNo = asset.emp_no || '';
const userName = asset.user_name || '';
const dept = asset.dept_name || '';
container.innerHTML = `
<span class="asset-code-title">${userName}</span>
<span class="service-type-badge">${empNo}</span>
<span class="asset-type-label">${dept}</span>
`;
}
}